diff --git a/changes/issue-10873-email-via-native-ses b/changes/issue-10873-email-via-native-ses new file mode 100644 index 0000000000..705c104fca --- /dev/null +++ b/changes/issue-10873-email-via-native-ses @@ -0,0 +1 @@ +* introduce new email backend capable of sending email directly using SES APIs \ No newline at end of file diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go index 96070469f6..faa5d07101 100644 --- a/cmd/fleet/serve.go +++ b/cmd/fleet/serve.go @@ -162,7 +162,6 @@ the way that the Fleet server works. var ds fleet.Datastore var carveStore fleet.CarveStore var installerStore fleet.InstallerStore - mailService := mail.NewService() opts := []mysql.DBOption{mysql.Logger(logger), mysql.WithFleetConfig(&config)} if config.MysqlReadReplica.Address != "" { @@ -545,6 +544,19 @@ the way that the Fleet server works. initFatal(err, "saving app config") } + // setup mail service + if appCfg.SMTPSettings.SMTPEnabled { + // if SMTP is already enabled then default the backend to empty string, which fill force load the SMTP implementation + if config.Email.EmailBackend != "" { + config.Email.EmailBackend = "" + level.Warn(logger).Log("msg", "SMTP is already enabled, first disable SMTP to utilize a different email backend") + } + } + mailService, err := mail.NewService(config) + if err != nil { + level.Error(logger).Log("err", err, "msg", "failed to configure mailing service") + } + cronSchedules := fleet.NewCronSchedules() baseCtx := licensectx.NewContext(context.Background(), license) @@ -600,6 +612,15 @@ the way that the Fleet server works. } } + // err = svc.RequestPasswordReset(context.Background(), "admin@fleetdm.com") + // if err != nil { + // level.Error(logger).Log("err", err) + // } + // err = svc.ResetPassword(context.Background(), "dnF5N1QwUmdWNWhzeDhsUjQxdW5BRmtQRCtzS3FyMkk=", "password1234!") + // if err != nil { + // level.Error(logger).Log("err", err) + // } + instanceID, err := server.GenerateRandomText(64) if err != nil { initFatal(errors.New("Error generating random instance identifier"), "") diff --git a/docs/Deploying/Configuration.md b/docs/Deploying/Configuration.md index 65880bb6e6..7841fd8886 100644 --- a/docs/Deploying/Configuration.md +++ b/docs/Deploying/Configuration.md @@ -2057,6 +2057,107 @@ kafkarest: status_topic: osquery_status ``` +#### Email backend + +By default, the SMTP backend is enabled and no additional configuration is required on the server settings. You can configure +SMTP through the [Fleet console UI](https://fleetdm.com/docs/using-fleet/configuration-files#smtp-settings). However, you can also +configure Fleet to use AWS SES natively rather than through SMTP. + +##### backend + +Enable SES support for Fleet. You must also configure the ses configurations such as `ses.source_arn` + +````yaml +email: + backend: ses +```` + +#### SES + +The following configurations only have an effect if SES email backend is enabled `FLEET_EMAIL_BACKEND=ses`. + +##### ses_region + +This flag only has effect if `email.backend` or `FLEET_EMAIL_BACKEND` is set to `ses`. + +AWS region to use for SES connection. + +- Default value: none +- Environment variable: `FLEET_SES_REGION` +- Config file format: + ```yaml + ses: + region: us-east-2 + ``` + +##### ses_access_key_id + +This flag only has effect if `email.backend` or `FLEET_EMAIL_BACKEND` is set to `ses`. + +If `ses_access_key_id` and `ses_secret_access_key` are omitted, Fleet +will try to use +[AWS STS](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_temp.html) +credentials. + +AWS access key ID to use for Lambda authentication. + +- Default value: none +- Environment variable: `FLEET_SES_ACCESS_KEY_ID` +- Config file format: + ``` + ses: + access_key_id: AKIAIOSFODNN7EXAMPLE + ``` + +##### ses_secret_access_key + +This flag only has effect if `email.backend` or `FLEET_EMAIL_BACKEND` is set to `ses`. + +If `ses_access_key_id` and `ses_secret_access_key` are omitted, Fleet +will try to use +[AWS STS](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_temp.html) +credentials. + +AWS secret access key to use for SES authentication. + +- Default value: none +- Environment variable: `FLEET_SES_SECRET_ACCESS_KEY` +- Config file format: + ```yaml + ses: + secret_access_key: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + ``` + +##### ses_sts_assume_role_arn + +This flag only has effect if `email.backend` or `FLEET_EMAIL_BACKEND` is set to `ses`. + +AWS STS role ARN to use for SES authentication. + +- Default value: none +- Environment variable: `FLEET_SES_STS_ASSUME_ROLE_ARN` +- Config file format: + ```yaml + ses: + sts_assume_role_arn: arn:aws:iam::1234567890:role/ses-role + ``` + +##### ses_source_arn + +This flag only has effect if `email.backend` or `FLEET_EMAIL_BACKEND` is set to `ses`. This configuration **is +required** when using the SES email backend. + +The 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 of SendRawEmail. + +- Default value: none +- Environment variable: `FLEET_SES_SOURCE_ARN` +- Config file format: + ```yaml + ses: + sts_assume_role_arn: arn:aws:iam::1234567890:role/ses-role + ``` + #### S3 file carving backend ##### s3_bucket diff --git a/infrastructure/dogfood/terraform/aws-tf-module/github.tf b/infrastructure/dogfood/terraform/aws-tf-module/github.tf index eaca3ac0b6..ba6dc262ab 100644 --- a/infrastructure/dogfood/terraform/aws-tf-module/github.tf +++ b/infrastructure/dogfood/terraform/aws-tf-module/github.tf @@ -95,6 +95,7 @@ data "aws_iam_policy_document" "gha-permissions" { "firehose:*", "athena:*", "glue:*", + "ses:*", ] resources = ["*"] } diff --git a/infrastructure/dogfood/terraform/aws-tf-module/main.tf b/infrastructure/dogfood/terraform/aws-tf-module/main.tf index a77a400465..d0af0d7bc2 100644 --- a/infrastructure/dogfood/terraform/aws-tf-module/main.tf +++ b/infrastructure/dogfood/terraform/aws-tf-module/main.tf @@ -87,9 +87,9 @@ module "main" { policy_name = "${local.customer}-iam-policy-execution" } } - extra_iam_policies = concat(module.firehose-logging.fleet_extra_iam_policies, module.osquery-carve.fleet_extra_iam_policies) + extra_iam_policies = concat(module.firehose-logging.fleet_extra_iam_policies, module.osquery-carve.fleet_extra_iam_policies, module.ses.fleet_extra_iam_policies) extra_execution_iam_policies = concat(module.mdm.extra_execution_iam_policies, [aws_iam_policy.sentry.arn]) - extra_environment_variables = merge(module.mdm.extra_environment_variables, module.firehose-logging.fleet_extra_environment_variables, module.osquery-carve.fleet_extra_environment_variables, local.extra_environment_variables) + extra_environment_variables = merge(module.mdm.extra_environment_variables, module.firehose-logging.fleet_extra_environment_variables, module.osquery-carve.fleet_extra_environment_variables, module.ses.fleet_extra_environment_variables, local.extra_environment_variables) extra_secrets = merge(module.mdm.extra_secrets, local.sentry_secrets) } alb_config = { @@ -289,3 +289,8 @@ module "notify_slack" { slack_channel = "#help-p1" slack_username = "monitoring" } + +module "ses" { + source = "github.com/fleetdm/fleet//terraform/addons/ses?ref=main" + domain = "dogfood.fleetdm.com" +} diff --git a/server/config/config.go b/server/config/config.go index 25b1c092df..2f5eb32d25 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -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"), diff --git a/server/fleet/app.go b/server/fleet/app.go index cd66f37680..81fff04c9c 100644 --- a/server/fleet/app.go +++ b/server/fleet/app.go @@ -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"` diff --git a/server/fleet/service.go b/server/fleet/service.go index ab14029ffc..bdd932b8f3 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -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. diff --git a/server/mail/mail.go b/server/mail/mail.go index 4617e63005..71b6732b77 100644 --- a/server/mail/mail.go +++ b/server/mail/mail.go @@ -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 } diff --git a/server/mail/mail_test.go b/server/mail/mail_test.go index ba964899a5..fef8fbc276 100644 --- a/server/mail/mail_test.go +++ b/server/mail/mail_test.go @@ -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) + }) + } +} diff --git a/server/mail/ses.go b/server/mail/ses.go new file mode 100644 index 0000000000..fd4b0bf5c7 --- /dev/null +++ b/server/mail/ses.go @@ -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 +} diff --git a/server/mail/ses_test.go b/server/mail/ses_test.go new file mode 100644 index 0000000000..696b6b2386 --- /dev/null +++ b/server/mail/ses_test.go @@ -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)) + }) + } +} diff --git a/server/service/appconfig.go b/server/service/appconfig.go index 0859e2cb4b..c3228871c3 100644 --- a/server/service/appconfig.go +++ b/server/service/appconfig.go @@ -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"` diff --git a/server/service/service_appconfig.go b/server/service/service_appconfig.go index 979e5d75a2..b1fe820b7a 100644 --- a/server/service/service_appconfig.go +++ b/server/service/service_appconfig.go @@ -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 +} diff --git a/server/service/service_appconfig_test.go b/server/service/service_appconfig_test.go index ee2f6dbcae..d9502c9521 100644 --- a/server/service/service_appconfig_test.go +++ b/server/service/service_appconfig_test.go @@ -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) + }) + } +} diff --git a/server/service/testing_utils.go b/server/service/testing_utils.go index 8a65b0a04e..73b0ddf8e9 100644 --- a/server/service/testing_utils.go +++ b/server/service/testing_utils.go @@ -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" diff --git a/server/service/users.go b/server/service/users.go index 5bd64f0448..79c01f9ec5 100644 --- a/server/service/users.go +++ b/server/service/users.go @@ -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) { diff --git a/terraform/addons/ses/.header.md b/terraform/addons/ses/.header.md new file mode 100644 index 0000000000..fd1edec288 --- /dev/null +++ b/terraform/addons/ses/.header.md @@ -0,0 +1,2 @@ +# SES Mailing Addon +This addon allows Fleet to send password resets via SES diff --git a/terraform/addons/ses/.terraform-docs.yml b/terraform/addons/ses/.terraform-docs.yml new file mode 100644 index 0000000000..1d139ddb40 --- /dev/null +++ b/terraform/addons/ses/.terraform-docs.yml @@ -0,0 +1 @@ +header-from: .header.md diff --git a/terraform/addons/ses/README.md b/terraform/addons/ses/README.md new file mode 100644 index 0000000000..7b549d31cc --- /dev/null +++ b/terraform/addons/ses/README.md @@ -0,0 +1,43 @@ +# SES Mailing Addon +This addon allows Fleet to send password resets via SES + +## Requirements + +No requirements. + +## Providers + +| Name | Version | +|------|---------| +| [aws](#provider\_aws) | 4.60.0 | + +## Modules + +No modules. + +## Resources + +| Name | Type | +|------|------| +| [aws_iam_policy.main](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource | +| [aws_route53_record.dkim](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route53_record) | resource | +| [aws_route53_record.ses_verification](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route53_record) | resource | +| [aws_route53_record.spf_domain](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route53_record) | resource | +| [aws_ses_domain_dkim.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ses_domain_dkim) | resource | +| [aws_ses_domain_identity.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ses_domain_identity) | resource | +| [aws_ses_domain_identity_verification.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ses_domain_identity_verification) | resource | +| [aws_iam_policy_document.main](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_route53_zone.main](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/route53_zone) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [domain](#input\_domain) | Domain to use for SES. | `string` | n/a | yes | + +## Outputs + +| Name | Description | +|------|-------------| +| [fleet\_extra\_environment\_variables](#output\_fleet\_extra\_environment\_variables) | n/a | +| [fleet\_extra\_iam\_policies](#output\_fleet\_extra\_iam\_policies) | n/a | diff --git a/terraform/addons/ses/main.tf b/terraform/addons/ses/main.tf new file mode 100644 index 0000000000..6394ffd1ec --- /dev/null +++ b/terraform/addons/ses/main.tf @@ -0,0 +1,51 @@ +data "aws_route53_zone" "main" { + name = var.domain +} + +resource "aws_ses_domain_identity" "default" { + domain = var.domain +} + +resource "aws_ses_domain_dkim" "default" { + domain = aws_ses_domain_identity.default.domain +} + +###DKIM VERIFICATION####### + +resource "aws_route53_record" "dkim" { + for_each = toset(aws_ses_domain_dkim.default.dkim_tokens) + zone_id = data.aws_route53_zone.main.zone_id + name = format("%s._domainkey.%s", each.key, var.domain) + type = "CNAME" + ttl = 600 + records = [format("%s.dkim.amazonses.com", each.key)] +} + +resource "aws_route53_record" "spf_domain" { + zone_id = data.aws_route53_zone.main.zone_id + name = "" + type = "TXT" + ttl = "600" + records = ["v=spf1 include:amazonses.com -all"] +} + +resource "aws_iam_policy" "main" { + policy = data.aws_iam_policy_document.main.json +} + +data "aws_iam_policy_document" "main" { + statement { + actions = [ + "ses:SendEmail", + "ses:SendRawEmail", + ] + resources = ["*"] + condition { + test = "StringLike" + variable = "ses:FromAddress" + values = [ + "*@${var.domain}" + ] + } + } +} diff --git a/terraform/addons/ses/outputs.tf b/terraform/addons/ses/outputs.tf new file mode 100644 index 0000000000..0e22664378 --- /dev/null +++ b/terraform/addons/ses/outputs.tf @@ -0,0 +1,12 @@ +output "fleet_extra_environment_variables" { + value = { + FLEET_EMAIL_BACKEND = "ses" + FLEET_SES_SOURCE_ARN = aws_ses_domain_identity.default.arn + } +} + +output "fleet_extra_iam_policies" { + value = [ + aws_iam_policy.main.arn + ] +} diff --git a/terraform/addons/ses/variables.tf b/terraform/addons/ses/variables.tf new file mode 100644 index 0000000000..af4c2932ca --- /dev/null +++ b/terraform/addons/ses/variables.tf @@ -0,0 +1,4 @@ +variable "domain" { + type = string + description = "Domain to use for SES." +}