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:
committed by
James Hillyerd
parent
58c3e17be7
commit
894db04d70
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user