mirror of
https://github.com/coder/coder.git
synced 2025-07-15 22:20:27 +00:00
feat: add SMTP auth & TLS support (#13902)
This commit is contained in:
@ -3,19 +3,25 @@ package dispatch
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"mime/quotedprintable"
|
||||
"net"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"net/textproto"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-sasl"
|
||||
smtp "github.com/emersion/go-smtp"
|
||||
"github.com/google/uuid"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
@ -41,11 +47,12 @@ var (
|
||||
|
||||
// SMTPHandler is responsible for dispatching notification messages via SMTP.
|
||||
// NOTE: auth and TLS is currently *not* enabled in this initial thin slice.
|
||||
// TODO: implement auth
|
||||
// TODO: implement TLS
|
||||
// TODO: implement DKIM/SPF/DMARC? https://github.com/emersion/go-msgauth
|
||||
type SMTPHandler struct {
|
||||
cfg codersdk.NotificationsEmailConfig
|
||||
log slog.Logger
|
||||
|
||||
loginWarnOnce sync.Once
|
||||
}
|
||||
|
||||
func NewSMTPHandler(cfg codersdk.NotificationsEmailConfig, log slog.Logger) *SMTPHandler {
|
||||
@ -83,7 +90,11 @@ func (s *SMTPHandler) Dispatcher(payload types.MessagePayload, titleTmpl, bodyTm
|
||||
|
||||
// dispatch returns a DeliveryFunc capable of delivering a notification via SMTP.
|
||||
//
|
||||
// NOTE: this is heavily inspired by Alertmanager's email notifier:
|
||||
// Our requirements are too complex to be implemented using smtp.SendMail:
|
||||
// - we require custom TLS settings
|
||||
// - dynamic determination of available AUTH mechanisms
|
||||
//
|
||||
// NOTE: this is inspired by Alertmanager's email notifier:
|
||||
// https://github.com/prometheus/alertmanager/blob/342f6a599ce16c138663f18ed0b880e777c3017d/notify/email/email.go
|
||||
func (s *SMTPHandler) dispatch(subject, htmlBody, plainBody, to string) DeliveryFunc {
|
||||
return func(ctx context.Context, msgID uuid.UUID) (bool, error) {
|
||||
@ -93,12 +104,6 @@ func (s *SMTPHandler) dispatch(subject, htmlBody, plainBody, to string) Delivery
|
||||
default:
|
||||
}
|
||||
|
||||
var (
|
||||
c *smtp.Client
|
||||
conn net.Conn
|
||||
err error
|
||||
)
|
||||
|
||||
s.log.Debug(ctx, "dispatching via SMTP", slog.F("msg_id", msgID))
|
||||
|
||||
// Dial the smarthost to establish a connection.
|
||||
@ -106,24 +111,17 @@ func (s *SMTPHandler) dispatch(subject, htmlBody, plainBody, to string) Delivery
|
||||
if err != nil {
|
||||
return false, xerrors.Errorf("'smarthost' validation: %w", err)
|
||||
}
|
||||
if smarthostPort == "465" {
|
||||
return false, xerrors.New("TLS is not currently supported")
|
||||
}
|
||||
|
||||
var d net.Dialer
|
||||
// Outer context has a deadline (see CODER_NOTIFICATIONS_DISPATCH_TIMEOUT).
|
||||
conn, err = d.DialContext(ctx, "tcp", fmt.Sprintf("%s:%s", smarthost, smarthostPort))
|
||||
if err != nil {
|
||||
return true, xerrors.Errorf("establish connection to server: %w", err)
|
||||
if _, ok := ctx.Deadline(); !ok {
|
||||
return false, xerrors.Errorf("context has no deadline")
|
||||
}
|
||||
|
||||
// Create an SMTP client.
|
||||
c, err = smtp.NewClient(conn, smarthost)
|
||||
// TODO: reuse client across dispatches (if possible).
|
||||
// Create an SMTP client for communication with the smarthost.
|
||||
c, err := s.client(ctx, smarthost, smarthostPort)
|
||||
if err != nil {
|
||||
if cerr := conn.Close(); cerr != nil {
|
||||
s.log.Warn(ctx, "failed to close connection", slog.Error(cerr))
|
||||
}
|
||||
return true, xerrors.Errorf("create client: %w", err)
|
||||
return true, xerrors.Errorf("SMTP client creation: %w", err)
|
||||
}
|
||||
|
||||
// Cleanup.
|
||||
@ -133,47 +131,42 @@ func (s *SMTPHandler) dispatch(subject, htmlBody, plainBody, to string) Delivery
|
||||
}
|
||||
}()
|
||||
|
||||
// Server handshake.
|
||||
hello, err := s.hello()
|
||||
if err != nil {
|
||||
return false, xerrors.Errorf("'hello' validation: %w", err)
|
||||
}
|
||||
err = c.Hello(hello)
|
||||
if err != nil {
|
||||
return false, xerrors.Errorf("server handshake: %w", err)
|
||||
}
|
||||
|
||||
// Check for authentication capabilities.
|
||||
// if ok, mech := c.Extension("AUTH"); ok {
|
||||
// auth, err := s.auth(mech)
|
||||
// if err != nil {
|
||||
// return true, xerrors.Errorf("find auth mechanism: %w", err)
|
||||
// }
|
||||
// if auth != nil {
|
||||
// if err := c.Auth(auth); err != nil {
|
||||
// return true, xerrors.Errorf("%T auth: %w", auth, err)
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
if ok, avail := c.Extension("AUTH"); ok {
|
||||
// Ensure the auth mechanisms available are ones we can use.
|
||||
auth, err := s.auth(ctx, avail)
|
||||
if err != nil {
|
||||
return true, xerrors.Errorf("determine auth mechanism: %w", err)
|
||||
}
|
||||
|
||||
// If so, use the auth mechanism to authenticate.
|
||||
if auth != nil {
|
||||
if err := c.Auth(auth); err != nil {
|
||||
return true, xerrors.Errorf("%T auth: %w", auth, err)
|
||||
}
|
||||
}
|
||||
} else if !s.cfg.Auth.Empty() {
|
||||
return false, xerrors.New("no authentication mechanisms supported by server")
|
||||
}
|
||||
|
||||
// Sender identification.
|
||||
from, err := s.validateFromAddr(s.cfg.From.String())
|
||||
if err != nil {
|
||||
return false, xerrors.Errorf("'from' validation: %w", err)
|
||||
}
|
||||
err = c.Mail(from)
|
||||
err = c.Mail(from, &smtp.MailOptions{})
|
||||
if err != nil {
|
||||
// This is retryable because the server may be temporarily down.
|
||||
return true, xerrors.Errorf("sender identification: %w", err)
|
||||
}
|
||||
|
||||
// Recipient designation.
|
||||
to, err := s.validateToAddrs(to)
|
||||
recipients, err := s.validateToAddrs(to)
|
||||
if err != nil {
|
||||
return false, xerrors.Errorf("'to' validation: %w", err)
|
||||
}
|
||||
for _, addr := range to {
|
||||
err = c.Rcpt(addr)
|
||||
for _, addr := range recipients {
|
||||
err = c.Rcpt(addr, &smtp.RcptOptions{})
|
||||
if err != nil {
|
||||
// This is a retryable case because the server may be temporarily down.
|
||||
// The addresses are already validated, although it is possible that the server might disagree - in which case
|
||||
@ -189,12 +182,12 @@ func (s *SMTPHandler) dispatch(subject, htmlBody, plainBody, to string) Delivery
|
||||
}
|
||||
defer message.Close()
|
||||
|
||||
// Transmit message headers.
|
||||
// Create message headers.
|
||||
msg := &bytes.Buffer{}
|
||||
multipartBuffer := &bytes.Buffer{}
|
||||
multipartWriter := multipart.NewWriter(multipartBuffer)
|
||||
_, _ = fmt.Fprintf(msg, "From: %s\r\n", from)
|
||||
_, _ = fmt.Fprintf(msg, "To: %s\r\n", strings.Join(to, ", "))
|
||||
_, _ = fmt.Fprintf(msg, "To: %s\r\n", strings.Join(recipients, ", "))
|
||||
_, _ = fmt.Fprintf(msg, "Subject: %s\r\n", subject)
|
||||
_, _ = fmt.Fprintf(msg, "Message-Id: %s@%s\r\n", msgID, s.hostname())
|
||||
_, _ = fmt.Fprintf(msg, "Date: %s\r\n", time.Now().Format(time.RFC1123Z))
|
||||
@ -260,10 +253,214 @@ func (s *SMTPHandler) dispatch(subject, htmlBody, plainBody, to string) Delivery
|
||||
}
|
||||
}
|
||||
|
||||
// auth returns a value which implements the smtp.Auth based on the available auth mechanism.
|
||||
// func (*SMTPHandler) auth(_ string) (smtp.Auth, error) {
|
||||
// return nil, nil
|
||||
//}
|
||||
// client creates an SMTP client capable of communicating over a plain or TLS-encrypted connection.
|
||||
func (s *SMTPHandler) client(ctx context.Context, host string, port string) (*smtp.Client, error) {
|
||||
var (
|
||||
c *smtp.Client
|
||||
conn net.Conn
|
||||
d net.Dialer
|
||||
err error
|
||||
)
|
||||
|
||||
// Outer context has a deadline (see CODER_NOTIFICATIONS_DISPATCH_TIMEOUT).
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
return nil, xerrors.Errorf("context has no deadline")
|
||||
}
|
||||
// Align with context deadline.
|
||||
d.Deadline = deadline
|
||||
|
||||
tlsCfg, err := s.tlsConfig()
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("build TLS config: %w", err)
|
||||
}
|
||||
|
||||
smarthost := fmt.Sprintf("%s:%s", host, port)
|
||||
useTLS := false
|
||||
|
||||
// Use TLS if known TLS port(s) are used or TLS is forced.
|
||||
if port == "465" || s.cfg.ForceTLS {
|
||||
useTLS = true
|
||||
|
||||
// STARTTLS is only used on plain connections to upgrade.
|
||||
if s.cfg.TLS.StartTLS {
|
||||
s.log.Warn(ctx, "STARTTLS is not allowed on TLS connections; disabling STARTTLS")
|
||||
s.cfg.TLS.StartTLS = false
|
||||
}
|
||||
}
|
||||
|
||||
// Dial a TLS or plain connection to the smarthost.
|
||||
if useTLS {
|
||||
conn, err = tls.DialWithDialer(&d, "tcp", smarthost, tlsCfg)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("establish TLS connection to server: %w", err)
|
||||
}
|
||||
} else {
|
||||
conn, err = d.DialContext(ctx, "tcp", smarthost)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("establish plain connection to server: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// If the connection is plain, and STARTTLS is configured, try to upgrade the connection.
|
||||
if s.cfg.TLS.StartTLS {
|
||||
c, err = smtp.NewClientStartTLS(conn, tlsCfg)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("upgrade connection with STARTTLS: %w", err)
|
||||
}
|
||||
} else {
|
||||
c = smtp.NewClient(conn)
|
||||
|
||||
// HELO is performed here and not always because smtp.NewClientStartTLS greets the server already to establish
|
||||
// whether STARTTLS is allowed.
|
||||
|
||||
var hello string
|
||||
// Server handshake.
|
||||
hello, err = s.hello()
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("'hello' validation: %w", err)
|
||||
}
|
||||
err = c.Hello(hello)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("server handshake: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Align with context deadline.
|
||||
c.CommandTimeout = time.Until(deadline)
|
||||
c.SubmissionTimeout = time.Until(deadline)
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (s *SMTPHandler) tlsConfig() (*tls.Config, error) {
|
||||
host, _, err := s.smarthost()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
srvName := s.cfg.TLS.ServerName.String()
|
||||
if srvName == "" {
|
||||
srvName = host
|
||||
}
|
||||
|
||||
ca, err := s.loadCAFile()
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("load CA: %w", err)
|
||||
}
|
||||
|
||||
var certs []tls.Certificate
|
||||
cert, err := s.loadCertificate()
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("load cert: %w", err)
|
||||
}
|
||||
|
||||
if cert != nil {
|
||||
certs = append(certs, *cert)
|
||||
}
|
||||
|
||||
return &tls.Config{
|
||||
ServerName: srvName,
|
||||
// nolint:gosec // Users may choose to enable this.
|
||||
InsecureSkipVerify: s.cfg.TLS.InsecureSkipVerify.Value(),
|
||||
|
||||
RootCAs: ca,
|
||||
Certificates: certs,
|
||||
ClientAuth: tls.RequireAndVerifyClientCert,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *SMTPHandler) loadCAFile() (*x509.CertPool, error) {
|
||||
if s.cfg.TLS.CAFile == "" {
|
||||
// nolint:nilnil // A nil CertPool is a valid response.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ca, err := os.ReadFile(s.cfg.TLS.CAFile.String())
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("load CA file: %w", err)
|
||||
}
|
||||
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(ca) {
|
||||
return nil, xerrors.Errorf("build cert pool: %w", err)
|
||||
}
|
||||
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
func (s *SMTPHandler) loadCertificate() (*tls.Certificate, error) {
|
||||
if len(s.cfg.TLS.CertFile) == 0 && len(s.cfg.TLS.KeyFile) == 0 {
|
||||
// nolint:nilnil // A nil certificate is a valid response.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
cert, err := os.ReadFile(s.cfg.TLS.CertFile.Value())
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("load cert: %w", err)
|
||||
}
|
||||
key, err := os.ReadFile(s.cfg.TLS.KeyFile.String())
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("load key: %w", err)
|
||||
}
|
||||
|
||||
pair, err := tls.X509KeyPair(cert, key)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("invalid or unusable keypair: %w", err)
|
||||
}
|
||||
|
||||
return &pair, nil
|
||||
}
|
||||
|
||||
// auth returns a value which implements the smtp.Auth based on the available auth mechanisms.
|
||||
func (s *SMTPHandler) auth(ctx context.Context, mechs string) (sasl.Client, error) {
|
||||
username := s.cfg.Auth.Username.String()
|
||||
|
||||
var errs error
|
||||
list := strings.Split(mechs, " ")
|
||||
for _, mech := range list {
|
||||
switch mech {
|
||||
case sasl.Plain:
|
||||
password, err := s.password()
|
||||
if err != nil {
|
||||
errs = multierror.Append(errs, err)
|
||||
continue
|
||||
}
|
||||
if password == "" {
|
||||
errs = multierror.Append(errs, xerrors.New("cannot use PLAIN auth, password not defined (see CODER_NOTIFICATIONS_EMAIL_AUTH_PASSWORD)"))
|
||||
continue
|
||||
}
|
||||
|
||||
return sasl.NewPlainClient(s.cfg.Auth.Identity.String(), username, password), nil
|
||||
case sasl.Login:
|
||||
if slices.Contains(list, sasl.Plain) {
|
||||
// Prefer PLAIN over LOGIN.
|
||||
continue
|
||||
}
|
||||
|
||||
// Warn that LOGIN is obsolete, but don't do it every time we dispatch a notification.
|
||||
s.loginWarnOnce.Do(func() {
|
||||
s.log.Warn(ctx, "LOGIN auth is obsolete and should be avoided (use PLAIN instead): https://www.ietf.org/archive/id/draft-murchison-sasl-login-00.txt")
|
||||
})
|
||||
|
||||
password, err := s.password()
|
||||
if err != nil {
|
||||
errs = multierror.Append(errs, err)
|
||||
continue
|
||||
}
|
||||
if password == "" {
|
||||
errs = multierror.Append(errs, xerrors.New("cannot use LOGIN auth, password not defined (see CODER_NOTIFICATIONS_EMAIL_AUTH_PASSWORD)"))
|
||||
continue
|
||||
}
|
||||
|
||||
return sasl.NewLoginClient(username, password), nil
|
||||
default:
|
||||
return nil, xerrors.Errorf("unsupported auth mechanism: %q (supported: %v)", mechs, []string{sasl.Plain, sasl.Login})
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errs
|
||||
}
|
||||
|
||||
func (*SMTPHandler) validateFromAddr(from string) (string, error) {
|
||||
addrs, err := mail.ParseAddressList(from)
|
||||
@ -330,3 +527,16 @@ func (*SMTPHandler) hostname() string {
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// password returns either the configured password, or reads it from the configured file (if possible).
|
||||
func (s *SMTPHandler) password() (string, error) {
|
||||
file := s.cfg.Auth.PasswordFile.String()
|
||||
if len(file) > 0 {
|
||||
content, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("could not read %s: %w", file, err)
|
||||
}
|
||||
return string(content), nil
|
||||
}
|
||||
return s.cfg.Auth.Password.String(), nil
|
||||
}
|
||||
|
Reference in New Issue
Block a user