1
0
mirror of https://github.com/jhillyerd/inbucket.git synced 2025-12-17 09:37:02 +00:00

Opportunistic TLS Support (#98)

* STARTTLS Support, disabled by default.
* Added documentation
This commit is contained in:
kingforaday
2018-05-06 14:56:38 -04:00
committed by James Hillyerd
parent 58c3e17be7
commit 894db04d70
4 changed files with 150 additions and 72 deletions

View File

@@ -21,6 +21,9 @@ variables it supports:
INBUCKET_SMTP_STOREDOMAINS Domains to store mail for INBUCKET_SMTP_STOREDOMAINS Domains to store mail for
INBUCKET_SMTP_DISCARDDOMAINS Domains to discard mail for INBUCKET_SMTP_DISCARDDOMAINS Domains to discard mail for
INBUCKET_SMTP_TIMEOUT 300s Idle network timeout 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_ADDR 0.0.0.0:1100 POP3 server IP4 host:port
INBUCKET_POP3_DOMAIN inbucket HELLO domain INBUCKET_POP3_DOMAIN inbucket HELLO domain
INBUCKET_POP3_TIMEOUT 600s Idle network timeout INBUCKET_POP3_TIMEOUT 600s Idle network timeout
@@ -202,6 +205,36 @@ to the public internet.
- Default: `300s` - Default: `300s`
- Values: Duration ending in `s` for seconds, `m` for minutes - 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 ## POP3

View File

@@ -76,6 +76,9 @@ type SMTP struct {
StoreDomains []string `desc:"Domains to store mail for"` StoreDomains []string `desc:"Domains to store mail for"`
DiscardDomains []string `desc:"Domains to discard mail for"` DiscardDomains []string `desc:"Domains to discard mail for"`
Timeout time.Duration `required:"true" default:"300s" desc:"Idle network timeout"` 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"` Debug bool `ignored:"true"`
} }

View File

