add support for AWS SES email backend (#10847)
This commit is contained in:
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: ®ion,
|
||||
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
|
||||
}
|
||||
@@ -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
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user