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:
Victor Lyuboslavsky
2025-01-09 12:56:54 -06:00
committed by GitHub
parent 689e78a598
commit 68b7cf9141
20 changed files with 684 additions and 57 deletions
+5 -3
View File
@@ -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
View File
@@ -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")
}
+61 -7
View File
@@ -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) {
+1
View File
@@ -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
+4
View File
@@ -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
View File
@@ -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"),
}
}
+4 -4
View File
@@ -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 {
+22 -1
View File
@@ -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)
+8 -6
View File
@@ -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
}
+19
View File
@@ -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
}
+1
View File
@@ -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
+9
View File
@@ -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
+14
View File
@@ -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
+2
View File
@@ -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)
}
+212
View File
@@ -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
View File
@@ -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(
+56
View File
@@ -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)
}