@@ -3,9 +3,11 @@ package smtp
import ( import (
"bufio" "bufio"
"bytes" "bytes"
"crypto/tls"
"fmt" "fmt"
"io" "io"
"net" "net"
"net/textproto"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
@@ -58,21 +60,22 @@ func (s State) String() string {
} }
var commands = map[string]bool{ var commands = map[string]bool{
"HELO": true, "HELO": true,
"EHLO": true, "EHLO": true,
"MAIL": true, "MAIL": true,
"RCPT": true, "RCPT": true,
"DATA": true, "DATA": true,
"RSET": true, "RSET": true,
"SEND": true, "SEND": true,
"SOML": true, "SOML": true,
"SAML": true, "SAML": true,
"VRFY": true, "VRFY": true,
"EXPN": true, "EXPN": true,
"HELP": true, "HELP": true,
"NOOP": true, "NOOP": true,
"QUIT": true, "QUIT": true,
"TURN": true, "TURN": true,
"STARTTLS": true,
} }
// Session holds the state of an SMTP session // Session holds the state of an SMTP session
@@ -89,12 +92,15 @@ type Session struct {
recipients []*policy.Recipient // Recipients from RCPT commands. recipients []*policy.Recipient // Recipients from RCPT commands.
logger zerolog.Logger // Session specific logger. logger zerolog.Logger // Session specific logger.
debug bool // Print network traffic to stdout. debug bool // Print network traffic to stdout.
tlsState *tls.ConnectionState
text *textproto.Conn
} }
// NewSession creates a new Session for the given connection // NewSession creates a new Session for the given connection
func NewSession(server *Server, id int, conn net.Conn, logger zerolog.Logger) *Session { func NewSession(server *Server, id int, conn net.Conn, logger zerolog.Logger) *Session {
reader := bufio.NewReader(conn) reader := bufio.NewReader(conn)
host, _, _ := net.SplitHostPort(conn.RemoteAddr().String()) host, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
return &Session{ return &Session{
Server: server, Server: server,
id: id, id: id,
@@ -105,6 +111,7 @@ func NewSession(server *Server, id int, conn net.Conn, logger zerolog.Logger) *S
recipients: make([]*policy.Recipient, 0), recipients: make([]*policy.Recipient, 0),
logger: logger, logger: logger,
debug: server.config.Debug, 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) ssn := NewSession(s, id, conn, logger)
defer ssn.text.Close()
ssn.greet() ssn.greet()
// This is our command reading loop // This is our command reading loop
@@ -232,6 +240,7 @@ func (s *Server) startSession(id int, conn net.Conn) {
// GREET state -> waiting for HELO // GREET state -> waiting for HELO
func (s *Session) greetHandler(cmd string, arg string) { func (s *Session) greetHandler(cmd string, arg string) {
const readyBanner = "Great, let's get this show on the road"
switch cmd { switch cmd {
case "HELO": case "HELO":
domain, err := parseHelloArgument(arg) domain, err := parseHelloArgument(arg)
@@ -240,7 +249,7 @@ func (s *Session) greetHandler(cmd string, arg string) {
return return
} }
s.remoteDomain = domain s.remoteDomain = domain
s.send("250 Great, let's get this show on the road") s.send("250 " + readyBanner)
s.enterState(READY) s.enterState(READY)
case "EHLO": case "EHLO":
domain, err := parseHelloArgument(arg) domain, err := parseHelloArgument(arg)
@@ -249,8 +258,12 @@ func (s *Session) greetHandler(cmd string, arg string) {
return return
} }
s.remoteDomain = domain 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") 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.send(fmt.Sprintf("250 SIZE %v", s.config.MaxMessageBytes))
s.enterState(READY) s.enterState(READY)
default: default:
@@ -271,7 +284,29 @@ func parseHelloArgument(arg string) (string, error) {
// READY state -> waiting for MAIL // READY state -> waiting for MAIL
func (s *Session) readyHandler(cmd string, arg string) { 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. // Capture group 1: from address. 2: optional params.
m := fromRegex.FindStringSubmatch(arg) m := fromRegex.FindStringSubmatch(arg)
if m == nil { if m == nil {
@@ -367,57 +402,43 @@ func (s *Session) mailHandler(cmd string, arg string) {
// DATA // DATA
func (s *Session) dataHandler() { func (s *Session) dataHandler() {
s.send("354 Start mail input; end with <CRLF>.<CRLF>") s.send("354 Start mail input; end with <CRLF>.<CRLF>")
msgBuf := &bytes.Buffer{} msgBuf, err := s.readByteLine()
for { if err != nil {
lineBuf, err := s.readByteLine() if netErr, ok := err.(net.Error); ok {
if err != nil { if netErr.Timeout() {
if netErr, ok := err.(net.Error); ok { s.send("221 Idle timeout, bye bye")
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) { func (s *Session) enterState(state State) {
@@ -440,7 +461,7 @@ func (s *Session) send(msg string) {
s.sendError = err s.sendError = err
return 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.sendError = err
s.logger.Warn().Msgf("Failed to send: %q", msg) s.logger.Warn().Msgf("Failed to send: %q", msg)
return return
@@ -455,9 +476,12 @@ func (s *Session) readByteLine() ([]byte, error) {
if err := s.conn.SetReadDeadline(s.nextDeadline()); err != nil { if err := s.conn.SetReadDeadline(s.nextDeadline()); err != nil {
return nil, err return nil, err
} }
b, err := s.reader.ReadBytes('\n') b, err := s.text.ReadDotBytes()
if err == nil && s.debug { if err != nil {
fmt.Printf("%04d %s\n", s.id, bytes.TrimRight(b, "\r\n")) return nil, err
}
if s.debug {
fmt.Printf("%04d Received %d bytes\n", s.id, len(b))
} }
return b, err return b, err
} }
@@ -467,7 +491,7 @@ func (s *Session) readLine() (line string, err error) {
if err = s.conn.SetReadDeadline(s.nextDeadline()); err != nil { if err = s.conn.SetReadDeadline(s.nextDeadline()); err != nil {
return "", err return "", err
} }
line, err = s.reader.ReadString('\n') line, err = s.text.ReadLine()
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -486,7 +510,7 @@ func (s *Session) parseCmd(line string) (cmd string, arg string, ok bool) {
case l < 4: case l < 4:
s.logger.Warn().Msgf("Command too short: %q", line) s.logger.Warn().Msgf("Command too short: %q", line)
return "", "", false return "", "", false
case l == 4: case l == 4 || l == 8:
return strings.ToUpper(line), "", true return strings.ToUpper(line), "", true
case l == 5: case l == 5:
// Too long to be only command, too short to have args // Too long to be only command, too short to have args

View File

@@ -3,6 +3,7 @@ package smtp
import ( import (
"container/list" "container/list"
"context" "context"
"crypto/tls"
"expvar" "expvar"
"net" "net"
"sync" "sync"
@@ -63,6 +64,7 @@ type Server struct {
manager message.Manager // Used to deliver messages. manager message.Manager // Used to deliver messages.
listener net.Listener // Incoming network connections. listener net.Listener // Incoming network connections.
wg *sync.WaitGroup // Waitgroup tracks individual sessions. wg *sync.WaitGroup // Waitgroup tracks individual sessions.
tlsConfig *tls.Config
} }
// NewServer creates a new Server instance with the specificed config. // NewServer creates a new Server instance with the specificed config.
@@ -72,12 +74,28 @@ func NewServer(
manager message.Manager, manager message.Manager,
apolicy *policy.Addressing, apolicy *policy.Addressing,
) *Server { ) *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{ return &Server{
config: smtpConfig, config: smtpConfig,
globalShutdown: globalShutdown, globalShutdown: globalShutdown,
manager: manager, manager: manager,
addrPolicy: apolicy, addrPolicy: apolicy,
wg: new(sync.WaitGroup), wg: new(sync.WaitGroup),
tlsConfig: tlsConfig,
} }
} }