maddy/internal/testutils/smtp_server.go

438 lines
11 KiB
Go

/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov <fox.cpp@disroot.org>, Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package testutils
import (
"crypto/tls"
"crypto/x509"
"io"
"io/ioutil"
"net"
"reflect"
"sort"
"testing"
"time"
"github.com/emersion/go-smtp"
"github.com/foxcpp/maddy/framework/exterrors"
)
type SMTPMessage struct {
From string
Opts smtp.MailOptions
To []string
Data []byte
State *smtp.ConnectionState
AuthUser string
AuthPass string
}
type SMTPBackend struct {
Messages []*SMTPMessage
MailFromCounter int
SessionCounter int
SourceEndpoints map[string]struct{}
AuthErr error
MailErr error
RcptErr map[string]error
DataErr error
LMTPDataErr []error
}
func (be *SMTPBackend) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) {
if be.AuthErr != nil {
return nil, be.AuthErr
}
be.SessionCounter++
if be.SourceEndpoints == nil {
be.SourceEndpoints = make(map[string]struct{})
}
be.SourceEndpoints[state.RemoteAddr.String()] = struct{}{}
return &session{
backend: be,
user: username,
password: password,
state: state,
}, nil
}
func (be *SMTPBackend) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) {
be.SessionCounter++
if be.SourceEndpoints == nil {
be.SourceEndpoints = make(map[string]struct{})
}
be.SourceEndpoints[state.RemoteAddr.String()] = struct{}{}
return &session{backend: be, state: state}, nil
}
func (be *SMTPBackend) CheckMsg(t *testing.T, indx int, from string, rcptTo []string) {
t.Helper()
if len(be.Messages) <= indx {
t.Errorf("Expected at least %d messages in mailbox, got %d", indx+1, len(be.Messages))
return
}
msg := be.Messages[indx]
if msg.From != from {
t.Errorf("Wrong MAIL FROM: %v", msg.From)
}
sort.Strings(msg.To)
sort.Strings(rcptTo)
if !reflect.DeepEqual(msg.To, rcptTo) {
t.Errorf("Wrong RCPT TO: %v", msg.To)
}
if string(msg.Data) != DeliveryData {
t.Errorf("Wrong DATA payload: %v (%v)", string(msg.Data), msg.Data)
}
}
type session struct {
backend *SMTPBackend
user string
password string
state *smtp.ConnectionState
msg *SMTPMessage
}
func (s *session) Reset() {
s.msg = &SMTPMessage{}
}
func (s *session) Logout() error {
return nil
}
func (s *session) Mail(from string, opts smtp.MailOptions) error {
s.backend.MailFromCounter++
if s.backend.MailErr != nil {
return s.backend.MailErr
}
s.Reset()
s.msg.From = from
s.msg.Opts = opts
return nil
}
func (s *session) Rcpt(to string) error {
if err := s.backend.RcptErr[to]; err != nil {
return err
}
s.msg.To = append(s.msg.To, to)
return nil
}
func (s *session) Data(r io.Reader) error {
if s.backend.DataErr != nil {
return s.backend.DataErr
}
b, err := ioutil.ReadAll(r)
if err != nil {
return err
}
s.msg.Data = b
s.msg.State = s.state
s.msg.AuthUser = s.user
s.msg.AuthPass = s.password
s.backend.Messages = append(s.backend.Messages, s.msg)
return nil
}
func (s *session) LMTPData(r io.Reader, status smtp.StatusCollector) error {
if s.backend.DataErr != nil {
return s.backend.DataErr
}
b, err := ioutil.ReadAll(r)
if err != nil {
return err
}
s.msg.Data = b
s.msg.State = s.state
s.msg.AuthUser = s.user
s.msg.AuthPass = s.password
s.backend.Messages = append(s.backend.Messages, s.msg)
for i, rcpt := range s.msg.To {
status.SetStatus(rcpt, s.backend.LMTPDataErr[i])
}
return nil
}
type SMTPServerConfigureFunc func(*smtp.Server)
var AuthDisabled = func(s *smtp.Server) {
s.AuthDisabled = true
}
func SMTPServer(t *testing.T, addr string, fn ...SMTPServerConfigureFunc) (*SMTPBackend, *smtp.Server) {
t.Helper()
l, err := net.Listen("tcp", addr)
if err != nil {
t.Fatal(err)
}
be := new(SMTPBackend)
s := smtp.NewServer(be)
s.Domain = "localhost"
s.AllowInsecureAuth = true
for _, f := range fn {
f(s)
}
go func() {
if err := s.Serve(l); err != nil {
t.Error(err)
}
}()
// Dial it once it make sure Server completes its initialization before
// we try to use it. Notably, if test fails before connecting to the server,
// it will call Server.Close which will call Server.listener.Close with a
// nil Server.listener (Serve sets it to a non-nil value, so it is racy and
// happens only sometimes).
testConn, err := net.Dial("tcp", addr)
if err != nil {
t.Fatal(err)
}
testConn.Close()
return be, s
}
// RSA 1024, valid for *.example.invalid, 127.0.0.1, 127.0.0.2,, 127.0.0.3
// until Nov 18 17:13:45 2029 GMT.
const testServerCert = `-----BEGIN CERTIFICATE-----
MIICDzCCAXigAwIBAgIRAJ1x+qCW7L+Hs6sRU8BHmWkwDQYJKoZIhvcNAQELBQAw
EjEQMA4GA1UEChMHQWNtZSBDbzAeFw0xOTExMTgxNzEzNDVaFw0yOTExMTUxNzEz
NDVaMBIxEDAOBgNVBAoTB0FjbWUgQ28wgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJ
AoGBAPINKMyuu3AvzndLDS2/BroA+DRUcAhWPBxMxG1b1BkkHisAZWteKajKmwdO
O13N8HHBRPPOD56AAPLZGNxYLHn6nel7AiH8k40/xC5tDOthqA82+00fwJHDFCnW
oDLOLcO17HulPvfCSWfefc+uee4kajPa+47hutqZH2bGMTXhAgMBAAGjZTBjMA4G
A1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAA
MC4GA1UdEQQnMCWCESouZXhhbXBsZS5pbnZhbGlkhwR/AAABhwR/AAAChwR/AAAD
MA0GCSqGSIb3DQEBCwUAA4GBAGRn3C2NbwR4cyQmTRm5jcaqi1kAYyEu6U8Q9PJW
Q15BXMKUTx2lw//QScK9MH2JpKxDuzWDSvaxZMnTxgri2uiplqpe8ydsWj6Wl0q9
2XMGJ9LIxTZk5+cyZP2uOolvmSP/q8VFTyk9Udl6KUZPQyoiiDq4rBFUIxUyb+bX
pHkR
-----END CERTIFICATE-----`
const testServerKey = `-----BEGIN PRIVATE KEY-----
MIICeAIBADANBgkqhkiG9w0BAQEFAASCAmIwggJeAgEAAoGBAPINKMyuu3AvzndL
DS2/BroA+DRUcAhWPBxMxG1b1BkkHisAZWteKajKmwdOO13N8HHBRPPOD56AAPLZ
GNxYLHn6nel7AiH8k40/xC5tDOthqA82+00fwJHDFCnWoDLOLcO17HulPvfCSWfe
fc+uee4kajPa+47hutqZH2bGMTXhAgMBAAECgYEAgPjSDH3uEdDnSlkLJJzskJ+D
oR58s3R/gvTElSCg2uSLzo3ffF4oBHAwOqxMpabdvz8j5mSdne7Gkp9qx72TtEG2
wt6uX1tZhm2UTAkInH8IQDthj98P8vAWQsS6HHEIMErsrW2CyUrAt/+o1BRg/hWW
zixA3CLTthhZTJkaUCECQQD5EM16UcTAKfhr3IZppgq+ZsAOMkeCl3XVV9gHo32i
DL6UFAb27BAYyjfcZB1fPou4RszX0Ryu9yU0P5qm6N47AkEA+MpdAPkaPziY0ok4
e9Tcee6P0mIR+/AHk9GliVX2P74DDoOHyMXOSRBwdb+z2tYjrdjkNEL1Txe+sHny
k/EukwJBAOBqlmqPwNNRPeiaRHZvSSD0XjqsbSirJl48D4gadPoNt66fOQNGAt8D
Xj/z6U9HgQdiq/IOFmVEhT5FzSh1jL8CQQD3Myth8iGQO84tM0c6U3CWfuHMqsEv
0XnV+HNAmHdLMqOa4joi1dh4ZKs5dDdi828UJ/PnsbhI1FEWzLSpJvWdAkAkVWqf
AC/TvWvEZLA6Z5CllyNzZJ7XvtIaNOosxHDolyZ1HMWMlfEb2K2ZXWLy5foKPeoY
Xi3olS9rB0J+Rvjz
-----END PRIVATE KEY-----`
// SMTPServerSTARTTLS starts a server listening on the specified addr with the
// STARTTLS extension supported.
//
// Returned *tls.Config is for the client and is set to trust the server
// certificate.
func SMTPServerSTARTTLS(t *testing.T, addr string, fn ...SMTPServerConfigureFunc) (*tls.Config, *SMTPBackend, *smtp.Server) {
t.Helper()
cert, err := tls.X509KeyPair([]byte(testServerCert), []byte(testServerKey))
if err != nil {
panic(err)
}
l, err := net.Listen("tcp", addr)
if err != nil {
t.Fatal(err)
}
be := new(SMTPBackend)
s := smtp.NewServer(be)
s.Domain = "localhost"
s.AllowInsecureAuth = true
s.TLSConfig = &tls.Config{
Certificates: []tls.Certificate{cert},
}
for _, f := range fn {
f(s)
}
pool := x509.NewCertPool()
pool.AppendCertsFromPEM([]byte(testServerCert))
clientCfg := &tls.Config{
ServerName: "127.0.0.1",
Time: func() time.Time {
return time.Date(2019, time.November, 18, 17, 59, 41, 0, time.UTC)
},
RootCAs: pool,
}
go func() {
if err := s.Serve(l); err != nil {
t.Error(err)
}
}()
// Dial it once it make sure Server completes its initialization before
// we try to use it. Notably, if test fails before connecting to the server,
// it will call Server.Close which will call Server.listener.Close with a
// nil Server.listener (Serve sets it to a non-nil value, so it is racy and
// happens only sometimes).
testConn, err := net.Dial("tcp", addr)
if err != nil {
t.Fatal(err)
}
testConn.Close()
return clientCfg, be, s
}
// SMTPServerTLS starts a SMTP server listening on the specified addr with
// Implicit TLS.
func SMTPServerTLS(t *testing.T, addr string, fn ...SMTPServerConfigureFunc) (*tls.Config, *SMTPBackend, *smtp.Server) {
t.Helper()
cert, err := tls.X509KeyPair([]byte(testServerCert), []byte(testServerKey))
if err != nil {
panic(err)
}
l, err := tls.Listen("tcp", addr, &tls.Config{
Certificates: []tls.Certificate{cert},
})
if err != nil {
t.Fatal(err)
}
be := new(SMTPBackend)
s := smtp.NewServer(be)
s.Domain = "localhost"
for _, f := range fn {
f(s)
}
pool := x509.NewCertPool()
pool.AppendCertsFromPEM([]byte(testServerCert))
clientCfg := &tls.Config{
ServerName: "127.0.0.1",
Time: func() time.Time {
return time.Date(2019, time.November, 18, 17, 59, 41, 0, time.UTC)
},
RootCAs: pool,
}
go func() {
if err := s.Serve(l); err != nil {
t.Error(err)
}
}()
// Dial it once it make sure Server completes its initialization before
// we try to use it. Notably, if test fails before connecting to the server,
// it will call Server.Close which will call Server.listener.Close with a
// nil Server.listener (Serve sets it to a non-nil value, so it is racy and
// happens only sometimes).
testConn, err := net.Dial("tcp", addr)
if err != nil {
t.Fatal(err)
}
testConn.Close()
return clientCfg, be, s
}
func CheckSMTPConnLeak(t *testing.T, srv *smtp.Server) {
t.Helper()
// Connection closure is handled asynchronously, so before failing
// wait a bit for handleQuit in go-smtp to do its work.
for i := 0; i < 10; i++ {
found := false
srv.ForEachConn(func(_ *smtp.Conn) {
found = true
})
if !found {
return
}
time.Sleep(100 * time.Millisecond)
}
t.Error("Non-closed connections present after test completion")
}
func WaitForConnsClose(t *testing.T, srv *smtp.Server) {
t.Helper()
CheckSMTPConnLeak(t, srv)
}
// FailOnConn fails the test if attempt is made to connect the
// specified endpoint.
func FailOnConn(t *testing.T, addr string) net.Listener {
t.Helper()
tarpit, err := net.Listen("tcp", addr)
if err != nil {
t.Fatal(err)
}
go func() {
t.Helper()
_, err := tarpit.Accept()
if err == nil {
t.Error("No connection expected")
}
}()
return tarpit
}
func CheckSMTPErr(t *testing.T, err error, code int, enchCode exterrors.EnhancedCode, msg string) {
t.Helper()
if err == nil {
t.Error("Expected an error, got none")
return
}
fields := exterrors.Fields(err)
if val, _ := fields["smtp_code"].(int); val != code {
t.Errorf("Wrong smtp_code: %v", val)
}
if val, _ := fields["smtp_enchcode"].(exterrors.EnhancedCode); val != enchCode {
t.Errorf("Wrong smtp_enchcode: %v", val)
}
if val, _ := fields["smtp_msg"].(string); val != msg {
t.Errorf("Wrong smtp_msg: %v", val)
}
}