mirror of
https://github.com/prometheus/alertmanager
synced 2025-02-20 04:37:07 +00:00
SMTP: Support SASL XOAUTH2
Signed-off-by: Jan-Otto Kröpke <joe@cloudeteer.de>
This commit is contained in:
parent
f6b942cf9b
commit
b4117666d5
@ -412,6 +412,9 @@ func (c *Config) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
||||
ec.AuthPassword = c.Global.SMTPAuthPassword
|
||||
ec.AuthPasswordFile = c.Global.SMTPAuthPasswordFile
|
||||
}
|
||||
if ec.AuthXOAuth2 == nil {
|
||||
ec.AuthXOAuth2 = c.Global.SMTPAuthXOAuth2
|
||||
}
|
||||
if ec.AuthSecret == "" {
|
||||
ec.AuthSecret = c.Global.SMTPAuthSecret
|
||||
}
|
||||
@ -820,6 +823,7 @@ type GlobalConfig struct {
|
||||
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"`
|
||||
SMTPAuthXOAuth2 *commoncfg.OAuth2 `yaml:"smtp_auth_xoauth2,omitempty" json:"smtp_auth_xoauth2,omitempty"`
|
||||
SMTPRequireTLS bool `yaml:"smtp_require_tls" json:"smtp_require_tls,omitempty"`
|
||||
SMTPTLSConfig *commoncfg.TLSConfig `yaml:"smtp_tls_config,omitempty" json:"smtp_tls_config,omitempty"`
|
||||
SlackAPIURL *SecretURL `yaml:"slack_api_url,omitempty" json:"slack_api_url,omitempty"`
|
||||
|
@ -291,6 +291,7 @@ type EmailConfig struct {
|
||||
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"`
|
||||
AuthXOAuth2 *commoncfg.OAuth2 `yaml:"auth_xoauth2,omitempty" json:"auth_xoauth2,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"`
|
||||
|
@ -91,6 +91,8 @@ global:
|
||||
[ smtp_auth_identity: <string> ]
|
||||
# SMTP Auth using CRAM-MD5.
|
||||
[ smtp_auth_secret: <secret> ]
|
||||
# SMTP Auth using SASL-XOAUTH2.
|
||||
[ smtp_auth_xoauth2: { <oauth2> } ]
|
||||
# The default SMTP TLS requirement.
|
||||
# Note that Go does not support unencrypted connections to remote SMTP endpoints.
|
||||
[ smtp_require_tls: <bool> | default = true ]
|
||||
@ -920,6 +922,7 @@ to: <tmpl_string>
|
||||
[ 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_xoauth2: { <oauth2> | default = global.smtp_auth_xoauth2 } ]
|
||||
[ auth_secret: <secret> | default = global.smtp_auth_secret ]
|
||||
[ auth_identity: <string> | default = global.smtp_auth_identity ]
|
||||
|
||||
|
2
go.mod
2
go.mod
@ -44,6 +44,7 @@ require (
|
||||
go.uber.org/automaxprocs v1.6.0
|
||||
golang.org/x/mod v0.20.0
|
||||
golang.org/x/net v0.30.0
|
||||
golang.org/x/oauth2 v0.23.0
|
||||
golang.org/x/text v0.19.0
|
||||
golang.org/x/tools v0.24.0
|
||||
gopkg.in/telebot.v3 v3.3.8
|
||||
@ -99,7 +100,6 @@ require (
|
||||
go.opentelemetry.io/otel/metric v1.24.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.24.0 // indirect
|
||||
golang.org/x/crypto v0.28.0 // indirect
|
||||
golang.org/x/oauth2 v0.23.0 // indirect
|
||||
golang.org/x/sync v0.8.0 // indirect
|
||||
golang.org/x/sys v0.26.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
|
@ -17,6 +17,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@ -28,12 +29,15 @@ import (
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/clientcredentials"
|
||||
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
@ -82,6 +86,12 @@ func (n *Email) auth(mechs string) (smtp.Auth, error) {
|
||||
err := &types.MultiError{}
|
||||
for _, mech := range strings.Split(mechs, " ") {
|
||||
switch mech {
|
||||
case "XOAUTH2":
|
||||
if n.conf.AuthXOAuth2 == nil {
|
||||
err.Add(errors.New("missing OAuth2 configuration"))
|
||||
continue
|
||||
}
|
||||
return XOAuth2Auth(n.conf.AuthUsername, n.conf.AuthXOAuth2)
|
||||
case "CRAM-MD5":
|
||||
secret := string(n.conf.AuthSecret)
|
||||
if secret == "" {
|
||||
@ -375,6 +385,76 @@ func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type xOAuth2Auth struct {
|
||||
smtpFrom string
|
||||
ts oauth2.TokenSource
|
||||
}
|
||||
|
||||
func XOAuth2Auth(authUsername string, cfg *commoncfg.OAuth2) (smtp.Auth, error) {
|
||||
if cfg == nil {
|
||||
return nil, errors.New("missing OAuth2 configuration")
|
||||
}
|
||||
|
||||
var clientSecret string
|
||||
|
||||
switch {
|
||||
case cfg.ClientSecret != "":
|
||||
clientSecret = string(cfg.ClientSecret)
|
||||
case cfg.ClientSecretFile != "":
|
||||
fileBytes, err := os.ReadFile(cfg.ClientSecretFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to read file %s: %w", cfg.ClientSecretFile, err)
|
||||
}
|
||||
|
||||
clientSecret = strings.TrimSpace(string(fileBytes))
|
||||
default:
|
||||
return nil, errors.New("no client secret provided")
|
||||
}
|
||||
|
||||
oauth2cfg := &clientcredentials.Config{
|
||||
ClientID: cfg.ClientID,
|
||||
ClientSecret: clientSecret,
|
||||
TokenURL: cfg.TokenURL,
|
||||
Scopes: cfg.Scopes,
|
||||
EndpointParams: mapToValues(cfg.EndpointParams),
|
||||
}
|
||||
|
||||
ts := oauth2cfg.TokenSource(context.Background())
|
||||
if _, err := ts.Token(); err != nil {
|
||||
return nil, fmt.Errorf("unable to get token: %w", err)
|
||||
}
|
||||
|
||||
return &xOAuth2Auth{authUsername, ts}, nil
|
||||
}
|
||||
|
||||
// Start implements the [smtp.Auth] interface.
|
||||
func (*xOAuth2Auth) Start(_ *smtp.ServerInfo) (string, []byte, error) {
|
||||
return "XOAUTH2", []byte{}, nil
|
||||
}
|
||||
|
||||
// Next implements the [smtp.Auth] interface.
|
||||
func (x *xOAuth2Auth) Next(_ []byte, more bool) ([]byte, error) {
|
||||
if more {
|
||||
accessToken, err := x.ts.Token()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to get token: %w", err)
|
||||
}
|
||||
|
||||
// Generates an unencoded XOAuth2 string of the form
|
||||
// "user=" {User} "^Aauth=Bearer " {Access Token} "^A^A"
|
||||
// as defined at https://developers.google.com/google-apps/gmail/xoauth2_protocol#the_sasl_xoauth2_mechanism.
|
||||
// as well as https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth#sasl-xoauth2
|
||||
saslOAuth2String := fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", x.smtpFrom, accessToken.AccessToken)
|
||||
|
||||
saslOAuth2Encoded := make([]byte, base64.RawStdEncoding.EncodedLen(len(saslOAuth2String)))
|
||||
base64.RawStdEncoding.Encode(saslOAuth2Encoded, []byte(saslOAuth2String))
|
||||
|
||||
return saslOAuth2Encoded, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (n *Email) getPassword() (string, error) {
|
||||
if len(n.conf.AuthPasswordFile) > 0 {
|
||||
content, err := os.ReadFile(n.conf.AuthPasswordFile)
|
||||
@ -385,3 +465,12 @@ func (n *Email) getPassword() (string, error) {
|
||||
}
|
||||
return string(n.conf.AuthPassword), nil
|
||||
}
|
||||
|
||||
func mapToValues(m map[string]string) url.Values {
|
||||
v := url.Values{}
|
||||
for name, value := range m {
|
||||
v.Set(name, value)
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
202
notify/email/email_oauth2_test.go
Normal file
202
notify/email/email_oauth2_test.go
Normal file
@ -0,0 +1,202 @@
|
||||
// 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/noauth.yml EMAIL_AUTH_CONFIG=testdata/auth.yml make
|
||||
//
|
||||
// See also https://github.com/djfarrelly/MailDev for more details.
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-sasl"
|
||||
"github.com/emersion/go-smtp"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/promslog"
|
||||
|
||||
// nolint:depguard // require cannot be called outside the main goroutine: https://pkg.go.dev/testing#T.FailNow
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
)
|
||||
|
||||
const (
|
||||
TestBearerUsername = "fxcp"
|
||||
TestBearerToken = "VkIvciKi9ijpiKNWrQmYCJrzgd9QYCMB"
|
||||
)
|
||||
|
||||
func TestEmail_OAuth2(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
// Setup mock SMTP server which will reject at the DATA stage.
|
||||
srv, l, err := mockSMTPServer(t, &xOAuth2Backend{})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
// We expect that the server has already been closed in the test.
|
||||
require.ErrorIs(t, srv.Shutdown(ctx), smtp.ErrServerClosed)
|
||||
})
|
||||
|
||||
done := make(chan any, 1)
|
||||
go func() {
|
||||
// nolint:testifylint // require cannot be called outside the main goroutine: https://pkg.go.dev/testing#T.FailNow
|
||||
assert.NoError(t, srv.Serve(l))
|
||||
close(done)
|
||||
}()
|
||||
|
||||
oidcServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"access_token":"%s","token_type":"Bearer","expires_in":3600}`, TestBearerToken)
|
||||
}))
|
||||
|
||||
// Wait for mock SMTP server to become ready.
|
||||
require.Eventuallyf(t, func() bool {
|
||||
c, err := smtp.Dial(srv.Addr)
|
||||
if err != nil {
|
||||
t.Logf("dial failed to %q: %s", srv.Addr, err)
|
||||
return false
|
||||
}
|
||||
|
||||
// Ping.
|
||||
if err = c.Noop(); err != nil {
|
||||
t.Logf("ping failed to %q: %s", srv.Addr, err)
|
||||
return false
|
||||
}
|
||||
|
||||
// Ensure we close the connection to not prevent server from shutting down cleanly.
|
||||
if err = c.Close(); err != nil {
|
||||
t.Logf("close failed to %q: %s", srv.Addr, err)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}, time.Second*10, time.Millisecond*100, "mock SMTP server failed to start")
|
||||
|
||||
// Use mock SMTP server and prepare alert to be sent.
|
||||
require.IsType(t, &net.TCPAddr{}, l.Addr())
|
||||
addr := l.Addr().(*net.TCPAddr)
|
||||
cfg := &config.EmailConfig{
|
||||
Smarthost: config.HostPort{Host: addr.IP.String(), Port: strconv.Itoa(addr.Port)},
|
||||
Hello: "localhost",
|
||||
Headers: make(map[string]string),
|
||||
From: "alertmanager@system",
|
||||
To: "sre@company",
|
||||
AuthUsername: TestBearerUsername,
|
||||
AuthXOAuth2: &commoncfg.OAuth2{
|
||||
ClientID: "client_id",
|
||||
ClientSecret: "client_secret",
|
||||
TokenURL: oidcServer.URL,
|
||||
Scopes: []string{"email"},
|
||||
},
|
||||
}
|
||||
|
||||
tmpl, firingAlert, err := prepare(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
e := New(cfg, tmpl, promslog.NewNopLogger())
|
||||
|
||||
// Send the alert to mock SMTP server.
|
||||
retry, err := e.Notify(context.Background(), firingAlert)
|
||||
require.ErrorContains(t, err, "501 5.5.4 Rejected!")
|
||||
require.True(t, retry)
|
||||
require.NoError(t, srv.Shutdown(ctx))
|
||||
|
||||
require.Eventuallyf(t, func() bool {
|
||||
<-done
|
||||
return true
|
||||
}, time.Second*10, time.Millisecond*100, "mock SMTP server goroutine failed to close in time")
|
||||
}
|
||||
|
||||
// xOAuth2Backend will reject submission at the DATA stage.
|
||||
type xOAuth2Backend struct{}
|
||||
|
||||
func (b *xOAuth2Backend) NewSession(c *smtp.Conn) (smtp.Session, error) {
|
||||
return &mockSMTPxOAuth2Session{
|
||||
conn: c,
|
||||
backend: b,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type mockSMTPxOAuth2Session struct {
|
||||
conn *smtp.Conn
|
||||
backend smtp.Backend
|
||||
}
|
||||
|
||||
func (s *mockSMTPxOAuth2Session) AuthMechanisms() []string {
|
||||
return []string{sasl.Plain, sasl.Login, "XOAUTH2"}
|
||||
}
|
||||
|
||||
func (s *mockSMTPxOAuth2Session) Auth(string) (sasl.Server, error) {
|
||||
return &xOAuth2BackendAuth{}, nil
|
||||
}
|
||||
|
||||
func (s *mockSMTPxOAuth2Session) Mail(string, *smtp.MailOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *mockSMTPxOAuth2Session) Rcpt(string, *smtp.RcptOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *mockSMTPxOAuth2Session) Data(io.Reader) error {
|
||||
return &smtp.SMTPError{Code: 501, EnhancedCode: smtp.EnhancedCode{5, 5, 4}, Message: "Rejected!"}
|
||||
}
|
||||
|
||||
func (*mockSMTPxOAuth2Session) Reset() {}
|
||||
|
||||
func (*mockSMTPxOAuth2Session) Logout() error { return nil }
|
||||
|
||||
type xOAuth2BackendAuth struct{}
|
||||
|
||||
func (*xOAuth2BackendAuth) Next(response []byte) ([]byte, bool, error) {
|
||||
// Generate empty challenge.
|
||||
if response == nil {
|
||||
return []byte{}, false, nil
|
||||
}
|
||||
|
||||
token := make([]byte, base64.RawStdEncoding.DecodedLen(len(response)))
|
||||
|
||||
_, err := base64.RawStdEncoding.Decode(token, response)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
|
||||
expectedToken := fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", TestBearerUsername, TestBearerToken)
|
||||
if expectedToken == string(token) {
|
||||
return nil, true, nil
|
||||
}
|
||||
|
||||
return nil, true, fmt.Errorf("unexpected token: %s, expected: %s", token, expectedToken)
|
||||
}
|
@ -654,6 +654,10 @@ func TestEmailConfigMissingAuthParam(t *testing.T) {
|
||||
_, err = email.auth("PLAIN LOGIN")
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "missing password for PLAIN auth mechanism; missing password for LOGIN auth mechanism", err.Error())
|
||||
|
||||
_, err = email.auth("XOAUTH2")
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "missing OAuth2 configuration", err.Error())
|
||||
}
|
||||
|
||||
func TestEmailNoUsernameStillOk(t *testing.T) {
|
||||
@ -672,7 +676,7 @@ func TestEmailRejected(t *testing.T) {
|
||||
t.Cleanup(cancel)
|
||||
|
||||
// Setup mock SMTP server which will reject at the DATA stage.
|
||||
srv, l, err := mockSMTPServer(t)
|
||||
srv, l, err := mockSMTPServer(t, &rejectingBackend{})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
// We expect that the server has already been closed in the test.
|
||||
@ -736,7 +740,7 @@ func TestEmailRejected(t *testing.T) {
|
||||
}, time.Second*10, time.Millisecond*100, "mock SMTP server goroutine failed to close in time")
|
||||
}
|
||||
|
||||
func mockSMTPServer(t *testing.T) (*smtp.Server, net.Listener, error) {
|
||||
func mockSMTPServer(t *testing.T, backend smtp.Backend) (*smtp.Server, net.Listener, error) {
|
||||
t.Helper()
|
||||
|
||||
// Listen on the next available high port.
|
||||
@ -750,7 +754,7 @@ func mockSMTPServer(t *testing.T) (*smtp.Server, net.Listener, error) {
|
||||
return nil, nil, fmt.Errorf("unexpected address type: %T", l.Addr())
|
||||
}
|
||||
|
||||
s := smtp.NewServer(&rejectingBackend{})
|
||||
s := smtp.NewServer(backend)
|
||||
s.Addr = addr.String()
|
||||
s.WriteTimeout = 10 * time.Second
|
||||
s.ReadTimeout = 10 * time.Second
|
||||
|
Loading…
Reference in New Issue
Block a user