Skip to content

Commit

Permalink
Signed URLs for software installers.
Browse files Browse the repository at this point in the history
  • Loading branch information
getvictor committed Jan 7, 2025
1 parent ce3e284 commit ebbef5d
Show file tree
Hide file tree
Showing 10 changed files with 184 additions and 45 deletions.
20 changes: 19 additions & 1 deletion cmd/fleet/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"context"
"crypto"
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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")
Expand All @@ -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 != "" {
Expand Down
73 changes: 66 additions & 7 deletions ee/server/service/software_installers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand All @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
69 changes: 43 additions & 26 deletions server/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package config

import (
"context"
"crypto"
"crypto/tls"
"crypto/x509"
"encoding/json"
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"),
}
}

Expand Down
8 changes: 4 additions & 4 deletions server/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "" {
Expand All @@ -732,7 +732,7 @@ func TestValidateCloudfrontURL(t *testing.T) {
t.Errorf("unexpected error: %v", err)
}
}
s3.ValidateCloudfrontURL(initFatal)
s3.ValidateCloudFrontURL(initFatal)
})
}
}
4 changes: 4 additions & 0 deletions server/datastore/filesystem/software_installer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
22 changes: 21 additions & 1 deletion server/datastore/s3/common_file_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
14 changes: 8 additions & 6 deletions server/datastore/s3/s3.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down
Loading

0 comments on commit ebbef5d

Please sign in to comment.