add support for AWS SES email backend (#10847)

This commit is contained in:
Benjamin Edwards
2023-04-06 13:21:07 -05:00
committed by GitHub
parent cf874f2901
commit 6f836d60cb
23 changed files with 748 additions and 52 deletions
+37
View File
@@ -259,6 +259,20 @@ type KinesisConfig struct {
AuditStream string `yaml:"audit_stream"`
}
// SESConfig defines configs for the AWS SES service for emailing
type SESConfig struct {
Region string
EndpointURL string `yaml:"endpoint_url"`
AccessKeyID string `yaml:"access_key_id"`
SecretAccessKey string `yaml:"secret_access_key"`
StsAssumeRoleArn string `yaml:"sts_assume_role_arn"`
SourceArn string `yaml:"source_arn"`
}
type EmailConfig struct {
EmailBackend string `yaml:"backend"`
}
// LambdaConfig defines configs for the AWS Lambda logging plugin
type LambdaConfig struct {
Region string
@@ -391,6 +405,8 @@ type FleetConfig struct {
Kinesis KinesisConfig
Lambda LambdaConfig
S3 S3Config
Email EmailConfig
SES SESConfig
PubSub PubSubConfig
Filesystem FilesystemConfig
KafkaREST KafkaRESTConfig
@@ -862,6 +878,16 @@ func (man Manager) addConfigs() {
man.addConfigString("logging.tracing_type", "opentelemetry",
"Select the kind of tracing, defaults to opentelemetry, can also be elasticapm")
// Email
man.addConfigString("email.backend", "", "Provide the email backend type, acceptable values are currently \"ses\" and \"default\" or empty string which will default to SMTP")
// SES
man.addConfigString("ses.region", "", "AWS Region to use")
man.addConfigString("ses.endpoint_url", "", "AWS Service Endpoint to use (leave empty for default service endpoints)")
man.addConfigString("ses.access_key_id", "", "Access Key ID for AWS authentication")
man.addConfigString("ses.secret_access_key", "", "Secret Access Key for AWS authentication")
man.addConfigString("ses.sts_assume_role_arn", "", "ARN of role to assume for AWS")
man.addConfigString("ses.source_arn", "", "ARN of the identity that is associated with the sending authorization policy that permits you to send for the email address specified in the Source parameter")
// Firehose
man.addConfigString("firehose.region", "", "AWS Region to use")
man.addConfigString("firehose.endpoint_url", "",
@@ -1185,6 +1211,17 @@ func (man Manager) LoadConfig() FleetConfig {
DisableSSL: man.getConfigBool("s3.disable_ssl"),
ForceS3PathStyle: man.getConfigBool("s3.force_s3_path_style"),
},
Email: EmailConfig{
EmailBackend: man.getConfigString("email.backend"),
},
SES: SESConfig{
Region: man.getConfigString("ses.region"),
EndpointURL: man.getConfigString("ses.endpoint_url"),
AccessKeyID: man.getConfigString("ses.access_key_id"),
SecretAccessKey: man.getConfigString("ses.secret_access_key"),
StsAssumeRoleArn: man.getConfigString("ses.sts_assume_role_arn"),
SourceArn: man.getConfigString("ses.source_arn"),
},
PubSub: PubSubConfig{
Project: man.getConfigString("pubsub.project"),
StatusTopic: man.getConfigString("pubsub.status_topic"),
+11
View File
@@ -358,6 +358,7 @@ type enrichedAppConfigFields struct {
Vulnerabilities *VulnerabilitiesConfig `json:"vulnerabilities,omitempty"`
License *LicenseInfo `json:"license,omitempty"`
Logging *Logging `json:"logging,omitempty"`
Email *EmailConfig `json:"email,omitempty"`
}
// UnmarshalJSON implements the json.Unmarshaler interface to make sure we serialize
@@ -741,6 +742,16 @@ type Logging struct {
Audit LoggingPlugin `json:"audit"`
}
type EmailConfig struct {
Backend string `json:"backend"`
Config interface{} `json:"config"`
}
type SESConfig struct {
Region string `json:"region"`
SourceARN string `json:"source_arn"`
}
type UpdateIntervalConfig struct {
OSQueryDetail time.Duration `json:"osquery_detail"`
OSQueryPolicy time.Duration `json:"osquery_policy"`
+30 -27
View File
@@ -85,7 +85,7 @@ type Service interface {
// TODO: find if there's a better way to accomplish this and standardize.
SetEnterpriseOverrides(overrides EnterpriseOverrides)
///////////////////////////////////////////////////////////////////////////////
// /////////////////////////////////////////////////////////////////////////////
// UserService contains methods for managing a Fleet User.
// CreateUserFromInvite creates a new User from a request payload when there is already an existing invitation.
@@ -145,7 +145,7 @@ type Service interface {
// write the new email address to user.
ChangeUserEmail(ctx context.Context, token string) (string, error)
///////////////////////////////////////////////////////////////////////////////
// /////////////////////////////////////////////////////////////////////////////
// Session
// InitiateSSO is used to initiate an SSO session and returns a URL that can be used in a redirect to the IDP.
@@ -173,7 +173,7 @@ type Service interface {
GetSessionByKey(ctx context.Context, key string) (session *Session, err error)
DeleteSession(ctx context.Context, id uint) (err error)
///////////////////////////////////////////////////////////////////////////////
// /////////////////////////////////////////////////////////////////////////////
// PackService is the service interface for managing query packs.
// ApplyPackSpecs applies a list of PackSpecs to the datastore, creating and updating packs as necessary.
@@ -206,7 +206,7 @@ type Service interface {
// ListPacksForHost lists the packs that a host should execute.
ListPacksForHost(ctx context.Context, hid uint) (packs []*Pack, err error)
///////////////////////////////////////////////////////////////////////////////
// /////////////////////////////////////////////////////////////////////////////
// LabelService
// ApplyLabelSpecs applies a list of LabelSpecs to the datastore, creating and updating labels as necessary.
@@ -229,7 +229,7 @@ type Service interface {
// ListHostsInLabel returns a slice of hosts in the label with the given ID.
ListHostsInLabel(ctx context.Context, lid uint, opt HostListOptions) ([]*Host, error)
///////////////////////////////////////////////////////////////////////////////
// /////////////////////////////////////////////////////////////////////////////
// QueryService
// ApplyQuerySpecs applies a list of queries (creating or updating them as necessary)
@@ -252,7 +252,7 @@ type Service interface {
// along with any error.
DeleteQueries(ctx context.Context, ids []uint) (uint, error)
///////////////////////////////////////////////////////////////////////////////
// /////////////////////////////////////////////////////////////////////////////
// CampaignService defines the distributed query campaign related service methods
// NewDistributedQueryCampaignByNames creates a new distributed query campaign with the provided query (or the query
@@ -276,14 +276,14 @@ type Service interface {
CompleteCampaign(ctx context.Context, campaign *DistributedQueryCampaign) error
RunLiveQueryDeadline(ctx context.Context, queryIDs []uint, hostIDs []uint, deadline time.Duration) ([]QueryCampaignResult, int)
///////////////////////////////////////////////////////////////////////////////
// /////////////////////////////////////////////////////////////////////////////
// AgentOptionsService
// AgentOptionsForHost gets the agent options for the provided host. The host information should be used for
// filtering based on team, platform, etc.
AgentOptionsForHost(ctx context.Context, hostTeamID *uint, hostPlatform string) (json.RawMessage, error)
///////////////////////////////////////////////////////////////////////////////
// /////////////////////////////////////////////////////////////////////////////
// HostService
// AuthenticateDevice loads host identified by the device's auth token.
@@ -347,7 +347,7 @@ type Service interface {
// Name cannot be used without version, and conversely, version cannot be used without name.
OSVersions(ctx context.Context, teamID *uint, platform *string, name *string, version *string) (*OSVersions, error)
///////////////////////////////////////////////////////////////////////////////
// /////////////////////////////////////////////////////////////////////////////
// AppConfigService provides methods for configuring the Fleet application
NewAppConfig(ctx context.Context, p AppConfig) (info *AppConfig, err error)
@@ -378,6 +378,9 @@ type Service interface {
// LoggingConfig parses config.FleetConfig instance and returns a Logging.
LoggingConfig(ctx context.Context) (*Logging, error)
// EmailConfig parses config.FleetConfig and returns an EmailConfig
EmailConfig(ctx context.Context) (*EmailConfig, error)
// UpdateIntervalConfig returns the duration for different update intervals configured in osquery
UpdateIntervalConfig(ctx context.Context) (*UpdateIntervalConfig, error)
@@ -385,7 +388,7 @@ type Service interface {
// the fleet instance.
VulnerabilitiesConfig(ctx context.Context) (*VulnerabilitiesConfig, error)
///////////////////////////////////////////////////////////////////////////////
// /////////////////////////////////////////////////////////////////////////////
// InviteService contains methods for a service which deals with user invites.
// InviteNewUser creates an invite for a new user to join Fleet.
@@ -402,7 +405,7 @@ type Service interface {
UpdateInvite(ctx context.Context, id uint, payload InvitePayload) (*Invite, error)
///////////////////////////////////////////////////////////////////////////////
// /////////////////////////////////////////////////////////////////////////////
// TargetService **NOTE: SearchTargets will be removed in Fleet 5.0**
// SearchTargets will accept a search query, a slice of IDs of hosts to omit, and a slice of IDs of labels to omit,
@@ -418,7 +421,7 @@ type Service interface {
// observer role for.
CountHostsInTargets(ctx context.Context, queryID *uint, targets HostTargets) (*TargetMetrics, error)
///////////////////////////////////////////////////////////////////////////////
// /////////////////////////////////////////////////////////////////////////////
// ScheduledQueryService
GetScheduledQueriesInPack(ctx context.Context, id uint, opts ListOptions) (queries []*ScheduledQuery, err error)
@@ -427,7 +430,7 @@ type Service interface {
DeleteScheduledQuery(ctx context.Context, id uint) (err error)
ModifyScheduledQuery(ctx context.Context, id uint, p ScheduledQueryPayload) (query *ScheduledQuery, err error)
///////////////////////////////////////////////////////////////////////////////
// /////////////////////////////////////////////////////////////////////////////
// StatusService
// StatusResultStore returns nil if the result store is functioning correctly, or an error indicating the problem.
@@ -437,7 +440,7 @@ type Service interface {
// error indicating the problem.
StatusLiveQuery(ctx context.Context) error
///////////////////////////////////////////////////////////////////////////////
// /////////////////////////////////////////////////////////////////////////////
// CarveService
CarveBegin(ctx context.Context, payload CarveBeginPayload) (*CarveMetadata, error)
@@ -446,7 +449,7 @@ type Service interface {
ListCarves(ctx context.Context, opt CarveListOptions) ([]*CarveMetadata, error)
GetBlock(ctx context.Context, carveId, blockId int64) ([]byte, error)
///////////////////////////////////////////////////////////////////////////////
// /////////////////////////////////////////////////////////////////////////////
// TeamService
// NewTeam creates a new team.
@@ -476,7 +479,7 @@ type Service interface {
// ApplyTeamSpecs applies the changes for each team as defined in the specs.
ApplyTeamSpecs(ctx context.Context, specs []*TeamSpec, applyOpts ApplySpecOptions) error
///////////////////////////////////////////////////////////////////////////////
// /////////////////////////////////////////////////////////////////////////////
// ActivitiesService
// NewActivity creates the given activity on the datastore.
@@ -490,13 +493,13 @@ type Service interface {
// logins, running a live query, etc.
ListActivities(ctx context.Context, opt ListActivitiesOptions) ([]*Activity, *PaginationMetadata, error)
///////////////////////////////////////////////////////////////////////////////
// /////////////////////////////////////////////////////////////////////////////
// UserRolesService
// ApplyUserRolesSpecs applies a list of user global and team role changes
ApplyUserRolesSpecs(ctx context.Context, specs UsersRoleSpec) error
///////////////////////////////////////////////////////////////////////////////
// /////////////////////////////////////////////////////////////////////////////
// GlobalScheduleService
GlobalScheduleQuery(ctx context.Context, sq *ScheduledQuery) (*ScheduledQuery, error)
@@ -504,12 +507,12 @@ type Service interface {
ModifyGlobalScheduledQueries(ctx context.Context, id uint, q ScheduledQueryPayload) (*ScheduledQuery, error)
DeleteGlobalScheduledQueries(ctx context.Context, id uint) error
///////////////////////////////////////////////////////////////////////////////
// /////////////////////////////////////////////////////////////////////////////
// TranslatorService
Translate(ctx context.Context, payloads []TranslatePayload) ([]TranslatePayload, error)
///////////////////////////////////////////////////////////////////////////////
// /////////////////////////////////////////////////////////////////////////////
// TeamScheduleService
TeamScheduleQuery(ctx context.Context, teamID uint, sq *ScheduledQuery) (*ScheduledQuery, error)
@@ -519,7 +522,7 @@ type Service interface {
) (*ScheduledQuery, error)
DeleteTeamScheduledQueries(ctx context.Context, teamID uint, id uint) error
///////////////////////////////////////////////////////////////////////////////
// /////////////////////////////////////////////////////////////////////////////
// GlobalPolicyService
NewGlobalPolicy(ctx context.Context, p PolicyPayload) (*Policy, error)
@@ -529,14 +532,14 @@ type Service interface {
GetPolicyByIDQueries(ctx context.Context, policyID uint) (*Policy, error)
ApplyPolicySpecs(ctx context.Context, policies []*PolicySpec) error
///////////////////////////////////////////////////////////////////////////////
// /////////////////////////////////////////////////////////////////////////////
// Software
ListSoftware(ctx context.Context, opt SoftwareListOptions) ([]Software, error)
SoftwareByID(ctx context.Context, id uint, includeCVEScores bool) (*Software, error)
CountSoftware(ctx context.Context, opt SoftwareListOptions) (int, error)
///////////////////////////////////////////////////////////////////////////////
// /////////////////////////////////////////////////////////////////////////////
// Team Policies
NewTeamPolicy(ctx context.Context, teamID uint, p PolicyPayload) (*Policy, error)
@@ -545,18 +548,18 @@ type Service interface {
ModifyTeamPolicy(ctx context.Context, teamID uint, id uint, p ModifyPolicyPayload) (*Policy, error)
GetTeamPolicyByIDQueries(ctx context.Context, teamID uint, policyID uint) (*Policy, error)
///////////////////////////////////////////////////////////////////////////////
// /////////////////////////////////////////////////////////////////////////////
// Geolocation
LookupGeoIP(ctx context.Context, ip string) *GeoLocation
///////////////////////////////////////////////////////////////////////////////
// /////////////////////////////////////////////////////////////////////////////
// Installers
GetInstaller(ctx context.Context, installer Installer) (io.ReadCloser, int64, error)
CheckInstallerExistence(ctx context.Context, installer Installer) error
///////////////////////////////////////////////////////////////////////////////
// /////////////////////////////////////////////////////////////////////////////
// Apple MDM
GetAppleMDM(ctx context.Context) (*AppleMDM, error)
@@ -675,7 +678,7 @@ type Service interface {
// error can be raised to the user. See TODO for more details.
VerifyMDMAppleConfigured(ctx context.Context) error
///////////////////////////////////////////////////////////////////////////////
// /////////////////////////////////////////////////////////////////////////////
// CronSchedulesService
// TriggerCronSchedule attempts to trigger an ad-hoc run of the named cron schedule.
+21 -6
View File
@@ -13,11 +13,17 @@ import (
"time"
"github.com/fleetdm/fleet/v4/server/bindata"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/fleet"
)
func NewService() fleet.MailService {
return &mailService{}
func NewService(config config.FleetConfig) (fleet.MailService, error) {
switch strings.ToLower(config.Email.EmailBackend) {
case "ses":
return NewSESSender(config.SES.Region, config.SES.EndpointURL, config.SES.AccessKeyID, config.SES.SecretAccessKey, config.SES.StsAssumeRoleArn, config.SES.SourceArn)
default:
return &mailService{}, nil
}
}
type mailService struct{}
@@ -27,7 +33,7 @@ type sender interface {
}
func Test(mailer fleet.MailService, e fleet.Email) error {
mailBody, err := getMessageBody(e)
mailBody, err := getMessageBody(e, getFrom)
if err != nil {
return fmt.Errorf("failed to get message body: %w", err)
}
@@ -50,7 +56,9 @@ const (
PortTLS = 587
)
func getMessageBody(e fleet.Email) ([]byte, error) {
type fromFunc func(e fleet.Email) (string, error)
func getMessageBody(e fleet.Email, f fromFunc) ([]byte, error) {
body, err := e.Mailer.Message()
if err != nil {
return nil, fmt.Errorf("get mailer message: %w", err)
@@ -58,16 +66,23 @@ func getMessageBody(e fleet.Email) ([]byte, error) {
mime := `MIME-version: 1.0;` + "\r\n"
content := `Content-Type: text/html; charset="UTF-8";` + "\r\n"
subject := "Subject: " + e.Subject + "\r\n"
from := "From: " + e.Config.SMTPSettings.SMTPSenderAddress + "\r\n"
from, err := f(e)
if err != nil {
return nil, fmt.Errorf("failed to obtain from address: %w", err)
}
msg := []byte(subject + from + mime + content + "\r\n" + string(body) + "\r\n")
return msg, nil
}
func getFrom(e fleet.Email) (string, error) {
return "From: " + e.Config.SMTPSettings.SMTPSenderAddress + "\r\n", nil
}
func (m mailService) SendEmail(e fleet.Email) error {
if !e.Config.SMTPSettings.SMTPConfigured {
return errors.New("email not configured")
}
msg, err := getMessageBody(e)
msg, err := getMessageBody(e, getFrom)
if err != nil {
return err
}
+32 -1
View File
@@ -1,9 +1,11 @@
package mail
import (
"fmt"
"os"
"testing"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/stretchr/testify/assert"
@@ -26,7 +28,8 @@ func TestMail(t *testing.T) {
}
for _, f := range testFunctions {
r := NewService()
r, err := NewService(config.TestConfig())
require.NoError(t, err)
t.Run(test.FunctionName(f), func(t *testing.T) {
f(t, r)
@@ -178,3 +181,31 @@ func TestTemplateProcessor(t *testing.T) {
require.Nil(t, err)
assert.NotNil(t, out)
}
func Test_getFrom(t *testing.T) {
type args struct {
e fleet.Email
}
tests := []struct {
name string
args args
want string
wantErr assert.ErrorAssertionFunc
}{
{
name: "should return SMTP formatted From string",
args: args{e: fleet.Email{Config: &fleet.AppConfig{SMTPSettings: fleet.SMTPSettings{SMTPSenderAddress: "foo@bar.com"}}}},
want: "From: foo@bar.com\r\n",
wantErr: assert.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := getFrom(tt.args.e)
if !tt.wantErr(t, err, fmt.Sprintf("getFrom(%v)", tt.args.e)) {
return
}
assert.Equalf(t, tt.want, got, "getFrom(%v)", tt.args.e)
})
}
}
+95
View File
@@ -0,0 +1,95 @@
package mail
import (
"errors"
"fmt"
"net/url"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/credentials/stscreds"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/ses"
"github.com/fleetdm/fleet/v4/server/fleet"
)
type fleetSESSender interface {
SendRawEmail(input *ses.SendRawEmailInput) (*ses.SendRawEmailOutput, error)
}
type sesSender struct {
client fleetSESSender
sourceArn string
}
func getFromSES(e fleet.Email) (string, error) {
if e.Config == nil {
return "", errors.New("app config is nil")
}
serverURL, err := url.Parse(e.Config.ServerSettings.ServerURL)
if err != nil || len(serverURL.Host) == 0 {
return "", fmt.Errorf("failed to parse server url %s err: %w", e.Config.ServerSettings.ServerURL, err)
}
return fmt.Sprintf("From: %s\r\n", fmt.Sprintf("do-not-reply@%s", serverURL.Host)), nil
}
func (s *sesSender) SendEmail(e fleet.Email) error {
if s.client == nil {
return errors.New("ses sender not configured")
}
msg, err := getMessageBody(e, getFromSES)
if err != nil {
return err
}
return s.sendMail(e, msg)
}
func NewSESSender(region, endpointURL, id, secret, stsAssumeRoleArn, sourceArn string) (*sesSender, error) {
conf := &aws.Config{
Region: &region,
Endpoint: &endpointURL, // empty string or nil will use default values
}
// Only provide static credentials if we have them
// otherwise use the default credentials provider chain
if id != "" && secret != "" {
conf.Credentials = credentials.NewStaticCredentials(id, secret, "")
}
sess, err := session.NewSession(conf)
if err != nil {
return nil, fmt.Errorf("create SES client: %w", err)
}
if stsAssumeRoleArn != "" {
creds := stscreds.NewCredentials(sess, stsAssumeRoleArn)
conf.Credentials = creds
sess, err = session.NewSession(conf)
if err != nil {
return nil, fmt.Errorf("create SES client: %w", err)
}
}
return &sesSender{client: ses.New(sess), sourceArn: sourceArn}, nil
}
func (s *sesSender) sendMail(e fleet.Email, msg []byte) error {
toAddresses := make([]*string, len(e.To))
for i := range e.To {
t := e.To[i]
toAddresses[i] = &t
}
_, err := s.client.SendRawEmail(&ses.SendRawEmailInput{
Destinations: toAddresses,
FromArn: &s.sourceArn,
RawMessage: &ses.RawMessage{Data: msg},
SourceArn: &s.sourceArn,
})
if err != nil {
return err
}
return nil
}
+146
View File
@@ -0,0 +1,146 @@
package mail
import (
"errors"
"fmt"
"testing"
"github.com/aws/aws-sdk-go/service/ses"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/stretchr/testify/assert"
)
func Test_getFromSES(t *testing.T) {
type args struct {
e fleet.Email
}
tests := []struct {
name string
args args
want string
wantErr assert.ErrorAssertionFunc
}{
{
name: "should return properly formatted SMTP from for use in SES",
args: args{e: fleet.Email{Config: &fleet.AppConfig{ServerSettings: fleet.ServerSettings{ServerURL: "https://foobar.fleetdm.com"}}}},
want: "From: do-not-reply@foobar.fleetdm.com\r\n",
wantErr: assert.NoError,
},
{
name: "should error when we fail to parse fleet server url",
args: args{e: fleet.Email{Config: &fleet.AppConfig{ServerSettings: fleet.ServerSettings{ServerURL: "not-a-url"}}}},
want: "",
wantErr: assert.Error,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := getFromSES(tt.args.e)
if !tt.wantErr(t, err, fmt.Sprintf("getFromSES(%v)", tt.args.e)) {
return
}
assert.Equalf(t, tt.want, got, "getFromSES(%v)", tt.args.e)
})
}
}
type mockSESSender struct {
shouldErr bool
}
func (m mockSESSender) SendRawEmail(input *ses.SendRawEmailInput) (*ses.SendRawEmailOutput, error) {
if m.shouldErr {
return nil, errors.New("some error")
}
return nil, nil
}
func Test_sesSender_SendEmail(t *testing.T) {
type fields struct {
client fleetSESSender
sourceArn string
}
type args struct {
e fleet.Email
}
tests := []struct {
name string
fields fields
args args
wantErr assert.ErrorAssertionFunc
}{
{
name: "should send email",
fields: fields{
client: mockSESSender{shouldErr: false},
sourceArn: "foo",
},
args: args{e: fleet.Email{
Subject: "Hello from Fleet!",
To: []string{"foouser@fleetdm.com"},
Config: &fleet.AppConfig{ServerSettings: fleet.ServerSettings{ServerURL: "https://foobar.fleetdm.com"}},
Mailer: &SMTPTestMailer{
BaseURL: "https://localhost:8080",
},
}},
wantErr: assert.NoError,
},
{
name: "should error when email config is nil",
fields: fields{
client: mockSESSender{shouldErr: false},
sourceArn: "foo",
},
args: args{e: fleet.Email{
Subject: "Hello from Fleet!",
To: []string{"foouser@fleetdm.com"},
Config: nil,
Mailer: &SMTPTestMailer{
BaseURL: "https://localhost:8080",
},
}},
wantErr: assert.Error,
},
{
name: "should error when ses client is nil",
fields: fields{
client: nil,
sourceArn: "foo",
},
args: args{e: fleet.Email{
Subject: "Hello from Fleet!",
To: []string{"foouser@fleetdm.com"},
Config: &fleet.AppConfig{ServerSettings: fleet.ServerSettings{ServerURL: "https://foobar.fleetdm.com"}},
Mailer: &SMTPTestMailer{
BaseURL: "https://localhost:8080",
},
}},
wantErr: assert.Error,
},
{
name: "should error when ses client returns an error",
fields: fields{
client: mockSESSender{shouldErr: true},
sourceArn: "foo",
},
args: args{e: fleet.Email{
Subject: "Hello from Fleet!",
To: []string{"foouser@fleetdm.com"},
Config: &fleet.AppConfig{ServerSettings: fleet.ServerSettings{ServerURL: "https://foobar.fleetdm.com"}},
Mailer: &SMTPTestMailer{
BaseURL: "https://localhost:8080",
},
}},
wantErr: assert.Error,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &sesSender{
client: tt.fields.client,
sourceArn: tt.fields.sourceArn,
}
tt.wantErr(t, s.SendEmail(tt.args.e), fmt.Sprintf("SendEmail(%v)", tt.args.e))
})
}
}
+19 -12
View File
@@ -23,9 +23,9 @@ import (
"github.com/kolide/kit/version"
)
////////////////////////////////////////////////////////////////////////////////
// //////////////////////////////////////////////////////////////////////////////
// Get AppConfig
////////////////////////////////////////////////////////////////////////////////
// //////////////////////////////////////////////////////////////////////////////
type appConfigResponse struct {
fleet.AppConfig
@@ -41,6 +41,8 @@ type appConfigResponseFields struct {
License *fleet.LicenseInfo `json:"license,omitempty"`
// Logging is loaded on the fly rather than from the database.
Logging *fleet.Logging `json:"logging,omitempty"`
// Email is returned when the email backend is something other than SMTP, for example SES
Email *fleet.EmailConfig `json:"email,omitempty"`
// SandboxEnabled is true if fleet serve was ran with server.sandbox_enabled=true
SandboxEnabled bool `json:"sandbox_enabled,omitempty"`
Err error `json:"error,omitempty"`
@@ -82,6 +84,10 @@ func getAppConfigEndpoint(ctx context.Context, request interface{}, svc fleet.Se
if err != nil {
return nil, err
}
emailConfig, err := svc.EmailConfig(ctx)
if err != nil {
return nil, err
}
updateIntervalConfig, err := svc.UpdateIntervalConfig(ctx)
if err != nil {
return nil, err
@@ -134,6 +140,7 @@ func getAppConfigEndpoint(ctx context.Context, request interface{}, svc fleet.Se
Vulnerabilities: vulnConfig,
License: license,
Logging: loggingConfig,
Email: emailConfig,
SandboxEnabled: svc.SandboxEnabled(),
},
}
@@ -171,9 +178,9 @@ func (svc *Service) AppConfigObfuscated(ctx context.Context) (*fleet.AppConfig,
return ac, nil
}
////////////////////////////////////////////////////////////////////////////////
// //////////////////////////////////////////////////////////////////////////////
// Modify AppConfig
////////////////////////////////////////////////////////////////////////////////
// //////////////////////////////////////////////////////////////////////////////
type modifyAppConfigRequest struct {
Force bool `json:"-" query:"force,optional"` // if true, bypass strict incoming json validation
@@ -543,9 +550,9 @@ func validateSSOSettings(p fleet.AppConfig, existing *fleet.AppConfig, invalid *
}
}
////////////////////////////////////////////////////////////////////////////////
// //////////////////////////////////////////////////////////////////////////////
// Apply enroll secret spec
////////////////////////////////////////////////////////////////////////////////
// //////////////////////////////////////////////////////////////////////////////
type applyEnrollSecretSpecRequest struct {
Spec *fleet.EnrollSecretSpec `json:"spec"`
@@ -587,9 +594,9 @@ func (svc *Service) ApplyEnrollSecretSpec(ctx context.Context, spec *fleet.Enrol
return svc.ds.ApplyEnrollSecrets(ctx, nil, spec.Secrets)
}
////////////////////////////////////////////////////////////////////////////////
// //////////////////////////////////////////////////////////////////////////////
// Get enroll secret spec
////////////////////////////////////////////////////////////////////////////////
// //////////////////////////////////////////////////////////////////////////////
type getEnrollSecretSpecResponse struct {
Spec *fleet.EnrollSecretSpec `json:"spec"`
@@ -618,9 +625,9 @@ func (svc *Service) GetEnrollSecretSpec(ctx context.Context) (*fleet.EnrollSecre
return &fleet.EnrollSecretSpec{Secrets: secrets}, nil
}
////////////////////////////////////////////////////////////////////////////////
// //////////////////////////////////////////////////////////////////////////////
// Version
////////////////////////////////////////////////////////////////////////////////
// //////////////////////////////////////////////////////////////////////////////
type versionResponse struct {
*version.Info
@@ -646,9 +653,9 @@ func (svc *Service) Version(ctx context.Context) (*version.Info, error) {
return &info, nil
}
////////////////////////////////////////////////////////////////////////////////
// //////////////////////////////////////////////////////////////////////////////
// Get Certificate Chain
////////////////////////////////////////////////////////////////////////////////
// //////////////////////////////////////////////////////////////////////////////
type getCertificateResponse struct {
CertificateChain []byte `json:"certificate_chain"`
+24
View File
@@ -231,3 +231,27 @@ func (svc *Service) LoggingConfig(ctx context.Context) (*fleet.Logging, error) {
}
return logging, nil
}
func (svc *Service) EmailConfig(ctx context.Context) (*fleet.EmailConfig, error) {
if err := svc.authz.Authorize(ctx, &fleet.AppConfig{}, fleet.ActionRead); err != nil {
return nil, err
}
conf := svc.config
var email *fleet.EmailConfig
switch conf.Email.EmailBackend {
case "ses":
email = &fleet.EmailConfig{
Backend: conf.Email.EmailBackend,
Config: fleet.SESConfig{
Region: conf.SES.Region,
SourceARN: conf.SES.SourceArn,
},
}
default:
// SES is the only email provider configured as server envs/yaml file, the default implementation, SMTP, is configured via API/UI
// SMTP config gets its own dedicated section in the AppConfig response
}
return email, nil
}
+67 -1
View File
@@ -2,11 +2,11 @@ package service
import (
"context"
"fmt"
"runtime"
"testing"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mock"
"github.com/fleetdm/fleet/v4/server/test"
@@ -386,3 +386,69 @@ func TestModifyAppConfigPatches(t *testing.T) {
assert.Equal(t, "Acme", storedConfig.OrgInfo.OrgName)
assert.Equal(t, "http://someurl", storedConfig.ServerSettings.ServerURL)
}
func TestService_EmailConfig(t *testing.T) {
type fields struct {
config config.FleetConfig
}
type args struct {
ctx context.Context
}
tests := []struct {
name string
fields fields
args args
want *fleet.EmailConfig
wantErr assert.ErrorAssertionFunc
}{
{
name: "configuring the ses email backend should return ses configurations",
fields: fields{
config: testSESPluginConfig(),
},
args: args{
ctx: test.UserContext(context.Background(), test.UserAdmin),
},
want: &fleet.EmailConfig{
Backend: "ses",
Config: fleet.SESConfig{
Region: "us-east-1",
SourceARN: "qux",
}},
wantErr: assert.NoError,
},
{
name: "no configured email backend should return nil",
fields: fields{
config: config.TestConfig(),
},
args: args{
ctx: test.UserContext(context.Background(), test.UserAdmin),
},
want: nil,
wantErr: assert.NoError,
},
{
name: "no configured email backend should return nil",
fields: fields{
config: config.TestConfig(),
},
args: args{
ctx: test.UserContext(context.Background(), test.UserNoRoles),
},
want: nil,
wantErr: assert.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ds := new(mock.Store)
svc, _ := newTestServiceWithConfig(t, ds, tt.fields.config, nil, nil)
got, err := svc.EmailConfig(tt.args.ctx)
if !tt.wantErr(t, err, fmt.Sprintf("EmailConfig(%v)", tt.args.ctx)) {
return
}
assert.Equalf(t, tt.want, got, "EmailConfig(%v)", tt.args.ctx)
})
}
}
+15 -1
View File
@@ -96,7 +96,8 @@ func newTestServiceWithConfig(t *testing.T, ds fleet.Datastore, fleetConfig conf
enrollHostLimiter = opts[0].EnrollHostLimiter
}
if opts[0].UseMailService {
mailer = mail.NewService()
mailer, err = mail.NewService(config.TestConfig())
require.NoError(t, err)
}
// allow to explicitly set installer store to nil
@@ -322,6 +323,19 @@ func RunServerForTestsWithDS(t *testing.T, ds fleet.Datastore, opts ...*TestServ
return users, server
}
func testSESPluginConfig() config.FleetConfig {
c := config.TestConfig()
c.Email = config.EmailConfig{EmailBackend: "ses"}
c.SES = config.SESConfig{
Region: "us-east-1",
AccessKeyID: "foo",
SecretAccessKey: "bar",
StsAssumeRoleArn: "baz",
SourceArn: "qux",
}
return c
}
func testKinesisPluginConfig() config.FleetConfig {
c := config.TestConfig()
c.Osquery.ResultLogPlugin = "kinesis"
+6 -1
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"encoding/base64"
"errors"
"github.com/go-kit/kit/log/level"
"html/template"
"net/http"
"time"
@@ -1031,7 +1032,11 @@ func (svc *Service) RequestPasswordReset(ctx context.Context, email string) erro
},
}
return svc.mailService.SendEmail(resetEmail)
err = svc.mailService.SendEmail(resetEmail)
if err != nil {
level.Error(svc.logger).Log("err", err, "msg", "failed to send password reset request email")
}
return err
}
func (svc *Service) ListAvailableTeamsForUser(ctx context.Context, user *fleet.User) ([]*fleet.TeamSummary, error) {