Added signed URLs (#25197)
For #24869 This subtask contains code to sign the CloudFront software installer and bootstrap package URL using AWS SDK URL signer. It works with the current bootstrap package delivery. For software installers, fleetd will need to be modified to take advantage of this URL in a future subtask (which will also include updated API contributor docs). My article on signed URLs, for context: https://victoronsoftware.com/posts/cloudfront-signed-urls/ # Checklist for submitter - [x] Added/updated automated tests - [x] Manual QA for all new/changed functionality
This commit is contained in:
+5
-3
@@ -633,6 +633,7 @@ func newWorkerIntegrationsSchedule(
|
||||
logger kitlog.Logger,
|
||||
depStorage *mysql.NanoDEPStorage,
|
||||
commander *apple_mdm.MDMAppleCommander,
|
||||
bootstrapPackageStore fleet.MDMBootstrapPackageStore,
|
||||
) (*schedule.Schedule, error) {
|
||||
const (
|
||||
name = string(fleet.CronWorkerIntegrations)
|
||||
@@ -681,9 +682,10 @@ func newWorkerIntegrationsSchedule(
|
||||
DEPClient: depCli,
|
||||
}
|
||||
appleMDM := &worker.AppleMDM{
|
||||
Datastore: ds,
|
||||
Log: logger,
|
||||
Commander: commander,
|
||||
Datastore: ds,
|
||||
Log: logger,
|
||||
Commander: commander,
|
||||
BootstrapPackageStore: bootstrapPackageStore,
|
||||
}
|
||||
dbMigrate := &worker.DBMigration{
|
||||
Datastore: ds,
|
||||
|
||||
+20
-2
@@ -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 != "" {
|
||||
@@ -914,7 +932,7 @@ the way that the Fleet server works.
|
||||
|
||||
if err := cronSchedules.StartCronSchedule(func() (fleet.CronSchedule, error) {
|
||||
commander := apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService)
|
||||
return newWorkerIntegrationsSchedule(ctx, instanceID, ds, logger, depStorage, commander)
|
||||
return newWorkerIntegrationsSchedule(ctx, instanceID, ds, logger, depStorage, commander, bootstrapPackageStore)
|
||||
}); err != nil {
|
||||
initFatal(err, "failed to register worker integrations schedule")
|
||||
}
|
||||
|
||||
@@ -797,10 +797,70 @@ 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")
|
||||
}
|
||||
|
||||
// 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)
|
||||
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 +880,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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
+41
-24
@@ -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"`
|
||||
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)
|
||||
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"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -4,15 +4,20 @@ 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"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
)
|
||||
|
||||
// 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 +139,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.Wrapf(ctx, fleet.ErrNotConfigured, "signing %s URL in S3 store", s.fileLabel)
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -24,3 +24,22 @@ func NewSoftwareInstallerStore(config config.S3Config) (*SoftwareInstallerStore,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewTestSoftwareInstallerStore is used in tests.
|
||||
func NewTestSoftwareInstallerStore(conf config.S3Config) (*SoftwareInstallerStore, error) {
|
||||
store := &s3store{
|
||||
bucket: "test-bucket",
|
||||
cloudFrontConfig: &config.S3CloudFrontConfig{
|
||||
BaseURL: conf.SoftwareInstallersCloudFrontURL,
|
||||
SigningPublicKeyID: conf.SoftwareInstallersCloudFrontURLSigningPublicKeyID,
|
||||
Signer: conf.SoftwareInstallersCloudFrontSigner,
|
||||
},
|
||||
}
|
||||
return &SoftwareInstallerStore{
|
||||
&commonFileStore{
|
||||
s3store: store,
|
||||
pathPrefix: softwareInstallersPrefix,
|
||||
fileLabel: "software installer",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -912,6 +912,7 @@ type MDMBootstrapPackageStore interface {
|
||||
Put(ctx context.Context, packageID string, content io.ReadSeeker) error
|
||||
Exists(ctx context.Context, packageID string) (bool, error)
|
||||
Cleanup(ctx context.Context, usedPackageIDs []string, removeCreatedBefore time.Time) (int, error)
|
||||
Sign(ctx context.Context, fileID string) (string, error)
|
||||
}
|
||||
|
||||
// MDMAppleMachineInfo is a [device's information][1] sent as part of an MDM enrollment profile request
|
||||
|
||||
@@ -19,6 +19,7 @@ var (
|
||||
ErrPasswordResetRequired = &passwordResetRequiredError{}
|
||||
ErrMissingLicense = &licenseError{}
|
||||
ErrMDMNotConfigured = &MDMNotConfiguredError{}
|
||||
ErrNotConfigured = &NotConfiguredError{}
|
||||
|
||||
MDMNotConfiguredMessage = "MDM features aren't turned on in Fleet. For more information about setting up MDM, please visit https://fleetdm.com/docs/using-fleet"
|
||||
WindowsMDMNotConfiguredMessage = "Windows MDM isn't turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM."
|
||||
@@ -350,6 +351,14 @@ func (e *MDMNotConfiguredError) Error() string {
|
||||
return MDMNotConfiguredMessage
|
||||
}
|
||||
|
||||
// NotConfiguredError is a generic "not configured" error that can be used
|
||||
// when expected configuration is missing.
|
||||
type NotConfiguredError struct{}
|
||||
|
||||
func (e *NotConfiguredError) Error() string {
|
||||
return "not configured"
|
||||
}
|
||||
|
||||
// GatewayError is an error type that generates a 502 or 504 status code.
|
||||
type GatewayError struct {
|
||||
Message string
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
//go:generate go run ./mockimpl/impl.go -o nanodep/storage.go "s *Storage" "github.com/fleetdm/fleet/v4/server/mdm/nanodep/storage.AllDEPStorage"
|
||||
//go:generate go run ./mockimpl/impl.go -o mdm/datastore_mdm_mock.go "fs *MDMAppleStore" "fleet.MDMAppleStore"
|
||||
//go:generate go run ./mockimpl/impl.go -o scep/depot.go "d *Depot" "depot.Depot"
|
||||
//go:generate go run ./mockimpl/impl.go -o mdm/bootstrap_package_store.go "s *MDMBootstrapPackageStore" "fleet.MDMBootstrapPackageStore"
|
||||
//go:generate go run ./mockimpl/impl.go -o software/software_installer_store.go "s *SoftwareInstallerStore" "fleet.SoftwareInstallerStore"
|
||||
|
||||
var _ fleet.Datastore = (*Store)(nil)
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// Automatically generated by mockimpl. DO NOT EDIT!
|
||||
|
||||
package mock
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
)
|
||||
|
||||
var _ fleet.MDMBootstrapPackageStore = (*MDMBootstrapPackageStore)(nil)
|
||||
|
||||
type GetFunc func(ctx context.Context, packageID string) (io.ReadCloser, int64, error)
|
||||
|
||||
type PutFunc func(ctx context.Context, packageID string, content io.ReadSeeker) error
|
||||
|
||||
type ExistsFunc func(ctx context.Context, packageID string) (bool, error)
|
||||
|
||||
type CleanupFunc func(ctx context.Context, usedPackageIDs []string, removeCreatedBefore time.Time) (int, error)
|
||||
|
||||
type SignFunc func(ctx context.Context, fileID string) (string, error)
|
||||
|
||||
type MDMBootstrapPackageStore struct {
|
||||
GetFunc GetFunc
|
||||
GetFuncInvoked bool
|
||||
|
||||
PutFunc PutFunc
|
||||
PutFuncInvoked bool
|
||||
|
||||
ExistsFunc ExistsFunc
|
||||
ExistsFuncInvoked bool
|
||||
|
||||
CleanupFunc CleanupFunc
|
||||
CleanupFuncInvoked bool
|
||||
|
||||
SignFunc SignFunc
|
||||
SignFuncInvoked bool
|
||||
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (fs *MDMBootstrapPackageStore) Get(ctx context.Context, packageID string) (io.ReadCloser, int64, error) {
|
||||
fs.mu.Lock()
|
||||
fs.GetFuncInvoked = true
|
||||
fs.mu.Unlock()
|
||||
return fs.GetFunc(ctx, packageID)
|
||||
}
|
||||
|
||||
func (fs *MDMBootstrapPackageStore) Put(ctx context.Context, packageID string, content io.ReadSeeker) error {
|
||||
fs.mu.Lock()
|
||||
fs.PutFuncInvoked = true
|
||||
fs.mu.Unlock()
|
||||
return fs.PutFunc(ctx, packageID, content)
|
||||
}
|
||||
|
||||
func (fs *MDMBootstrapPackageStore) Exists(ctx context.Context, packageID string) (bool, error) {
|
||||
fs.mu.Lock()
|
||||
fs.ExistsFuncInvoked = true
|
||||
fs.mu.Unlock()
|
||||
return fs.ExistsFunc(ctx, packageID)
|
||||
}
|
||||
|
||||
func (fs *MDMBootstrapPackageStore) Cleanup(ctx context.Context, usedPackageIDs []string, removeCreatedBefore time.Time) (int, error) {
|
||||
fs.mu.Lock()
|
||||
fs.CleanupFuncInvoked = true
|
||||
fs.mu.Unlock()
|
||||
return fs.CleanupFunc(ctx, usedPackageIDs, removeCreatedBefore)
|
||||
}
|
||||
|
||||
func (fs *MDMBootstrapPackageStore) Sign(ctx context.Context, fileID string) (string, error) {
|
||||
fs.mu.Lock()
|
||||
fs.SignFuncInvoked = true
|
||||
fs.mu.Unlock()
|
||||
return fs.SignFunc(ctx, fileID)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Automatically generated by mockimpl. DO NOT EDIT!
|
||||
|
||||
package mock
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
)
|
||||
|
||||
var _ fleet.SoftwareInstallerStore = (*SoftwareInstallerStore)(nil)
|
||||
|
||||
type GetFunc func(ctx context.Context, installerID string) (io.ReadCloser, int64, error)
|
||||
|
||||
type PutFunc func(ctx context.Context, installerID string, content io.ReadSeeker) error
|
||||
|
||||
type ExistsFunc func(ctx context.Context, installerID string) (bool, error)
|
||||
|
||||
type CleanupFunc func(ctx context.Context, usedInstallerIDs []string, removeCreatedBefore time.Time) (int, error)
|
||||
|
||||
type SignFunc func(ctx context.Context, fileID string) (string, error)
|
||||
|
||||
type SoftwareInstallerStore struct {
|
||||
GetFunc GetFunc
|
||||
GetFuncInvoked bool
|
||||
|
||||
PutFunc PutFunc
|
||||
PutFuncInvoked bool
|
||||
|
||||
ExistsFunc ExistsFunc
|
||||
ExistsFuncInvoked bool
|
||||
|
||||
CleanupFunc CleanupFunc
|
||||
CleanupFuncInvoked bool
|
||||
|
||||
SignFunc SignFunc
|
||||
SignFuncInvoked bool
|
||||
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (s *SoftwareInstallerStore) Get(ctx context.Context, installerID string) (io.ReadCloser, int64, error) {
|
||||
s.mu.Lock()
|
||||
s.GetFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.GetFunc(ctx, installerID)
|
||||
}
|
||||
|
||||
func (s *SoftwareInstallerStore) Put(ctx context.Context, installerID string, content io.ReadSeeker) error {
|
||||
s.mu.Lock()
|
||||
s.PutFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.PutFunc(ctx, installerID, content)
|
||||
}
|
||||
|
||||
func (s *SoftwareInstallerStore) Exists(ctx context.Context, installerID string) (bool, error) {
|
||||
s.mu.Lock()
|
||||
s.ExistsFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.ExistsFunc(ctx, installerID)
|
||||
}
|
||||
|
||||
func (s *SoftwareInstallerStore) Cleanup(ctx context.Context, usedInstallerIDs []string, removeCreatedBefore time.Time) (int, error) {
|
||||
s.mu.Lock()
|
||||
s.CleanupFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.CleanupFunc(ctx, usedInstallerIDs, removeCreatedBefore)
|
||||
}
|
||||
|
||||
func (s *SoftwareInstallerStore) Sign(ctx context.Context, fileID string) (string, error) {
|
||||
s.mu.Lock()
|
||||
s.SignFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.SignFunc(ctx, fileID)
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/config"
|
||||
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
|
||||
"github.com/fleetdm/fleet/v4/server/datastore/s3"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
software_mock "github.com/fleetdm/fleet/v4/server/mock/software"
|
||||
"github.com/go-kit/log"
|
||||
kitlog "github.com/go-kit/log"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
func TestIntegrationsInstall(t *testing.T) {
|
||||
testingSuite := new(integrationInstallTestSuite)
|
||||
testingSuite.withServer.s = &testingSuite.Suite
|
||||
suite.Run(t, testingSuite)
|
||||
}
|
||||
|
||||
type integrationInstallTestSuite struct {
|
||||
withServer
|
||||
suite.Suite
|
||||
softwareInstallStore *software_mock.SoftwareInstallerStore
|
||||
}
|
||||
|
||||
func (s *integrationInstallTestSuite) SetupSuite() {
|
||||
s.withDS.SetupSuite("integrationInstallTestSuite")
|
||||
|
||||
// Create a mock S3 software install store
|
||||
softwareInstallStore := &software_mock.SoftwareInstallerStore{}
|
||||
s.softwareInstallStore = softwareInstallStore
|
||||
|
||||
fleetConfig := config.TestConfig()
|
||||
signer, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||
fleetConfig.S3.SoftwareInstallersCloudFrontSigner = signer
|
||||
installConfig := TestServerOpts{
|
||||
License: &fleet.LicenseInfo{
|
||||
Tier: fleet.TierPremium,
|
||||
},
|
||||
Logger: log.NewLogfmtLogger(os.Stdout),
|
||||
EnableCachedDS: true,
|
||||
SoftwareInstallStore: softwareInstallStore,
|
||||
FleetConfig: &fleetConfig,
|
||||
}
|
||||
if os.Getenv("FLEET_INTEGRATION_TESTS_DISABLE_LOG") != "" {
|
||||
installConfig.Logger = kitlog.NewNopLogger()
|
||||
}
|
||||
users, server := RunServerForTestsWithDS(s.T(), s.ds, &installConfig)
|
||||
s.server = server
|
||||
s.users = users
|
||||
s.token = s.getTestAdminToken()
|
||||
s.cachedTokens = make(map[string]string)
|
||||
}
|
||||
|
||||
func (s *integrationInstallTestSuite) TearDownTest() {
|
||||
s.withServer.commonTearDownTest(s.T())
|
||||
}
|
||||
|
||||
// TestSoftwareInstallerSignedURL tests that the software installer signed URL is returned.
|
||||
// We test using both mock and real fleet.SoftwareInstallerStore.Sign functions.
|
||||
func (s *integrationInstallTestSuite) TestSoftwareInstallerSignedURL() {
|
||||
t := s.T()
|
||||
|
||||
openFile := func(name string) *os.File {
|
||||
f, err := os.Open(filepath.Join("testdata", "software-installers", name))
|
||||
require.NoError(t, err)
|
||||
return f
|
||||
}
|
||||
|
||||
filename := "ruby.deb"
|
||||
var expectBytes []byte
|
||||
var expectLen int
|
||||
f := openFile(filename)
|
||||
st, err := f.Stat()
|
||||
require.NoError(t, err)
|
||||
expectLen = int(st.Size())
|
||||
require.Equal(t, expectLen, 11340)
|
||||
expectBytes = make([]byte, expectLen)
|
||||
n, err := f.Read(expectBytes)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, n, expectLen)
|
||||
f.Close()
|
||||
|
||||
// Set up mocks
|
||||
var myInstallerID string
|
||||
s.softwareInstallStore.ExistsFunc = func(ctx context.Context, installerID string) (bool, error) {
|
||||
return installerID == myInstallerID, nil
|
||||
}
|
||||
s.softwareInstallStore.PutFunc = func(ctx context.Context, installerID string, content io.ReadSeeker) error {
|
||||
myInstallerID = installerID
|
||||
return nil
|
||||
}
|
||||
s.softwareInstallStore.SignFunc = func(ctx context.Context, fileID string) (string, error) {
|
||||
return "https://example.com/signed", nil
|
||||
}
|
||||
|
||||
var createTeamResp teamResponse
|
||||
s.DoJSON("POST", "/api/latest/fleet/teams", &fleet.Team{
|
||||
Name: t.Name(),
|
||||
}, http.StatusOK, &createTeamResp)
|
||||
require.NotZero(t, createTeamResp.Team.ID)
|
||||
|
||||
payload := &fleet.UploadSoftwareInstallerPayload{
|
||||
TeamID: &createTeamResp.Team.ID,
|
||||
InstallScript: "another install script",
|
||||
PreInstallQuery: "another pre install query",
|
||||
PostInstallScript: "another post install script",
|
||||
Filename: filename,
|
||||
// additional fields below are pre-populated so we can re-use the payload later for the test assertions
|
||||
Title: "ruby",
|
||||
Version: "1:2.5.1",
|
||||
Source: "deb_packages",
|
||||
StorageID: "df06d9ce9e2090d9cb2e8cd1f4d7754a803dc452bf93e3204e3acd3b95508628",
|
||||
Platform: "linux",
|
||||
SelfService: true,
|
||||
}
|
||||
s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
|
||||
|
||||
// check the software installer
|
||||
var id uint
|
||||
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
||||
return sqlx.GetContext(context.Background(), q, &id,
|
||||
`SELECT id FROM software_installers WHERE global_or_team_id = ? AND filename = ?`, payload.TeamID, payload.Filename)
|
||||
})
|
||||
require.NotZero(t, id)
|
||||
|
||||
meta, err := s.ds.GetSoftwareInstallerMetadataByID(context.Background(), id)
|
||||
require.NoError(t, err)
|
||||
titleID := *meta.TitleID
|
||||
|
||||
// create an orbit host, assign to team
|
||||
hostInTeam := createOrbitEnrolledHost(t, "linux", "orbit-host-team", s.ds)
|
||||
require.NoError(t, s.ds.AddHostsToTeam(context.Background(), &createTeamResp.Team.ID, []uint{hostInTeam.ID}))
|
||||
|
||||
// Create a software installation request
|
||||
s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", hostInTeam.ID, titleID), installSoftwareRequest{},
|
||||
http.StatusAccepted)
|
||||
|
||||
// Get the InstallerUUID
|
||||
var installUUID string
|
||||
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
||||
return sqlx.GetContext(context.Background(), q, &installUUID,
|
||||
"SELECT execution_id FROM host_software_installs WHERE host_id = ?", hostInTeam.ID)
|
||||
})
|
||||
|
||||
// Fetch installer details
|
||||
var orbitSoftwareResp orbitGetSoftwareInstallResponse
|
||||
s.DoJSON("POST", "/api/fleet/orbit/software_install/details", orbitGetSoftwareInstallRequest{
|
||||
InstallUUID: installUUID,
|
||||
OrbitNodeKey: *hostInTeam.OrbitNodeKey,
|
||||
}, http.StatusOK, &orbitSoftwareResp)
|
||||
assert.Equal(t, meta.InstallerID, orbitSoftwareResp.InstallerID)
|
||||
require.NotNil(t, orbitSoftwareResp.SoftwareInstallerURL)
|
||||
assert.Equal(t, "https://example.com/signed", orbitSoftwareResp.SoftwareInstallerURL.URL)
|
||||
require.Equal(t, filename, orbitSoftwareResp.SoftwareInstallerURL.Filename)
|
||||
|
||||
// Error in signing -- we simply don't return the URL
|
||||
s.softwareInstallStore.SignFunc = func(ctx context.Context, fileID string) (string, error) {
|
||||
return "", errors.New("error signing")
|
||||
}
|
||||
orbitSoftwareResp = orbitGetSoftwareInstallResponse{}
|
||||
s.DoJSON("POST", "/api/fleet/orbit/software_install/details", orbitGetSoftwareInstallRequest{
|
||||
InstallUUID: installUUID,
|
||||
OrbitNodeKey: *hostInTeam.OrbitNodeKey,
|
||||
}, http.StatusOK, &orbitSoftwareResp)
|
||||
assert.Equal(t, meta.InstallerID, orbitSoftwareResp.InstallerID)
|
||||
assert.Nil(t, orbitSoftwareResp.SoftwareInstallerURL)
|
||||
|
||||
// Now test with the real sign function
|
||||
signer, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||
|
||||
s3Config := config.S3Config{
|
||||
SoftwareInstallersCloudFrontURL: "https://example.cloudfront.net",
|
||||
SoftwareInstallersCloudFrontURLSigningPublicKeyID: "ABC123XYZ",
|
||||
SoftwareInstallersCloudFrontSigner: signer,
|
||||
}
|
||||
s3Store, err := s3.NewTestSoftwareInstallerStore(s3Config)
|
||||
require.NoError(t, err)
|
||||
s.softwareInstallStore.SignFunc = func(ctx context.Context, fileID string) (string, error) {
|
||||
return s3Store.Sign(ctx, fileID)
|
||||
}
|
||||
s.DoJSON("POST", "/api/fleet/orbit/software_install/details", orbitGetSoftwareInstallRequest{
|
||||
InstallUUID: installUUID,
|
||||
OrbitNodeKey: *hostInTeam.OrbitNodeKey,
|
||||
}, http.StatusOK, &orbitSoftwareResp)
|
||||
assert.Equal(t, meta.InstallerID, orbitSoftwareResp.InstallerID)
|
||||
require.NotNil(t, orbitSoftwareResp.SoftwareInstallerURL)
|
||||
assert.True(t,
|
||||
strings.HasPrefix(orbitSoftwareResp.SoftwareInstallerURL.URL,
|
||||
s3Config.SoftwareInstallersCloudFrontURL+"/software-installers/"+payload.StorageID+"?Expires="),
|
||||
orbitSoftwareResp.SoftwareInstallerURL.URL)
|
||||
assert.Contains(t, orbitSoftwareResp.SoftwareInstallerURL.URL, "&Signature=")
|
||||
assert.Contains(t, orbitSoftwareResp.SoftwareInstallerURL.URL,
|
||||
"&Key-Pair-Id="+s3Config.SoftwareInstallersCloudFrontURLSigningPublicKeyID)
|
||||
require.Equal(t, filename, orbitSoftwareResp.SoftwareInstallerURL.Filename)
|
||||
|
||||
}
|
||||
+45
-10
@@ -2,6 +2,7 @@ package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -38,9 +39,10 @@ const (
|
||||
|
||||
// AppleMDM is the job processor for the apple_mdm job.
|
||||
type AppleMDM struct {
|
||||
Datastore fleet.Datastore
|
||||
Log kitlog.Logger
|
||||
Commander *apple_mdm.MDMAppleCommander
|
||||
Datastore fleet.Datastore
|
||||
Log kitlog.Logger
|
||||
Commander *apple_mdm.MDMAppleCommander
|
||||
BootstrapPackageStore fleet.MDMBootstrapPackageStore
|
||||
}
|
||||
|
||||
// Name returns the name of the job.
|
||||
@@ -323,14 +325,19 @@ func (a *AppleMDM) installBootstrapPackage(ctx context.Context, hostUUID string,
|
||||
return "", err
|
||||
}
|
||||
|
||||
appCfg, err := a.Datastore.AppConfig(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Get CloudFront CDN signed URL if configured
|
||||
url := a.getSignedURL(ctx, meta)
|
||||
|
||||
url, err := meta.URL(appCfg.MDMUrl())
|
||||
if err != nil {
|
||||
return "", err
|
||||
if url == "" {
|
||||
appCfg, err := a.Datastore.AppConfig(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
url, err = meta.URL(appCfg.MDMUrl())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
manifest := appmanifest.NewFromSha(meta.Sha256, url)
|
||||
@@ -347,6 +354,34 @@ func (a *AppleMDM) installBootstrapPackage(ctx context.Context, hostUUID string,
|
||||
return cmdUUID, nil
|
||||
}
|
||||
|
||||
func (a *AppleMDM) getSignedURL(ctx context.Context, meta *fleet.MDMAppleBootstrapPackage) string {
|
||||
var url string
|
||||
if a.BootstrapPackageStore != nil {
|
||||
pkgID := hex.EncodeToString(meta.Sha256)
|
||||
signedURL, err := a.BootstrapPackageStore.Sign(ctx, pkgID)
|
||||
switch {
|
||||
case errors.Is(err, fleet.ErrNotConfigured):
|
||||
// no CDN configured, fall back to the MDM URL
|
||||
case err != nil:
|
||||
// log the error but continue with the MDM URL
|
||||
level.Error(a.Log).Log("msg", "failed to sign bootstrap package URL", "err", err)
|
||||
default:
|
||||
exists, err := a.BootstrapPackageStore.Exists(ctx, pkgID)
|
||||
switch {
|
||||
case err != nil:
|
||||
// log the error but continue with the MDM URL
|
||||
level.Error(a.Log).Log("msg", "failed to check if bootstrap package exists", "err", err)
|
||||
case !exists:
|
||||
// log the error but continue with the MDM URL
|
||||
level.Error(a.Log).Log("msg", "bootstrap package does not exist in package store", "pkg_id", pkgID)
|
||||
default:
|
||||
url = signedURL
|
||||
}
|
||||
}
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// QueueAppleMDMJob queues a apple_mdm job for one of the supported tasks, to
|
||||
// be processed asynchronously via the worker.
|
||||
func QueueAppleMDMJob(
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
@@ -12,10 +14,12 @@ import (
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
|
||||
nanomdm_push "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push"
|
||||
mock "github.com/fleetdm/fleet/v4/server/mock/mdm"
|
||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||||
kitlog "github.com/go-kit/log"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -601,3 +605,55 @@ func TestAppleMDM(t *testing.T) {
|
||||
require.Contains(t, string(*jobs[0].Args), AppleMDMPostDEPReleaseDeviceTask)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetSignedURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
meta := &fleet.MDMAppleBootstrapPackage{
|
||||
Sha256: []byte{1, 2, 3},
|
||||
}
|
||||
|
||||
var data []byte
|
||||
buf := bytes.NewBuffer(data)
|
||||
logger := kitlog.NewLogfmtLogger(buf)
|
||||
a := &AppleMDM{Log: logger}
|
||||
|
||||
// S3 not configured
|
||||
assert.Empty(t, a.getSignedURL(ctx, meta))
|
||||
assert.Empty(t, buf.String())
|
||||
|
||||
// Signer not configured
|
||||
mockStore := &mock.MDMBootstrapPackageStore{}
|
||||
a.BootstrapPackageStore = mockStore
|
||||
mockStore.SignFunc = func(ctx context.Context, fileID string) (string, error) {
|
||||
return "bozo", fleet.ErrNotConfigured
|
||||
}
|
||||
assert.Empty(t, a.getSignedURL(ctx, meta))
|
||||
assert.Empty(t, buf.String())
|
||||
|
||||
// Test happy path
|
||||
mockStore.SignFunc = func(ctx context.Context, fileID string) (string, error) {
|
||||
return "signed", nil
|
||||
}
|
||||
mockStore.ExistsFunc = func(ctx context.Context, packageID string) (bool, error) {
|
||||
assert.Equal(t, "010203", packageID)
|
||||
return true, nil
|
||||
}
|
||||
assert.Equal(t, "signed", a.getSignedURL(ctx, meta))
|
||||
assert.Empty(t, buf.String())
|
||||
assert.True(t, mockStore.SignFuncInvoked)
|
||||
assert.True(t, mockStore.ExistsFuncInvoked)
|
||||
mockStore.SignFuncInvoked = false
|
||||
mockStore.ExistsFuncInvoked = false
|
||||
|
||||
// Test error -- sign failed
|
||||
mockStore.SignFunc = func(ctx context.Context, fileID string) (string, error) {
|
||||
return "", errors.New("test error")
|
||||
}
|
||||
assert.Empty(t, a.getSignedURL(ctx, meta))
|
||||
assert.Contains(t, buf.String(), "test error")
|
||||
assert.True(t, mockStore.SignFuncInvoked)
|
||||
assert.False(t, mockStore.ExistsFuncInvoked)
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user