Files
fleet/server/service/service_appconfig.go
T
3e10ad717c Add optional SES sender domain configuration (#43811)
**Related issue:** Resolves #42288

# Summary

This PR adds support for configuring an optional SES sender domain.

When the SES email backend is enabled, Fleet can now use a configured
sender domain for the `From` address instead of always deriving the
domain from `server.server_url`. If the setting is not provided, Fleet
keeps the existing behavior.

# Impact

This gives self-hosted operators a server-side SES configuration option
for email sending without changing UI-managed SMTP settings.

# Root cause

The SES sender path only generated `do-not-reply@<server host>` from the
Fleet server URL, so there was no way to override the sender domain
through server configuration.

# Checklist for submitter

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
- [x] Added/updated automated tests
- [x] Setting(s) is/are explicitly excluded from GitOps

## Testing

- [x] `go test -tags full,fts5,netgo ./server/mail -run
'Test_(getFromSES|sesSender_SendEmail)$'`
- [x] `go test -tags full,fts5,netgo ./server/config -run
'TestConfig(SESSenderDomain|Roundtrip)$'`
- [x] `go test -tags full,fts5,netgo ./server/service -run
'TestService_EmailConfig$'`
- [ ] QA'd all new/changed functionality manually


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added optional SES sender domain configuration. Users can specify a
custom domain for the email "From" address via config or environment
variable; when unset it falls back to the server hostname.

* **Tests**
* Added and expanded tests to verify sender-domain precedence,
From-header generation, and related error cases.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/fleetdm/fleet/pull/43811?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Lucas Manuel Rodriguez <lucas@fleetdm.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-19 11:21:46 -05:00

284 lines
8.2 KiB
Go

package service
import (
"context"
"errors"
"html/template"
"strings"
"github.com/fleetdm/fleet/v4/server"
authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/contexts/license"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mail"
)
func (svc *Service) NewAppConfig(ctx context.Context, p fleet.AppConfig) (*fleet.AppConfig, error) {
// skipauth: No user context yet when the app config is first created.
svc.authz.SkipAuthorization(ctx)
newConfig, err := svc.ds.NewAppConfig(ctx, &p)
if err != nil {
return nil, err
}
// Set up a default enroll secret
secret, err := server.GenerateRandomText(fleet.EnrollSecretDefaultLength)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "generate enroll secret string")
}
secrets := []*fleet.EnrollSecret{
{
Secret: secret,
},
}
err = svc.ds.ApplyEnrollSecrets(ctx, nil, secrets)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "save enroll secret")
}
return newConfig, nil
}
func (svc *Service) sendTestEmail(ctx context.Context, config *fleet.AppConfig) error {
vc, ok := viewer.FromContext(ctx)
if !ok {
return fleet.ErrNoContext
}
var smtpSettings fleet.SMTPSettings
if config.SMTPSettings != nil {
smtpSettings = *config.SMTPSettings
}
testMail := fleet.Email{
Subject: "Hello from Fleet",
To: []string{vc.User.Email},
Mailer: &mail.SMTPTestMailer{
BaseURL: template.URL(config.ServerSettings.ServerURL + svc.config.Server.URLPrefix),
AssetURL: getAssetURL(),
},
SMTPSettings: smtpSettings,
ServerURL: config.ServerSettings.ServerURL,
}
if err := mail.Test(svc.mailService, testMail); err != nil {
if errors.Is(err, mail.ErrSTARTTLSWithoutSSLTLS) {
return mail.ErrSTARTTLSWithoutSSLTLS
}
return MailError{Message: err.Error()}
}
return nil
}
func cleanupURL(url string) string {
return strings.TrimRight(strings.Trim(url, " \t\n"), "/")
}
func (svc *Service) License(ctx context.Context) (*fleet.LicenseInfo, error) {
if !svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceToken) &&
!svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceCertificate) &&
!svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceURL) {
if err := svc.authz.Authorize(ctx, &fleet.AppConfig{}, fleet.ActionRead); err != nil {
return nil, err
}
}
licChecker, _ := license.FromContext(ctx)
// Type assert to get the concrete type for modification and return
lic, _ := licChecker.(*fleet.LicenseInfo)
// Currently we use the presence of Microsoft Compliance Partner settings
// (only configured in cloud instances) to determine if a Fleet instance
// is a cloud managed instance.
if lic != nil && svc.config.MicrosoftCompliancePartner.IsSet() {
lic.ManagedCloud = true
}
return lic, nil
}
func (svc *Service) SetupRequired(ctx context.Context) (bool, error) {
hasUsers, err := svc.ds.HasUsers(ctx)
if err != nil {
return false, err
}
return !hasUsers, nil
}
func (svc *Service) UpdateIntervalConfig(ctx context.Context) (*fleet.UpdateIntervalConfig, error) {
return &fleet.UpdateIntervalConfig{
OSQueryDetail: svc.config.Osquery.DetailUpdateInterval,
OSQueryPolicy: svc.config.Osquery.PolicyUpdateInterval,
}, nil
}
func (svc *Service) VulnerabilitiesConfig(ctx context.Context) (*fleet.VulnerabilitiesConfig, error) {
return &fleet.VulnerabilitiesConfig{
DatabasesPath: svc.config.Vulnerabilities.DatabasesPath,
Periodicity: svc.config.Vulnerabilities.Periodicity,
CPEDatabaseURL: svc.config.Vulnerabilities.CPEDatabaseURL,
CPETranslationsURL: svc.config.Vulnerabilities.CPETranslationsURL,
CVEFeedPrefixURL: svc.config.Vulnerabilities.CVEFeedPrefixURL,
CurrentInstanceChecks: svc.config.Vulnerabilities.CurrentInstanceChecks,
DisableDataSync: svc.config.Vulnerabilities.DisableDataSync,
RecentVulnerabilityMaxAge: svc.config.Vulnerabilities.RecentVulnerabilityMaxAge,
DisableWinOSVulnerabilities: svc.config.Vulnerabilities.DisableWinOSVulnerabilities,
OSVForVulnerabilities: svc.config.Vulnerabilities.OSVForVulnerabilities,
}, nil
}
func (svc *Service) LoggingConfig(ctx context.Context) (*fleet.Logging, error) {
conf := svc.config
logging := &fleet.Logging{
Debug: conf.Logging.Debug,
Json: conf.Logging.JSON,
}
loggings := []struct {
plugin string
target *fleet.LoggingPlugin
}{
{
plugin: conf.Osquery.StatusLogPlugin,
target: &logging.Status,
},
{
plugin: conf.Osquery.ResultLogPlugin,
target: &logging.Result,
},
}
if conf.Activity.EnableAuditLog {
loggings = append(loggings, struct {
plugin string
target *fleet.LoggingPlugin
}{
plugin: conf.Activity.AuditLogPlugin,
target: &logging.Audit,
})
}
for _, lp := range loggings {
switch lp.plugin {
case "", "filesystem":
*lp.target = fleet.LoggingPlugin{
Plugin: "filesystem",
Config: fleet.FilesystemConfig{
FilesystemConfig: conf.Filesystem,
},
}
case "webhook":
*lp.target = fleet.LoggingPlugin{
Plugin: "webhook",
Config: fleet.WebhookConfig{
WebhookConfig: conf.Webhook,
},
}
case "kinesis":
*lp.target = fleet.LoggingPlugin{
Plugin: "kinesis",
Config: fleet.KinesisConfig{
Region: conf.Kinesis.Region,
StatusStream: conf.Kinesis.StatusStream,
ResultStream: conf.Kinesis.ResultStream,
AuditStream: conf.Kinesis.AuditStream,
},
}
case "firehose":
*lp.target = fleet.LoggingPlugin{
Plugin: "firehose",
Config: fleet.FirehoseConfig{
Region: conf.Firehose.Region,
StatusStream: conf.Firehose.StatusStream,
ResultStream: conf.Firehose.ResultStream,
AuditStream: conf.Firehose.AuditStream,
},
}
case "lambda":
*lp.target = fleet.LoggingPlugin{
Plugin: "lambda",
Config: fleet.LambdaConfig{
Region: conf.Lambda.Region,
StatusFunction: conf.Lambda.StatusFunction,
ResultFunction: conf.Lambda.ResultFunction,
AuditFunction: conf.Lambda.AuditFunction,
},
}
case "pubsub":
*lp.target = fleet.LoggingPlugin{
Plugin: "pubsub",
Config: fleet.PubSubConfig{
PubSubConfig: conf.PubSub,
},
}
case "stdout":
*lp.target = fleet.LoggingPlugin{Plugin: "stdout"}
case "kafkarest":
*lp.target = fleet.LoggingPlugin{
Plugin: "kafkarest",
Config: fleet.KafkaRESTConfig{
StatusTopic: conf.KafkaREST.StatusTopic,
ResultTopic: conf.KafkaREST.ResultTopic,
AuditTopic: conf.KafkaREST.AuditTopic,
ProxyHost: conf.KafkaREST.ProxyHost,
},
}
case "nats":
*lp.target = fleet.LoggingPlugin{
Plugin: "nats",
Config: fleet.NatsConfig{
StatusSubject: conf.Nats.StatusSubject,
ResultSubject: conf.Nats.ResultSubject,
AuditSubject: conf.Nats.AuditSubject,
Server: conf.Nats.Server,
},
}
default:
return nil, ctxerr.Errorf(ctx, "unrecognized logging plugin: %s", lp.plugin)
}
}
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,
SenderDomain: conf.SES.SenderDomain,
},
}
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
}
func (svc *Service) PartnershipsConfig(ctx context.Context) (*fleet.Partnerships, error) {
if err := svc.authz.Authorize(ctx, &fleet.AppConfig{}, fleet.ActionRead); err != nil {
return nil, err
}
enablePrimo := svc.config.Partnerships.EnablePrimo
if !enablePrimo {
// for now, since this is the only partnership of this type, exclude the whole struct if not enabled
return nil, nil
}
return &fleet.Partnerships{
EnablePrimo: svc.config.Partnerships.EnablePrimo,
}, nil
}