diff --git a/.circleci/config.yml b/.circleci/config.yml index 036a453a..ba2bde21 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,23 +1,51 @@ --- version: 2.1 -executors: - # Whenever the Go version is updated here, .travis.yml and .promu.yml - # should also be updated. - golang: +jobs: + test: docker: + # Whenever the Go version is updated here, .travis.yml and .promu.yml + # should also be updated. - image: circleci/golang:1.12 + # maildev containers are for running the email tests against a "real" SMTP server. + # See notify/email_test.go for details. + - image: djfarrelly/maildev@sha256:624e0ec781e11c3531da83d9448f5861f258ee008c1b2da63b3248bfd680acfa + name: maildev-noauth + entrypoint: bin/maildev + command: + - -v + - image: djfarrelly/maildev@sha256:624e0ec781e11c3531da83d9448f5861f258ee008c1b2da63b3248bfd680acfa + name: maildev-auth + entrypoint: bin/maildev + command: + - -v + - --incoming-user + - user + - --incoming-pass + - pass + # errcheck requires to be executed from GOPATH for now. working_directory: /go/src/github.com/prometheus/alertmanager -jobs: - test: - executor: golang + environment: + EMAIL_NO_AUTH_CONFIG: /tmp/smtp_no_auth.yml + EMAIL_AUTH_CONFIG: /tmp/smtp_auth.yml steps: - checkout - - setup_remote_docker - run: make promu + - run: + command: | + cat \< $EMAIL_NO_AUTH_CONFIG + smarthost: maildev-noauth:1025 + server: http://maildev-noauth:1080/ + EOF + cat \< $EMAIL_AUTH_CONFIG + smarthost: maildev-auth:1025 + server: http://maildev-auth:1080/ + username: user + password: pass + EOF - run: make - run: command: | @@ -53,8 +81,8 @@ jobs: destination: /build docker_hub_master: - executor: golang - + docker: + - image: circleci/golang steps: - checkout - setup_remote_docker @@ -71,8 +99,8 @@ jobs: - run: make docker-publish DOCKER_REPO=quay.io/prometheus docker_hub_release_tags: - executor: golang - + docker: + - image: circleci/golang steps: - checkout - setup_remote_docker diff --git a/notify/email.go b/notify/email.go new file mode 100644 index 00000000..1249d4ed --- /dev/null +++ b/notify/email.go @@ -0,0 +1,338 @@ +// Copyright 2019 Prometheus Team +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package notify + +import ( + "bytes" + "context" + "crypto/tls" + "errors" + "fmt" + "mime" + "mime/multipart" + "mime/quotedprintable" + "net" + "net/mail" + "net/smtp" + "net/textproto" + "strings" + "time" + + "github.com/go-kit/kit/log" + "github.com/go-kit/kit/log/level" + commoncfg "github.com/prometheus/common/config" + + "github.com/prometheus/alertmanager/config" + "github.com/prometheus/alertmanager/template" + "github.com/prometheus/alertmanager/types" +) + +// Email implements a Notifier for email notifications. +type Email struct { + conf *config.EmailConfig + tmpl *template.Template + logger log.Logger +} + +// NewEmail returns a new Email notifier. +func NewEmail(c *config.EmailConfig, t *template.Template, l log.Logger) *Email { + if _, ok := c.Headers["Subject"]; !ok { + c.Headers["Subject"] = config.DefaultEmailSubject + } + if _, ok := c.Headers["To"]; !ok { + c.Headers["To"] = c.To + } + if _, ok := c.Headers["From"]; !ok { + c.Headers["From"] = c.From + } + return &Email{conf: c, tmpl: t, logger: l} +} + +// auth resolves a string of authentication mechanisms. +func (n *Email) auth(mechs string) (smtp.Auth, error) { + username := n.conf.AuthUsername + + // If no username is set, keep going without authentication. + if n.conf.AuthUsername == "" { + level.Debug(n.logger).Log("msg", "smtp_auth_username is not configured. Attempting to send email without authenticating") + return nil, nil + } + + err := &types.MultiError{} + for _, mech := range strings.Split(mechs, " ") { + switch mech { + case "CRAM-MD5": + secret := string(n.conf.AuthSecret) + if secret == "" { + err.Add(errors.New("missing secret for CRAM-MD5 auth mechanism")) + continue + } + return smtp.CRAMMD5Auth(username, secret), nil + + case "PLAIN": + password := string(n.conf.AuthPassword) + if password == "" { + err.Add(errors.New("missing password for PLAIN auth mechanism")) + continue + } + identity := n.conf.AuthIdentity + + // We need to know the hostname for both auth and TLS. + host, _, err := net.SplitHostPort(n.conf.Smarthost) + if err != nil { + return nil, fmt.Errorf("invalid address: %s", err) + } + return smtp.PlainAuth(identity, username, password, host), nil + case "LOGIN": + password := string(n.conf.AuthPassword) + if password == "" { + err.Add(errors.New("missing password for LOGIN auth mechanism")) + continue + } + return LoginAuth(username, password), nil + } + } + if err.Len() == 0 { + err.Add(errors.New("unknown auth mechanism: " + mechs)) + } + return nil, err +} + +// Notify implements the Notifier interface. +func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { + // We need to know the hostname for both auth and TLS. + var c *smtp.Client + host, port, err := net.SplitHostPort(n.conf.Smarthost) + if err != nil { + return false, fmt.Errorf("invalid address: %s", err) + } + + if port == "465" { + tlsConfig, err := commoncfg.NewTLSConfig(&n.conf.TLSConfig) + if err != nil { + return false, err + } + if tlsConfig.ServerName == "" { + tlsConfig.ServerName = host + } + + conn, err := tls.Dial("tcp", n.conf.Smarthost, tlsConfig) + if err != nil { + return true, err + } + c, err = smtp.NewClient(conn, host) + if err != nil { + return true, err + } + } else { + // Connect to the SMTP smarthost. + c, err = smtp.Dial(n.conf.Smarthost) + if err != nil { + return true, err + } + } + defer func() { + if err := c.Quit(); err != nil { + level.Error(n.logger).Log("msg", "failed to close SMTP connection", "err", err) + } + }() + + if n.conf.Hello != "" { + err := c.Hello(n.conf.Hello) + if err != nil { + return true, err + } + } + + // Global Config guarantees RequireTLS is not nil. + if *n.conf.RequireTLS { + if ok, _ := c.Extension("STARTTLS"); !ok { + return true, fmt.Errorf("require_tls: true (default), but %q does not advertise the STARTTLS extension", n.conf.Smarthost) + } + + tlsConf, err := commoncfg.NewTLSConfig(&n.conf.TLSConfig) + if err != nil { + return false, err + } + if tlsConf.ServerName == "" { + tlsConf.ServerName = host + } + + if err := c.StartTLS(tlsConf); err != nil { + return true, fmt.Errorf("starttls failed: %s", err) + } + } + + if ok, mech := c.Extension("AUTH"); ok { + auth, err := n.auth(mech) + if err != nil { + return true, err + } + if auth != nil { + if err := c.Auth(auth); err != nil { + return true, fmt.Errorf("%T failed: %s", auth, err) + } + } + } + + var ( + tmplErr error + data = n.tmpl.Data(receiverName(ctx, n.logger), groupLabels(ctx, n.logger), as...) + tmpl = tmplText(n.tmpl, data, &tmplErr) + from = tmpl(n.conf.From) + to = tmpl(n.conf.To) + ) + if tmplErr != nil { + return false, fmt.Errorf("failed to template 'from' or 'to': %v", tmplErr) + } + + addrs, err := mail.ParseAddressList(from) + if err != nil { + return false, fmt.Errorf("parsing from addresses: %s", err) + } + if len(addrs) != 1 { + return false, fmt.Errorf("must be exactly one from address") + } + if err := c.Mail(addrs[0].Address); err != nil { + return true, fmt.Errorf("sending mail from: %s", err) + } + addrs, err = mail.ParseAddressList(to) + if err != nil { + return false, fmt.Errorf("parsing to addresses: %s", err) + } + for _, addr := range addrs { + if err := c.Rcpt(addr.Address); err != nil { + return true, fmt.Errorf("sending rcpt to: %s", err) + } + } + + // Send the email body. + wc, err := c.Data() + if err != nil { + return true, err + } + defer wc.Close() + + buffer := &bytes.Buffer{} + for header, t := range n.conf.Headers { + value, err := n.tmpl.ExecuteTextString(t, data) + if err != nil { + return false, fmt.Errorf("executing %q header template: %s", header, err) + } + fmt.Fprintf(buffer, "%s: %s\r\n", header, mime.QEncoding.Encode("utf-8", value)) + } + + multipartBuffer := &bytes.Buffer{} + multipartWriter := multipart.NewWriter(multipartBuffer) + + fmt.Fprintf(buffer, "Date: %s\r\n", time.Now().Format(time.RFC1123Z)) + fmt.Fprintf(buffer, "Content-Type: multipart/alternative; boundary=%s\r\n", multipartWriter.Boundary()) + fmt.Fprintf(buffer, "MIME-Version: 1.0\r\n\r\n") + + // TODO: Add some useful headers here, such as URL of the alertmanager + // and active/resolved. + _, err = wc.Write(buffer.Bytes()) + if err != nil { + return false, fmt.Errorf("failed to write header buffer: %v", err) + } + + if len(n.conf.Text) > 0 { + // Text template + w, err := multipartWriter.CreatePart(textproto.MIMEHeader{ + "Content-Transfer-Encoding": {"quoted-printable"}, + "Content-Type": {"text/plain; charset=UTF-8"}, + }) + if err != nil { + return false, fmt.Errorf("creating part for text template: %s", err) + } + body, err := n.tmpl.ExecuteTextString(n.conf.Text, data) + if err != nil { + return false, fmt.Errorf("executing email text template: %s", err) + } + qw := quotedprintable.NewWriter(w) + _, err = qw.Write([]byte(body)) + if err != nil { + return true, err + } + err = qw.Close() + if err != nil { + return true, err + } + } + + if len(n.conf.HTML) > 0 { + // Html template + // Preferred alternative placed last per section 5.1.4 of RFC 2046 + // https://www.ietf.org/rfc/rfc2046.txt + w, err := multipartWriter.CreatePart(textproto.MIMEHeader{ + "Content-Transfer-Encoding": {"quoted-printable"}, + "Content-Type": {"text/html; charset=UTF-8"}, + }) + if err != nil { + return false, fmt.Errorf("creating part for html template: %s", err) + } + body, err := n.tmpl.ExecuteHTMLString(n.conf.HTML, data) + if err != nil { + return false, fmt.Errorf("executing email html template: %s", err) + } + qw := quotedprintable.NewWriter(w) + _, err = qw.Write([]byte(body)) + if err != nil { + return true, err + } + err = qw.Close() + if err != nil { + return true, err + } + } + + err = multipartWriter.Close() + if err != nil { + return false, fmt.Errorf("failed to close multipartWriter: %v", err) + } + + _, err = wc.Write(multipartBuffer.Bytes()) + if err != nil { + return false, fmt.Errorf("failed to write body buffer: %v", err) + } + + return false, nil +} + +type loginAuth struct { + username, password string +} + +func LoginAuth(username, password string) smtp.Auth { + return &loginAuth{username, password} +} + +func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) { + return "LOGIN", []byte{}, nil +} + +// Used for AUTH LOGIN. (Maybe password should be encrypted) +func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) { + if more { + switch strings.ToLower(string(fromServer)) { + case "username:": + return []byte(a.username), nil + case "password:": + return []byte(a.password), nil + default: + return nil, errors.New("unexpected server challenge") + } + } + return nil, nil +} diff --git a/notify/email_test.go b/notify/email_test.go new file mode 100644 index 00000000..bc1be417 --- /dev/null +++ b/notify/email_test.go @@ -0,0 +1,450 @@ +// Copyright 2019 Prometheus Team +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Some tests require a running mail catcher. We use MailDev for this purpose, +// it can work without or with authentication (LOGIN only). It exposes a REST +// API which we use to retrieve and check the sent emails. +// +// Those tests are only executed when specific environment variables are set, +// otherwise they are skipped. The tests must be run by the CI. +// +// To run the tests locally, you should start 2 MailDev containers: +// +// $ docker run --rm -p 1080:1080 -p 1025:1025 --entrypoint bin/maildev djfarrelly/maildev@sha256:624e0ec781e11c3531da83d9448f5861f258ee008c1b2da63b3248bfd680acfa -v +// $ docker run --rm -p 1081:1080 -p 1026:1025 --entrypoint bin/maildev djfarrelly/maildev@sha256:624e0ec781e11c3531da83d9448f5861f258ee008c1b2da63b3248bfd680acfa --incoming-user user --incoming-pass pass -v +// +// $ EMAIL_NO_AUTH_CONFIG=testdata/email_noauth.yml EMAIL_AUTH_CONFIG=testdata/email_auth.yml make +// +// See also https://github.com/djfarrelly/MailDev for more details. +package notify + +import ( + "context" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "os" + "strings" + "testing" + "time" + + "github.com/go-kit/kit/log" + commoncfg "github.com/prometheus/common/config" + "github.com/prometheus/common/model" + "github.com/stretchr/testify/require" + yaml "gopkg.in/yaml.v2" + + "github.com/prometheus/alertmanager/config" + "github.com/prometheus/alertmanager/template" + "github.com/prometheus/alertmanager/types" +) + +const ( + emailNoAuthConfigVar = "EMAIL_NO_AUTH_CONFIG" + emailAuthConfigVar = "EMAIL_AUTH_CONFIG" + + emailTo = "alerts@example.com" + emailFrom = "alertmanager@example.com" +) + +// email represents an email returned by the MailDev REST API. +// See https://github.com/djfarrelly/MailDev/blob/master/docs/rest.md. +type email struct { + To []map[string]string + From []map[string]string + Subject string + HTML *string + Text *string + Headers map[string]string +} + +// mailDev is a client for the MailDev server. +type mailDev struct { + *url.URL +} + +func (m *mailDev) UnmarshalYAML(unmarshal func(interface{}) error) error { + var s string + if err := unmarshal(&s); err != nil { + return err + } + urlp, err := url.Parse(s) + if err != nil { + return err + } + m.URL = urlp + return nil +} + +// getLastEmail returns the last received email. +func (m *mailDev) getLastEmail() (*email, error) { + code, b, err := m.doEmailRequest(http.MethodGet, "/email") + if err != nil { + return nil, err + } + if code != http.StatusOK { + return nil, fmt.Errorf("expected status OK, got %d", code) + } + + var emails []email + err = yaml.Unmarshal(b, &emails) + if err != nil { + return nil, err + } + if len(emails) == 0 { + return nil, fmt.Errorf("expected non-empty list of emails") + } + return &emails[len(emails)-1], nil +} + +// deleteAllEmails deletes all emails. +func (m *mailDev) deleteAllEmails() error { + _, _, err := m.doEmailRequest(http.MethodDelete, "/email/all") + return err +} + +// doEmailRequest makes a request to the MailDev API. +func (m *mailDev) doEmailRequest(method string, path string) (int, []byte, error) { + req, err := http.NewRequest(method, fmt.Sprintf("%s://%s%s", m.Scheme, m.Host, path), nil) + if err != nil { + return 0, nil, err + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + req = req.WithContext(ctx) + defer cancel() + res, err := http.DefaultClient.Do(req) + if err != nil { + return 0, nil, err + } + defer res.Body.Close() + b, err := ioutil.ReadAll(res.Body) + if err != nil { + return 0, nil, err + } + return res.StatusCode, b, nil +} + +// emailTestConfig is the configuration for the tests. +type emailTestConfig struct { + Smarthost string `yaml:"smarthost"` + Username string `yaml:"username"` + Password string `yaml:"password"` + Server *mailDev `yaml:"server"` +} + +func loadEmailTestConfiguration(f string) (emailTestConfig, error) { + c := emailTestConfig{} + b, err := ioutil.ReadFile(f) + if err != nil { + return c, err + } + + err = yaml.UnmarshalStrict(b, &c) + if err != nil { + return c, err + } + + return c, nil +} + +// notifyEmail sends a notification with one firing alert and retrieves the +// email from the SMTP server. +func notifyEmail(cfg *config.EmailConfig, server *mailDev) (*email, bool, error) { + if cfg.RequireTLS == nil { + cfg.RequireTLS = new(bool) + } + if cfg.Headers == nil { + cfg.Headers = make(map[string]string) + } + firingAlert := &types.Alert{ + Alert: model.Alert{ + Labels: model.LabelSet{}, + StartsAt: time.Now(), + EndsAt: time.Now().Add(time.Hour), + }, + } + err := server.deleteAllEmails() + if err != nil { + return nil, false, err + } + + tmpl, err := template.FromGlobs() + if err != nil { + return nil, false, err + } + tmpl.ExternalURL, _ = url.Parse("http://am") + email := NewEmail(cfg, tmpl, log.NewNopLogger()) + + ctx := context.Background() + retry, err := email.Notify(ctx, firingAlert) + if err != nil { + return nil, retry, err + } + + e, err := server.getLastEmail() + return e, retry, err +} + +// TestEmailNotifyWithoutAuthentication sends an email to an instance of +// MailDev configured with no authentication then it checks that the server has +// successfully processed the email. +func TestEmailNotifyWithoutAuthentication(t *testing.T) { + cfgFile := os.Getenv(emailNoAuthConfigVar) + if len(cfgFile) == 0 { + t.Skipf("%s not set", emailNoAuthConfigVar) + } + + c, err := loadEmailTestConfiguration(cfgFile) + if err != nil { + t.Fatal(err) + } + + _, _, err = notifyEmail( + &config.EmailConfig{ + Smarthost: c.Smarthost, + To: emailTo, + From: emailFrom, + HTML: "HTML body", + Text: "Text body", + }, + c.Server, + ) + require.NoError(t, err) +} + +// TestEmailNotifyWithSTARTTLS connects to the server, upgrades the connection +// to TLS, sends an email then it checks that the server has successfully +// processed the email. +// MailDev doesn't support STARTTLS and authentication at the same time so it +// is the only way to test successful STARTTLS. +func TestEmailNotifyWithSTARTTLS(t *testing.T) { + cfgFile := os.Getenv(emailNoAuthConfigVar) + if len(cfgFile) == 0 { + t.Skipf("%s not set", emailNoAuthConfigVar) + } + + c, err := loadEmailTestConfiguration(cfgFile) + if err != nil { + t.Fatal(err) + } + + trueVar := true + _, _, err = notifyEmail( + &config.EmailConfig{ + Smarthost: c.Smarthost, + To: emailTo, + From: emailFrom, + HTML: "HTML body", + Text: "Text body", + RequireTLS: &trueVar, + // MailDev embeds a self-signed certificate which can't be retrieved. + TLSConfig: commoncfg.TLSConfig{InsecureSkipVerify: true}, + }, + c.Server, + ) + require.NoError(t, err) +} + +// TestEmailNotifyWithAuthentication sends emails to an instance of MailDev +// configured with authentication. +func TestEmailNotifyWithAuthentication(t *testing.T) { + cfgFile := os.Getenv(emailAuthConfigVar) + if len(cfgFile) == 0 { + t.Skipf("%s not set", emailAuthConfigVar) + } + + c, err := loadEmailTestConfiguration(cfgFile) + if err != nil { + t.Fatal(err) + } + + testCases := []struct { + updateCfg func(*config.EmailConfig) + + errMsg string + retry bool + }{ + { + updateCfg: func(cfg *config.EmailConfig) { + cfg.AuthUsername = c.Username + cfg.AuthPassword = config.Secret(c.Password) + }, + }, + { + // HTML-only email. + updateCfg: func(cfg *config.EmailConfig) { + cfg.AuthUsername = c.Username + cfg.AuthPassword = config.Secret(c.Password) + cfg.Text = "" + }, + }, + { + // text-only email. + updateCfg: func(cfg *config.EmailConfig) { + cfg.AuthUsername = c.Username + cfg.AuthPassword = config.Secret(c.Password) + cfg.HTML = "" + }, + }, + { + // Multiple To addresses. + updateCfg: func(cfg *config.EmailConfig) { + cfg.AuthUsername = c.Username + cfg.AuthPassword = config.Secret(c.Password) + cfg.To = strings.Join([]string{emailTo, emailFrom}, ",") + }, + }, + { + // No more than one From address. + updateCfg: func(cfg *config.EmailConfig) { + cfg.AuthUsername = c.Username + cfg.AuthPassword = config.Secret(c.Password) + cfg.From = strings.Join([]string{emailFrom, emailTo}, ",") + }, + + errMsg: "must be exactly one from address", + retry: false, + }, + { + // Wrong credentials. + updateCfg: func(cfg *config.EmailConfig) { + cfg.AuthUsername = c.Username + cfg.AuthPassword = config.Secret(c.Password + "wrong") + }, + + errMsg: "Invalid username or password", + retry: true, + }, + { + // No credentials. + errMsg: "authentication Required", + retry: true, + }, + { + // Fail to enable STARTTLS. + updateCfg: func(cfg *config.EmailConfig) { + cfg.RequireTLS = new(bool) + *cfg.RequireTLS = true + }, + + errMsg: "does not advertise the STARTTLS extension", + retry: true, + }, + { + // Invalid Hello string. + updateCfg: func(cfg *config.EmailConfig) { + cfg.AuthUsername = c.Username + cfg.AuthPassword = config.Secret(c.Password) + cfg.Hello = "invalid hello string" + }, + + errMsg: "501 Error", + retry: true, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run("", func(t *testing.T) { + emailCfg := &config.EmailConfig{ + Smarthost: c.Smarthost, + To: emailTo, + From: emailFrom, + HTML: "HTML body", + Text: "Text body", + Headers: map[string]string{ + "Subject": "{{ len .Alerts }} {{ .Status }} alert(s)", + }, + } + if tc.updateCfg != nil { + tc.updateCfg(emailCfg) + } + + e, retry, err := notifyEmail(emailCfg, c.Server) + if len(tc.errMsg) > 0 { + require.Error(t, err) + require.Contains(t, err.Error(), tc.errMsg) + require.Equal(t, tc.retry, retry) + return + } + require.NoError(t, err) + + require.Equal(t, "1 firing alert(s)", e.Subject) + + getAddresses := func(addresses []map[string]string) []string { + res := make([]string, 0, len(addresses)) + for _, addr := range addresses { + res = append(res, addr["address"]) + } + return res + } + to := getAddresses(e.To) + from := getAddresses(e.From) + require.Equal(t, strings.Split(emailCfg.To, ","), to) + require.Equal(t, strings.Split(emailCfg.From, ","), from) + + if len(emailCfg.HTML) > 0 { + require.Equal(t, emailCfg.HTML, *e.HTML) + } else { + require.Nil(t, e.HTML) + } + + if len(emailCfg.Text) > 0 { + require.Equal(t, emailCfg.Text, *e.Text) + } else { + require.Nil(t, e.Text) + } + }) + } +} + +func TestEmailConfigNoAuthMechs(t *testing.T) { + email := &Email{ + conf: &config.EmailConfig{AuthUsername: "test"}, tmpl: &template.Template{}, logger: log.NewNopLogger(), + } + _, err := email.auth("") + require.Error(t, err) + require.Equal(t, err.Error(), "unknown auth mechanism: ") +} + +func TestEmailConfigMissingAuthParam(t *testing.T) { + conf := &config.EmailConfig{AuthUsername: "test"} + email := &Email{ + conf: conf, tmpl: &template.Template{}, logger: log.NewNopLogger(), + } + _, err := email.auth("CRAM-MD5") + require.Error(t, err) + require.Equal(t, err.Error(), "missing secret for CRAM-MD5 auth mechanism") + + _, err = email.auth("PLAIN") + require.Error(t, err) + require.Equal(t, err.Error(), "missing password for PLAIN auth mechanism") + + _, err = email.auth("LOGIN") + require.Error(t, err) + require.Equal(t, err.Error(), "missing password for LOGIN auth mechanism") + + _, err = email.auth("PLAIN LOGIN") + require.Error(t, err) + require.Equal(t, err.Error(), "missing password for PLAIN auth mechanism; missing password for LOGIN auth mechanism") +} + +func TestEmailNoUsernameStillOk(t *testing.T) { + email := &Email{ + conf: &config.EmailConfig{}, tmpl: &template.Template{}, logger: log.NewNopLogger(), + } + a, err := email.auth("CRAM-MD5") + require.NoError(t, err) + require.Nil(t, a) +} diff --git a/notify/impl.go b/notify/impl.go index e3f9fcb8..d1d0dbdf 100644 --- a/notify/impl.go +++ b/notify/impl.go @@ -17,20 +17,12 @@ import ( "bytes" "context" "crypto/sha256" - "crypto/tls" "encoding/json" "errors" "fmt" "io" "io/ioutil" - "mime" - "mime/multipart" - "mime/quotedprintable" - "net" "net/http" - "net/mail" - "net/smtp" - "net/textproto" "net/url" "strings" "time" @@ -197,275 +189,6 @@ func (w *Webhook) retry(statusCode int) (bool, error) { return false, nil } -// Email implements a Notifier for email notifications. -type Email struct { - conf *config.EmailConfig - tmpl *template.Template - logger log.Logger -} - -// NewEmail returns a new Email notifier. -func NewEmail(c *config.EmailConfig, t *template.Template, l log.Logger) *Email { - if _, ok := c.Headers["Subject"]; !ok { - c.Headers["Subject"] = config.DefaultEmailSubject - } - if _, ok := c.Headers["To"]; !ok { - c.Headers["To"] = c.To - } - if _, ok := c.Headers["From"]; !ok { - c.Headers["From"] = c.From - } - return &Email{conf: c, tmpl: t, logger: l} -} - -// auth resolves a string of authentication mechanisms. -func (n *Email) auth(mechs string) (smtp.Auth, error) { - username := n.conf.AuthUsername - - // If no username is set, keep going without authentication. - if n.conf.AuthUsername == "" { - level.Debug(n.logger).Log("msg", "smtp_auth_username is not configured. Attempting to send email without authenticating") - return nil, nil - } - - err := &types.MultiError{} - for _, mech := range strings.Split(mechs, " ") { - switch mech { - case "CRAM-MD5": - secret := string(n.conf.AuthSecret) - if secret == "" { - err.Add(errors.New("missing secret for CRAM-MD5 auth mechanism")) - continue - } - return smtp.CRAMMD5Auth(username, secret), nil - - case "PLAIN": - password := string(n.conf.AuthPassword) - if password == "" { - err.Add(errors.New("missing password for PLAIN auth mechanism")) - continue - } - identity := n.conf.AuthIdentity - - // We need to know the hostname for both auth and TLS. - host, _, err := net.SplitHostPort(n.conf.Smarthost) - if err != nil { - return nil, fmt.Errorf("invalid address: %s", err) - } - return smtp.PlainAuth(identity, username, password, host), nil - case "LOGIN": - password := string(n.conf.AuthPassword) - if password == "" { - err.Add(errors.New("missing password for LOGIN auth mechanism")) - continue - } - return LoginAuth(username, password), nil - } - } - if err.Len() == 0 { - err.Add(errors.New("unknown auth mechanism: " + mechs)) - } - return nil, err -} - -// Notify implements the Notifier interface. -func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { - // We need to know the hostname for both auth and TLS. - var c *smtp.Client - host, port, err := net.SplitHostPort(n.conf.Smarthost) - if err != nil { - return false, fmt.Errorf("invalid address: %s", err) - } - - if port == "465" { - tlsConfig, err := commoncfg.NewTLSConfig(&n.conf.TLSConfig) - if err != nil { - return false, err - } - if tlsConfig.ServerName == "" { - tlsConfig.ServerName = host - } - - conn, err := tls.Dial("tcp", n.conf.Smarthost, tlsConfig) - if err != nil { - return true, err - } - c, err = smtp.NewClient(conn, host) - if err != nil { - return true, err - } - - } else { - // Connect to the SMTP smarthost. - c, err = smtp.Dial(n.conf.Smarthost) - if err != nil { - return true, err - } - } - defer func() { - if err := c.Quit(); err != nil { - level.Error(n.logger).Log("msg", "failed to close SMTP connection", "err", err) - } - }() - - if n.conf.Hello != "" { - err := c.Hello(n.conf.Hello) - if err != nil { - return true, err - } - } - - // Global Config guarantees RequireTLS is not nil - if *n.conf.RequireTLS { - if ok, _ := c.Extension("STARTTLS"); !ok { - return true, fmt.Errorf("require_tls: true (default), but %q does not advertise the STARTTLS extension", n.conf.Smarthost) - } - - tlsConf, err := commoncfg.NewTLSConfig(&n.conf.TLSConfig) - if err != nil { - return false, err - } - if tlsConf.ServerName == "" { - tlsConf.ServerName = host - } - - if err := c.StartTLS(tlsConf); err != nil { - return true, fmt.Errorf("starttls failed: %s", err) - } - } - - if ok, mech := c.Extension("AUTH"); ok { - auth, err := n.auth(mech) - if err != nil { - return true, err - } - if auth != nil { - if err := c.Auth(auth); err != nil { - return true, fmt.Errorf("%T failed: %s", auth, err) - } - } - } - - var ( - tmplErr error - data = n.tmpl.Data(receiverName(ctx, n.logger), groupLabels(ctx, n.logger), as...) - tmpl = tmplText(n.tmpl, data, &tmplErr) - from = tmpl(n.conf.From) - to = tmpl(n.conf.To) - ) - if tmplErr != nil { - return false, fmt.Errorf("failed to template 'from' or 'to': %v", tmplErr) - } - - addrs, err := mail.ParseAddressList(from) - if err != nil { - return false, fmt.Errorf("parsing from addresses: %s", err) - } - if len(addrs) != 1 { - return false, fmt.Errorf("must be exactly one from address") - } - if err := c.Mail(addrs[0].Address); err != nil { - return true, fmt.Errorf("sending mail from: %s", err) - } - addrs, err = mail.ParseAddressList(to) - if err != nil { - return false, fmt.Errorf("parsing to addresses: %s", err) - } - for _, addr := range addrs { - if err := c.Rcpt(addr.Address); err != nil { - return true, fmt.Errorf("sending rcpt to: %s", err) - } - } - - // Send the email body. - wc, err := c.Data() - if err != nil { - return true, err - } - defer wc.Close() - - for header, t := range n.conf.Headers { - value, err := n.tmpl.ExecuteTextString(t, data) - if err != nil { - return false, fmt.Errorf("executing %q header template: %s", header, err) - } - fmt.Fprintf(wc, "%s: %s\r\n", header, mime.QEncoding.Encode("utf-8", value)) - } - - buffer := &bytes.Buffer{} - multipartWriter := multipart.NewWriter(buffer) - - fmt.Fprintf(wc, "Date: %s\r\n", time.Now().Format(time.RFC1123Z)) - fmt.Fprintf(wc, "Content-Type: multipart/alternative; boundary=%s\r\n", multipartWriter.Boundary()) - fmt.Fprintf(wc, "MIME-Version: 1.0\r\n") - - // TODO: Add some useful headers here, such as URL of the alertmanager - // and active/resolved. - fmt.Fprintf(wc, "\r\n") - - if len(n.conf.Text) > 0 { - // Text template - w, err := multipartWriter.CreatePart(textproto.MIMEHeader{ - "Content-Transfer-Encoding": {"quoted-printable"}, - "Content-Type": {"text/plain; charset=UTF-8"}, - }) - if err != nil { - return false, fmt.Errorf("creating part for text template: %s", err) - } - body, err := n.tmpl.ExecuteTextString(n.conf.Text, data) - if err != nil { - return false, fmt.Errorf("executing email text template: %s", err) - } - qw := quotedprintable.NewWriter(w) - _, err = qw.Write([]byte(body)) - if err != nil { - return true, err - } - err = qw.Close() - if err != nil { - return true, err - } - } - - if len(n.conf.HTML) > 0 { - // Html template - // Preferred alternative placed last per section 5.1.4 of RFC 2046 - // https://www.ietf.org/rfc/rfc2046.txt - w, err := multipartWriter.CreatePart(textproto.MIMEHeader{ - "Content-Transfer-Encoding": {"quoted-printable"}, - "Content-Type": {"text/html; charset=UTF-8"}, - }) - if err != nil { - return false, fmt.Errorf("creating part for html template: %s", err) - } - body, err := n.tmpl.ExecuteHTMLString(n.conf.HTML, data) - if err != nil { - return false, fmt.Errorf("executing email html template: %s", err) - } - qw := quotedprintable.NewWriter(w) - _, err = qw.Write([]byte(body)) - if err != nil { - return true, err - } - err = qw.Close() - if err != nil { - return true, err - } - } - - err = multipartWriter.Close() - if err != nil { - return false, fmt.Errorf("failed to close multipartWriter: %v", err) - } - - _, err = wc.Write(buffer.Bytes()) - if err != nil { - return false, fmt.Errorf("failed to write body buffer: %v", err) - } - - return false, nil -} - // PagerDuty implements a Notifier for PagerDuty notifications. type PagerDuty struct { conf *config.PagerdutyConfig @@ -1543,33 +1266,6 @@ func tmplHTML(tmpl *template.Template, data *template.Data, err *error) func(str } } -type loginAuth struct { - username, password string -} - -func LoginAuth(username, password string) smtp.Auth { - return &loginAuth{username, password} -} - -func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) { - return "LOGIN", []byte{}, nil -} - -// Used for AUTH LOGIN. (Maybe password should be encrypted) -func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) { - if more { - switch strings.ToLower(string(fromServer)) { - case "username:": - return []byte(a.username), nil - case "password:": - return []byte(a.password), nil - default: - return nil, errors.New("unexpected server challenge") - } - } - return nil, nil -} - // hashKey returns the sha256 for a group key as integrations may have // maximum length requirements on deduplication keys. func hashKey(s string) string { diff --git a/notify/impl_test.go b/notify/impl_test.go index bac38d81..eb2850e5 100644 --- a/notify/impl_test.go +++ b/notify/impl_test.go @@ -292,48 +292,6 @@ func TestOpsGenie(t *testing.T) { require.Equal(t, err.Error(), "templating error: template: :1: function \"kaput\" not defined") } -func TestEmailConfigNoAuthMechs(t *testing.T) { - - email := &Email{ - conf: &config.EmailConfig{AuthUsername: "test"}, tmpl: &template.Template{}, logger: log.NewNopLogger(), - } - _, err := email.auth("") - require.Error(t, err) - require.Equal(t, err.Error(), "unknown auth mechanism: ") -} - -func TestEmailConfigMissingAuthParam(t *testing.T) { - - conf := &config.EmailConfig{AuthUsername: "test"} - email := &Email{ - conf: conf, tmpl: &template.Template{}, logger: log.NewNopLogger(), - } - _, err := email.auth("CRAM-MD5") - require.Error(t, err) - require.Equal(t, err.Error(), "missing secret for CRAM-MD5 auth mechanism") - - _, err = email.auth("PLAIN") - require.Error(t, err) - require.Equal(t, err.Error(), "missing password for PLAIN auth mechanism") - - _, err = email.auth("LOGIN") - require.Error(t, err) - require.Equal(t, err.Error(), "missing password for LOGIN auth mechanism") - - _, err = email.auth("PLAIN LOGIN") - require.Error(t, err) - require.Equal(t, err.Error(), "missing password for PLAIN auth mechanism; missing password for LOGIN auth mechanism") -} - -func TestEmailNoUsernameStillOk(t *testing.T) { - email := &Email{ - conf: &config.EmailConfig{}, tmpl: &template.Template{}, logger: log.NewNopLogger(), - } - a, err := email.auth("CRAM-MD5") - require.NoError(t, err) - require.Nil(t, a) -} - func TestVictorOpsCustomFields(t *testing.T) { logger := log.NewNopLogger() tmpl := createTmpl(t)