SMTP config: add global and local password file fields (#3038)

* SMTP config: add global and local password file fields

Add config fields (for both global email config and route-specific email
config) that specify path to file containing SMTP password.  We don't
want the password in the config file itself, and reading the password
from a k8s-secret-backed file keeps the password itself "encrypted at
rest" in etcd, and cleanly separated from the rest of the AM config.

I used the same approach as pull request #2534 "Add support to set the
Slack URL in the file"
<https://github.com/prometheus/alertmanager/pull/2534/files> in the
upstream repo.

Signed-off-by: Eric R. Rath <eric.rath@oracle.com>

* changed *AuthPasswordFile field types to string per review feedback

Signed-off-by: Eric R. Rath <eric.rath@oracle.com>

* added error to getPassword() retval per review feedback

Signed-off-by: Eric R. Rath <eric.rath@oracle.com>

* simplified conf.smtp-* files

Signed-off-by: Eric R. Rath <eric.rath@oracle.com>

* update docs to reflect field type change

Signed-off-by: Eric R. Rath <eric.rath@oracle.com>

* don't treat username-without-password as invalid

Signed-off-by: Eric R. Rath <eric.rath@oracle.com>

* test cleanup

Signed-off-by: Eric R. Rath <eric.rath@oracle.com>

* Apply suggestions from code review

Co-authored-by: Simon Pasquier <spasquie@redhat.com>
Signed-off-by: Eric R. Rath <4080262+ericrrath@users.noreply.github.com>

* Updated per review feedback

Signed-off-by: Eric R. Rath <eric.rath@oracle.com>

* added sub-test per review feedback

Signed-off-by: Eric R. Rath <eric.rath@oracle.com>

* added test on Email.getPassword() per feedback

Signed-off-by: Eric R. Rath <eric.rath@oracle.com>

* only inherit global SMTP passwords if neither local password field is set

Signed-off-by: Eric R. Rath <eric.rath@oracle.com>

* removed blank line caught by gofumpt

Signed-off-by: Eric R. Rath <eric.rath@oracle.com>

Signed-off-by: Eric R. Rath <eric.rath@oracle.com>
Signed-off-by: Eric R. Rath <4080262+ericrrath@users.noreply.github.com>
Co-authored-by: Simon Pasquier <spasquie@redhat.com>
This commit is contained in:
Eric R. Rath 2022-09-16 05:36:57 -07:00 committed by GitHub
parent 3d624c0552
commit efc956c7f7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 226 additions and 36 deletions

View File

@ -335,6 +335,10 @@ func (c *Config) UnmarshalYAML(unmarshal func(interface{}) error) error {
return fmt.Errorf("at most one of opsgenie_api_key & opsgenie_api_key_file must be configured")
}
if len(c.Global.SMTPAuthPassword) > 0 && len(c.Global.SMTPAuthPasswordFile) > 0 {
return fmt.Errorf("at most one of smtp_auth_password & smtp_auth_password_file must be configured")
}
names := map[string]struct{}{}
for _, rcv := range c.Receivers {
@ -365,8 +369,9 @@ func (c *Config) UnmarshalYAML(unmarshal func(interface{}) error) error {
if ec.AuthUsername == "" {
ec.AuthUsername = c.Global.SMTPAuthUsername
}
if ec.AuthPassword == "" {
if ec.AuthPassword == "" && ec.AuthPasswordFile == "" {
ec.AuthPassword = c.Global.SMTPAuthPassword
ec.AuthPasswordFile = c.Global.SMTPAuthPasswordFile
}
if ec.AuthSecret == "" {
ec.AuthSecret = c.Global.SMTPAuthSecret
@ -693,26 +698,27 @@ type GlobalConfig struct {
HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"`
SMTPFrom string `yaml:"smtp_from,omitempty" json:"smtp_from,omitempty"`
SMTPHello string `yaml:"smtp_hello,omitempty" json:"smtp_hello,omitempty"`
SMTPSmarthost HostPort `yaml:"smtp_smarthost,omitempty" json:"smtp_smarthost,omitempty"`
SMTPAuthUsername string `yaml:"smtp_auth_username,omitempty" json:"smtp_auth_username,omitempty"`
SMTPAuthPassword Secret `yaml:"smtp_auth_password,omitempty" json:"smtp_auth_password,omitempty"`
SMTPAuthSecret Secret `yaml:"smtp_auth_secret,omitempty" json:"smtp_auth_secret,omitempty"`
SMTPAuthIdentity string `yaml:"smtp_auth_identity,omitempty" json:"smtp_auth_identity,omitempty"`
SMTPRequireTLS bool `yaml:"smtp_require_tls" json:"smtp_require_tls,omitempty"`
SlackAPIURL *SecretURL `yaml:"slack_api_url,omitempty" json:"slack_api_url,omitempty"`
SlackAPIURLFile string `yaml:"slack_api_url_file,omitempty" json:"slack_api_url_file,omitempty"`
PagerdutyURL *URL `yaml:"pagerduty_url,omitempty" json:"pagerduty_url,omitempty"`
OpsGenieAPIURL *URL `yaml:"opsgenie_api_url,omitempty" json:"opsgenie_api_url,omitempty"`
OpsGenieAPIKey Secret `yaml:"opsgenie_api_key,omitempty" json:"opsgenie_api_key,omitempty"`
OpsGenieAPIKeyFile string `yaml:"opsgenie_api_key_file,omitempty" json:"opsgenie_api_key_file,omitempty"`
WeChatAPIURL *URL `yaml:"wechat_api_url,omitempty" json:"wechat_api_url,omitempty"`
WeChatAPISecret Secret `yaml:"wechat_api_secret,omitempty" json:"wechat_api_secret,omitempty"`
WeChatAPICorpID string `yaml:"wechat_api_corp_id,omitempty" json:"wechat_api_corp_id,omitempty"`
VictorOpsAPIURL *URL `yaml:"victorops_api_url,omitempty" json:"victorops_api_url,omitempty"`
VictorOpsAPIKey Secret `yaml:"victorops_api_key,omitempty" json:"victorops_api_key,omitempty"`
TelegramAPIUrl *URL `yaml:"telegram_api_url,omitempty" json:"telegram_api_url,omitempty"`
SMTPFrom string `yaml:"smtp_from,omitempty" json:"smtp_from,omitempty"`
SMTPHello string `yaml:"smtp_hello,omitempty" json:"smtp_hello,omitempty"`
SMTPSmarthost HostPort `yaml:"smtp_smarthost,omitempty" json:"smtp_smarthost,omitempty"`
SMTPAuthUsername string `yaml:"smtp_auth_username,omitempty" json:"smtp_auth_username,omitempty"`
SMTPAuthPassword Secret `yaml:"smtp_auth_password,omitempty" json:"smtp_auth_password,omitempty"`
SMTPAuthPasswordFile string `yaml:"smtp_auth_password_file,omitempty" json:"smtp_auth_password_file,omitempty"`
SMTPAuthSecret Secret `yaml:"smtp_auth_secret,omitempty" json:"smtp_auth_secret,omitempty"`
SMTPAuthIdentity string `yaml:"smtp_auth_identity,omitempty" json:"smtp_auth_identity,omitempty"`
SMTPRequireTLS bool `yaml:"smtp_require_tls" json:"smtp_require_tls,omitempty"`
SlackAPIURL *SecretURL `yaml:"slack_api_url,omitempty" json:"slack_api_url,omitempty"`
SlackAPIURLFile string `yaml:"slack_api_url_file,omitempty" json:"slack_api_url_file,omitempty"`
PagerdutyURL *URL `yaml:"pagerduty_url,omitempty" json:"pagerduty_url,omitempty"`
OpsGenieAPIURL *URL `yaml:"opsgenie_api_url,omitempty" json:"opsgenie_api_url,omitempty"`
OpsGenieAPIKey Secret `yaml:"opsgenie_api_key,omitempty" json:"opsgenie_api_key,omitempty"`
OpsGenieAPIKeyFile string `yaml:"opsgenie_api_key_file,omitempty" json:"opsgenie_api_key_file,omitempty"`
WeChatAPIURL *URL `yaml:"wechat_api_url,omitempty" json:"wechat_api_url,omitempty"`
WeChatAPISecret Secret `yaml:"wechat_api_secret,omitempty" json:"wechat_api_secret,omitempty"`
WeChatAPICorpID string `yaml:"wechat_api_corp_id,omitempty" json:"wechat_api_corp_id,omitempty"`
VictorOpsAPIURL *URL `yaml:"victorops_api_url,omitempty" json:"victorops_api_url,omitempty"`
VictorOpsAPIKey Secret `yaml:"victorops_api_key,omitempty" json:"victorops_api_key,omitempty"`
TelegramAPIUrl *URL `yaml:"telegram_api_url,omitempty" json:"telegram_api_url,omitempty"`
}
// UnmarshalYAML implements the yaml.Unmarshaler interface for GlobalConfig.

View File

@ -965,6 +965,39 @@ func TestSMTPHello(t *testing.T) {
}
}
func TestSMTPBothPasswordAndFile(t *testing.T) {
_, err := LoadFile("testdata/conf.smtp-both-password-and-file.yml")
if err == nil {
t.Fatalf("Expected an error parsing %s: %s", "testdata/conf.smtp-both-password-and-file.yml", err)
}
if err.Error() != "at most one of smtp_auth_password & smtp_auth_password_file must be configured" {
t.Errorf("Expected: %s\nGot: %s", "at most one of auth_password & auth_password_file must be configured", err.Error())
}
}
func TestSMTPNoUsernameOrPassword(t *testing.T) {
_, err := LoadFile("testdata/conf.smtp-no-username-or-password.yml")
if err != nil {
t.Fatalf("Error parsing %s: %s", "testdata/conf.smtp-no-username-or-password.yml", err)
}
}
func TestGlobalAndLocalSMTPPassword(t *testing.T) {
config, err := LoadFile("testdata/conf.smtp-password-global-and-local.yml")
if err != nil {
t.Fatalf("Error parsing %s: %s", "testdata/conf.smtp-password-global-and-local.yml", err)
}
require.Equal(t, "/tmp/globaluserpassword", config.Receivers[0].EmailConfigs[0].AuthPasswordFile, "first email should use password file /tmp/globaluserpassword")
require.Emptyf(t, config.Receivers[0].EmailConfigs[0].AuthPassword, "password field should be empty when file provided")
require.Equal(t, "/tmp/localuser1password", config.Receivers[0].EmailConfigs[1].AuthPasswordFile, "second email should use password file /tmp/localuser1password")
require.Emptyf(t, config.Receivers[0].EmailConfigs[1].AuthPassword, "password field should be empty when file provided")
require.Equal(t, Secret("mysecret"), config.Receivers[0].EmailConfigs[2].AuthPassword, "third email should use password mysecret")
require.Emptyf(t, config.Receivers[0].EmailConfigs[2].AuthPasswordFile, "file field should be empty when password provided")
}
func TestGroupByAll(t *testing.T) {
c, err := LoadFile("testdata/conf.group-by-all.yml")
if err != nil {

View File

@ -165,19 +165,20 @@ type EmailConfig struct {
NotifierConfig `yaml:",inline" json:",inline"`
// Email address to notify.
To string `yaml:"to,omitempty" json:"to,omitempty"`
From string `yaml:"from,omitempty" json:"from,omitempty"`
Hello string `yaml:"hello,omitempty" json:"hello,omitempty"`
Smarthost HostPort `yaml:"smarthost,omitempty" json:"smarthost,omitempty"`
AuthUsername string `yaml:"auth_username,omitempty" json:"auth_username,omitempty"`
AuthPassword Secret `yaml:"auth_password,omitempty" json:"auth_password,omitempty"`
AuthSecret Secret `yaml:"auth_secret,omitempty" json:"auth_secret,omitempty"`
AuthIdentity string `yaml:"auth_identity,omitempty" json:"auth_identity,omitempty"`
Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"`
HTML string `yaml:"html,omitempty" json:"html,omitempty"`
Text string `yaml:"text,omitempty" json:"text,omitempty"`
RequireTLS *bool `yaml:"require_tls,omitempty" json:"require_tls,omitempty"`
TLSConfig commoncfg.TLSConfig `yaml:"tls_config,omitempty" json:"tls_config,omitempty"`
To string `yaml:"to,omitempty" json:"to,omitempty"`
From string `yaml:"from,omitempty" json:"from,omitempty"`
Hello string `yaml:"hello,omitempty" json:"hello,omitempty"`
Smarthost HostPort `yaml:"smarthost,omitempty" json:"smarthost,omitempty"`
AuthUsername string `yaml:"auth_username,omitempty" json:"auth_username,omitempty"`
AuthPassword Secret `yaml:"auth_password,omitempty" json:"auth_password,omitempty"`
AuthPasswordFile string `yaml:"auth_password_file,omitempty" json:"auth_password_file,omitempty"`
AuthSecret Secret `yaml:"auth_secret,omitempty" json:"auth_secret,omitempty"`
AuthIdentity string `yaml:"auth_identity,omitempty" json:"auth_identity,omitempty"`
Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"`
HTML string `yaml:"html,omitempty" json:"html,omitempty"`
Text string `yaml:"text,omitempty" json:"text,omitempty"`
RequireTLS *bool `yaml:"require_tls,omitempty" json:"require_tls,omitempty"`
TLSConfig commoncfg.TLSConfig `yaml:"tls_config,omitempty" json:"tls_config,omitempty"`
}
// UnmarshalYAML implements the yaml.Unmarshaler interface.

View File

@ -0,0 +1,13 @@
global:
smtp_smarthost: 'localhost:25'
smtp_from: 'alertmanager@example.org'
smtp_auth_username: 'alertmanager'
smtp_auth_password: "multiline\nmysecret"
smtp_auth_password_file: "/tmp/global"
smtp_hello: "host.example.org"
route:
receiver: 'email-notifications'
receivers:
- name: 'email-notifications'
email_configs:
- to: 'one@example.org'

View File

@ -0,0 +1,10 @@
global:
smtp_smarthost: 'localhost:25'
smtp_from: 'alertmanager@example.org'
smtp_hello: "host.example.org"
route:
receiver: 'email-notifications'
receivers:
- name: 'email-notifications'
email_configs:
- to: 'one@example.org'

View File

@ -0,0 +1,21 @@
global:
smtp_smarthost: 'localhost:25'
smtp_from: 'alertmanager@example.org'
smtp_auth_username: 'globaluser'
smtp_auth_password_file: '/tmp/globaluserpassword'
smtp_hello: "host.example.org"
route:
receiver: 'email-notifications'
receivers:
- name: 'email-notifications'
email_configs:
# Use global
- to: 'one@example.org'
# Override global with other file
- to: 'two@example.org'
auth_username: 'localuser1'
auth_password_file: '/tmp/localuser1password'
# Override global with inline password
- to: 'three@example.org'
auth_username: 'localuser2'
auth_password: 'mysecret'

View File

@ -71,6 +71,8 @@ global:
[ smtp_auth_username: <string> ]
# SMTP Auth using LOGIN and PLAIN.
[ smtp_auth_password: <secret> ]
# SMTP Auth using LOGIN and PLAIN.
[ smtp_auth_password_file: <string> ]
# SMTP Auth using PLAIN.
[ smtp_auth_identity: <string> ]
# SMTP Auth using CRAM-MD5.
@ -514,8 +516,10 @@ to: <tmpl_string>
[ hello: <string> | default = global.smtp_hello ]
# SMTP authentication information.
# auth_password and auth_password_file are mutually exclusive.
[ auth_username: <string> | default = global.smtp_auth_username ]
[ auth_password: <secret> | default = global.smtp_auth_password ]
[ auth_password_file: <string> | default = global.smtp_auth_password_file ]
[ auth_secret: <secret> | default = global.smtp_auth_secret ]
[ auth_identity: <string> | default = global.smtp_auth_identity ]

View File

@ -91,7 +91,11 @@ func (n *Email) auth(mechs string) (smtp.Auth, error) {
return smtp.CRAMMD5Auth(username, secret), nil
case "PLAIN":
password := string(n.conf.AuthPassword)
password, passwordErr := n.getPassword()
if passwordErr != nil {
err.Add(passwordErr)
continue
}
if password == "" {
err.Add(errors.New("missing password for PLAIN auth mechanism"))
continue
@ -100,7 +104,11 @@ func (n *Email) auth(mechs string) (smtp.Auth, error) {
return smtp.PlainAuth(identity, username, password, n.conf.Smarthost.Host), nil
case "LOGIN":
password := string(n.conf.AuthPassword)
password, passwordErr := n.getPassword()
if passwordErr != nil {
err.Add(passwordErr)
continue
}
if password == "" {
err.Add(errors.New("missing password for LOGIN auth mechanism"))
continue
@ -353,3 +361,14 @@ func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
}
return nil, nil
}
func (n *Email) getPassword() (string, error) {
if len(n.conf.AuthPasswordFile) > 0 {
content, err := os.ReadFile(n.conf.AuthPasswordFile)
if err != nil {
return "", fmt.Errorf("could not read %s: %w", n.conf.AuthPasswordFile, err)
}
return string(content), nil
}
return string(n.conf.AuthPassword), nil
}

View File

@ -427,6 +427,16 @@ func TestEmailNotifyWithAuthentication(t *testing.T) {
t.Fatal(err)
}
fileWithCorrectPassword, err := os.CreateTemp("", "smtp-password-correct")
require.NoError(t, err, "creating temp file failed")
_, err = fileWithCorrectPassword.WriteString(c.Password)
require.NoError(t, err, "writing to temp file failed")
fileWithIncorrectPassword, err := os.CreateTemp("", "smtp-password-incorrect")
require.NoError(t, err, "creating temp file failed")
_, err = fileWithIncorrectPassword.WriteString(c.Password + "wrong")
require.NoError(t, err, "writing to temp file failed")
for _, tc := range []struct {
title string
updateCfg func(*config.EmailConfig)
@ -441,6 +451,13 @@ func TestEmailNotifyWithAuthentication(t *testing.T) {
cfg.AuthPassword = config.Secret(c.Password)
},
},
{
title: "email with authentication (password from file)",
updateCfg: func(cfg *config.EmailConfig) {
cfg.AuthUsername = c.Username
cfg.AuthPasswordFile = fileWithCorrectPassword.Name()
},
},
{
title: "HTML-only email",
updateCfg: func(cfg *config.EmailConfig) {
@ -486,6 +503,16 @@ func TestEmailNotifyWithAuthentication(t *testing.T) {
errMsg: "Invalid username or password",
retry: true,
},
{
title: "wrong credentials (password from file)",
updateCfg: func(cfg *config.EmailConfig) {
cfg.AuthUsername = c.Username
cfg.AuthPasswordFile = fileWithIncorrectPassword.Name()
},
errMsg: "Invalid username or password",
retry: true,
},
{
title: "no credentials",
errMsg: "authentication Required",
@ -606,3 +633,59 @@ func TestEmailNoUsernameStillOk(t *testing.T) {
require.NoError(t, err)
require.Nil(t, a)
}
func TestEmailGetPassword(t *testing.T) {
passwordFile, err := os.CreateTemp("", "smtp-password")
require.NoError(t, err, "creating temp file failed")
_, err = passwordFile.WriteString("secret")
require.NoError(t, err, "writing to temp file failed")
for _, tc := range []struct {
title string
updateCfg func(*config.EmailConfig)
errMsg string
}{
{
title: "password from field",
updateCfg: func(cfg *config.EmailConfig) {
cfg.AuthPassword = "secret"
cfg.AuthPasswordFile = ""
},
},
{
title: "password from file field",
updateCfg: func(cfg *config.EmailConfig) {
cfg.AuthPassword = ""
cfg.AuthPasswordFile = passwordFile.Name()
},
},
{
title: "password file path incorrect",
updateCfg: func(cfg *config.EmailConfig) {
cfg.AuthPassword = ""
cfg.AuthPasswordFile = "/does/not/exist"
},
errMsg: "could not read",
},
} {
tc := tc
t.Run(tc.title, func(t *testing.T) {
email := &Email{
conf: &config.EmailConfig{},
}
tc.updateCfg(email.conf)
password, err := email.getPassword()
if len(tc.errMsg) > 0 {
require.Error(t, err)
require.Contains(t, err.Error(), tc.errMsg)
require.Equal(t, "", password)
} else {
require.Nil(t, err)
require.Equal(t, "secret", password)
}
})
}
}