From 894db04d702a2fddc29e48d724bcb574d0832832 Mon Sep 17 00:00:00 2001 From: kingforaday <38838211+kingforaday@users.noreply.github.com> Date: Sun, 6 May 2018 14:56:38 -0400 Subject: [PATCH] Opportunistic TLS Support (#98) * STARTTLS Support, disabled by default. * Added documentation --- doc/config.md | 33 +++++++ pkg/config/config.go | 3 + pkg/server/smtp/handler.go | 168 ++++++++++++++++++++---------------- pkg/server/smtp/listener.go | 18 ++++ 4 files changed, 150 insertions(+), 72 deletions(-) diff --git a/doc/config.md b/doc/config.md index f932d01..e003d3a 100644 --- a/doc/config.md +++ b/doc/config.md @@ -21,6 +21,9 @@ variables it supports: INBUCKET_SMTP_STOREDOMAINS Domains to store mail for INBUCKET_SMTP_DISCARDDOMAINS Domains to discard mail for INBUCKET_SMTP_TIMEOUT 300s Idle network timeout + INBUCKET_SMTP_TLSENABLED false Enable STARTTLS option + INBUCKET_SMTP_TLSPRIVKEY cert.key X509 Private Key file for TLS Support + INBUCKET_SMTP_TLSCERT cert.crt X509 Public Certificate file for TLS Support INBUCKET_POP3_ADDR 0.0.0.0:1100 POP3 server IP4 host:port INBUCKET_POP3_DOMAIN inbucket HELLO domain INBUCKET_POP3_TIMEOUT 600s Idle network timeout @@ -202,6 +205,36 @@ to the public internet. - Default: `300s` - Values: Duration ending in `s` for seconds, `m` for minutes +### TLS Support Availability + +`INBUCKET_SMTP_TLSENABLED` + +Enable the STARTTLS option for opportunistic TLS support + +- Default: `false` +- Values: `true` or `false` + +### TLS Private Key File + +`INBUCKET_SMTP_TLSPRIVKEY` + +Specify the x509 Private key file to be used for TLS negotiation. +This option is only valid when INBUCKET_SMTP_TLSENABLED is enabled. + +- Default: `cert.key` +- Values: filename or path to private key +- Example: `server.privkey` + +### TLS Public Certificate File + +`INBUCKET_SMTP_TLSPRIVKEY` + +Specify the x509 Certificate file to be used for TLS negotiation. +This option is only valid when INBUCKET_SMTP_TLSENABLED is enabled. + +- Default: `cert.crt` +- Values: filename or path to the certificate key +- Example: `server.crt` ## POP3 diff --git a/pkg/config/config.go b/pkg/config/config.go index 3071907..9c1228f 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -76,6 +76,9 @@ type SMTP struct { StoreDomains []string `desc:"Domains to store mail for"` DiscardDomains []string `desc:"Domains to discard mail for"` Timeout time.Duration `required:"true" default:"300s" desc:"Idle network timeout"` + TLSEnabled bool `default:"false" desc:"Enable STARTTLS option"` + TLSPrivKey string `default:"cert.key" desc:"X509 Private Key file for TLS Support"` + TLSCert string `default:"cert.crt" desc:"X509 Public Certificate file for TLS Support"` Debug bool `ignored:"true"` } diff --git a/pkg/server/smtp/handler.go b/pkg/server/smtp/handler.go index ea0a801..cb54e8e 100644 --- a/pkg/server/smtp/handler.go +++ b/pkg/server/smtp/handler.go @@ -3,9 +3,11 @@ package smtp import ( "bufio" "bytes" + "crypto/tls" "fmt" "io" "net" + "net/textproto" "regexp" "strconv" "strings" @@ -58,21 +60,22 @@ func (s State) String() string { } var commands = map[string]bool{ - "HELO": true, - "EHLO": true, - "MAIL": true, - "RCPT": true, - "DATA": true, - "RSET": true, - "SEND": true, - "SOML": true, - "SAML": true, - "VRFY": true, - "EXPN": true, - "HELP": true, - "NOOP": true, - "QUIT": true, - "TURN": true, + "HELO": true, + "EHLO": true, + "MAIL": true, + "RCPT": true, + "DATA": true, + "RSET": true, + "SEND": true, + "SOML": true, + "SAML": true, + "VRFY": true, + "EXPN": true, + "HELP": true, + "NOOP": true, + "QUIT": true, + "TURN": true, + "STARTTLS": true, } // Session holds the state of an SMTP session @@ -89,12 +92,15 @@ type Session struct { recipients []*policy.Recipient // Recipients from RCPT commands. logger zerolog.Logger // Session specific logger. debug bool // Print network traffic to stdout. + tlsState *tls.ConnectionState + text *textproto.Conn } // NewSession creates a new Session for the given connection func NewSession(server *Server, id int, conn net.Conn, logger zerolog.Logger) *Session { reader := bufio.NewReader(conn) host, _, _ := net.SplitHostPort(conn.RemoteAddr().String()) + return &Session{ Server: server, id: id, @@ -105,6 +111,7 @@ func NewSession(server *Server, id int, conn net.Conn, logger zerolog.Logger) *S recipients: make([]*policy.Recipient, 0), logger: logger, debug: server.config.Debug, + text: textproto.NewConn(conn), } } @@ -135,6 +142,7 @@ func (s *Server) startSession(id int, conn net.Conn) { }() ssn := NewSession(s, id, conn, logger) + defer ssn.text.Close() ssn.greet() // This is our command reading loop @@ -232,6 +240,7 @@ func (s *Server) startSession(id int, conn net.Conn) { // GREET state -> waiting for HELO func (s *Session) greetHandler(cmd string, arg string) { + const readyBanner = "Great, let's get this show on the road" switch cmd { case "HELO": domain, err := parseHelloArgument(arg) @@ -240,7 +249,7 @@ func (s *Session) greetHandler(cmd string, arg string) { return } s.remoteDomain = domain - s.send("250 Great, let's get this show on the road") + s.send("250 " + readyBanner) s.enterState(READY) case "EHLO": domain, err := parseHelloArgument(arg) @@ -249,8 +258,12 @@ func (s *Session) greetHandler(cmd string, arg string) { return } s.remoteDomain = domain - s.send("250-Great, let's get this show on the road") + // features before SIZE per RFC + s.send("250-" + readyBanner) s.send("250-8BITMIME") + if s.Server.config.TLSEnabled && s.Server.tlsConfig != nil && s.tlsState == nil { + s.send("250-STARTTLS") + } s.send(fmt.Sprintf("250 SIZE %v", s.config.MaxMessageBytes)) s.enterState(READY) default: @@ -271,7 +284,29 @@ func parseHelloArgument(arg string) (string, error) { // READY state -> waiting for MAIL func (s *Session) readyHandler(cmd string, arg string) { - if cmd == "MAIL" { + if cmd == "STARTTLS" { + if !s.Server.config.TLSEnabled { + // invalid command since unconfigured + s.logger.Debug().Msgf("454 TLS unavailable on the server") + s.send("454 TLS unavailable on the server") + return + } + if s.tlsState != nil { + // tls state previously valid + s.logger.Debug().Msg("454 A TLS session already agreed upon.") + s.send("454 A TLS session already agreed upon.") + return + } + s.logger.Debug().Msg("Initiating TLS context.") + s.send("220 STARTTLS") + // start tls connection handshake + tlsConn := tls.Server(s.conn, s.Server.tlsConfig) + s.conn = tlsConn + s.text = textproto.NewConn(s.conn) + s.tlsState = new(tls.ConnectionState) + *s.tlsState = tlsConn.ConnectionState() + s.enterState(GREET) + } else if cmd == "MAIL" { // Capture group 1: from address. 2: optional params. m := fromRegex.FindStringSubmatch(arg) if m == nil { @@ -367,57 +402,43 @@ func (s *Session) mailHandler(cmd string, arg string) { // DATA func (s *Session) dataHandler() { s.send("354 Start mail input; end with .") - msgBuf := &bytes.Buffer{} - for { - lineBuf, err := s.readByteLine() - if err != nil { - if netErr, ok := err.(net.Error); ok { - if netErr.Timeout() { - s.send("221 Idle timeout, bye bye") - } + msgBuf, err := s.readByteLine() + if err != nil { + if netErr, ok := err.(net.Error); ok { + if netErr.Timeout() { + s.send("221 Idle timeout, bye bye") } - s.logger.Warn().Msgf("Error: %v while reading", err) - s.enterState(QUIT) - return - } - if bytes.Equal(lineBuf, []byte(".\r\n")) || bytes.Equal(lineBuf, []byte(".\n")) { - // Mail data complete. - tstamp := time.Now().Format(timeStampFormat) - for _, recip := range s.recipients { - if recip.ShouldStore() { - // Generate Received header. - prefix := fmt.Sprintf("Received: from %s ([%s]) by %s\r\n for <%s>; %s\r\n", - s.remoteDomain, s.remoteHost, s.config.Domain, recip.Address.Address, - tstamp) - // Deliver message. - _, err := s.manager.Deliver( - recip, s.from, s.recipients, prefix, msgBuf.Bytes()) - if err != nil { - s.logger.Error().Msgf("delivery for %v: %v", recip.LocalPart, err) - s.send(fmt.Sprintf("451 Failed to store message for %v", recip.LocalPart)) - s.reset() - return - } - } - expReceivedTotal.Add(1) - } - s.send("250 Mail accepted for delivery") - s.logger.Info().Msgf("Message size %v bytes", msgBuf.Len()) - s.reset() - return - } - // RFC: remove leading periods from DATA. - if len(lineBuf) > 0 && lineBuf[0] == '.' { - lineBuf = lineBuf[1:] - } - msgBuf.Write(lineBuf) - if msgBuf.Len() > s.config.MaxMessageBytes { - s.send("552 Maximum message size exceeded") - s.logger.Warn().Msgf("Max message size exceeded while in DATA") - s.reset() - return } + s.logger.Warn().Msgf("Error: %v while reading", err) + s.enterState(QUIT) + return } + mailData := bytes.NewBuffer(msgBuf) + + // Mail data complete. + tstamp := time.Now().Format(timeStampFormat) + for _, recip := range s.recipients { + if recip.ShouldStore() { + // Generate Received header. + prefix := fmt.Sprintf("Received: from %s ([%s]) by %s\r\n for <%s>; %s\r\n", + s.remoteDomain, s.remoteHost, s.config.Domain, recip.Address.Address, + tstamp) + // Deliver message. + _, err := s.manager.Deliver( + recip, s.from, s.recipients, prefix, mailData.Bytes()) + if err != nil { + s.logger.Error().Msgf("delivery for %v: %v", recip.LocalPart, err) + s.send(fmt.Sprintf("451 Failed to store message for %v", recip.LocalPart)) + s.reset() + return + } + } + expReceivedTotal.Add(1) + } + s.send("250 Mail accepted for delivery") + s.logger.Info().Msgf("Message size %v bytes", mailData.Len()) + s.reset() + return } func (s *Session) enterState(state State) { @@ -440,7 +461,7 @@ func (s *Session) send(msg string) { s.sendError = err return } - if _, err := fmt.Fprint(s.conn, msg+"\r\n"); err != nil { + if err := s.text.PrintfLine("%s", msg); err != nil { s.sendError = err s.logger.Warn().Msgf("Failed to send: %q", msg) return @@ -455,9 +476,12 @@ func (s *Session) readByteLine() ([]byte, error) { if err := s.conn.SetReadDeadline(s.nextDeadline()); err != nil { return nil, err } - b, err := s.reader.ReadBytes('\n') - if err == nil && s.debug { - fmt.Printf("%04d %s\n", s.id, bytes.TrimRight(b, "\r\n")) + b, err := s.text.ReadDotBytes() + if err != nil { + return nil, err + } + if s.debug { + fmt.Printf("%04d Received %d bytes\n", s.id, len(b)) } return b, err } @@ -467,7 +491,7 @@ func (s *Session) readLine() (line string, err error) { if err = s.conn.SetReadDeadline(s.nextDeadline()); err != nil { return "", err } - line, err = s.reader.ReadString('\n') + line, err = s.text.ReadLine() if err != nil { return "", err } @@ -486,7 +510,7 @@ func (s *Session) parseCmd(line string) (cmd string, arg string, ok bool) { case l < 4: s.logger.Warn().Msgf("Command too short: %q", line) return "", "", false - case l == 4: + case l == 4 || l == 8: return strings.ToUpper(line), "", true case l == 5: // Too long to be only command, too short to have args diff --git a/pkg/server/smtp/listener.go b/pkg/server/smtp/listener.go index 9161d75..a4d3a3f 100644 --- a/pkg/server/smtp/listener.go +++ b/pkg/server/smtp/listener.go @@ -3,6 +3,7 @@ package smtp import ( "container/list" "context" + "crypto/tls" "expvar" "net" "sync" @@ -63,6 +64,7 @@ type Server struct { manager message.Manager // Used to deliver messages. listener net.Listener // Incoming network connections. wg *sync.WaitGroup // Waitgroup tracks individual sessions. + tlsConfig *tls.Config } // NewServer creates a new Server instance with the specificed config. @@ -72,12 +74,28 @@ func NewServer( manager message.Manager, apolicy *policy.Addressing, ) *Server { + slog := log.With().Str("module", "smtp").Str("phase", "tls").Logger() + tlsConfig := &tls.Config{} + if smtpConfig.TLSEnabled { + var err error + tlsConfig.Certificates = make([]tls.Certificate, 1) + tlsConfig.Certificates[0], err = tls.LoadX509KeyPair(smtpConfig.TLSCert, smtpConfig.TLSPrivKey) + if err != nil { + slog.Error().Msgf("Failed loading X509 KeyPair: %v", err) + slog.Error().Msg("Disabling STARTTLS support") + smtpConfig.TLSEnabled = false + } else { + slog.Debug().Msg("STARTTLS feature available") + } + } + return &Server{ config: smtpConfig, globalShutdown: globalShutdown, manager: manager, addrPolicy: apolicy, wg: new(sync.WaitGroup), + tlsConfig: tlsConfig, } }