Некоторые проверки не удались
CI / test (push) Successful in 2m5s
Docker / Build and publish worker image (push) Failing after 31s
274 строки
8.4 KiB
Go
274 строки
8.4 KiB
Go
package sender
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/md5" //nolint:gosec // CRAM-MD5 is an SMTP protocol requirement.
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/base64"
|
|
"encoding/pem"
|
|
"errors"
|
|
"math/big"
|
|
"net"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"rocketgit.ru/rsmon/worker/internal/wire"
|
|
)
|
|
|
|
type fakeSMTPServer struct {
|
|
listener net.Listener
|
|
done chan error
|
|
tls *tls.Config
|
|
}
|
|
|
|
func newFakeSMTPServer(t *testing.T, implicitTLS, startTLS, blockEHLO bool, auth string) *fakeSMTPServer {
|
|
t.Helper()
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
require.NoError(t, err)
|
|
server := &fakeSMTPServer{listener: listener, done: make(chan error, 1), tls: fakeSMTPTLSConfig(t)}
|
|
go func() { server.done <- server.serve(implicitTLS, startTLS, blockEHLO, auth) }()
|
|
t.Cleanup(func() {
|
|
_ = listener.Close()
|
|
select {
|
|
case err := <-server.done:
|
|
require.NoError(t, err)
|
|
case <-time.After(time.Second):
|
|
t.Error("fake SMTP server did not exit")
|
|
}
|
|
})
|
|
return server
|
|
}
|
|
|
|
func (s *fakeSMTPServer) serve(implicitTLS, startTLS, blockEHLO bool, auth string) error {
|
|
conn, err := s.listener.Accept()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer conn.Close() //nolint:errcheck
|
|
if implicitTLS {
|
|
conn = tls.Server(conn, s.tls)
|
|
if err := conn.(*tls.Conn).Handshake(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
reader := bufio.NewReader(conn)
|
|
writer := bufio.NewWriter(conn)
|
|
if err := smtpReply(writer, "220 fake smtp"); err != nil {
|
|
return err
|
|
}
|
|
line, err := smtpCommand(reader)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !strings.HasPrefix(line, "EHLO ") {
|
|
return errors.New("expected EHLO")
|
|
}
|
|
if blockEHLO {
|
|
_, _ = reader.ReadByte()
|
|
return nil
|
|
}
|
|
capabilities := []string{"250-fake"}
|
|
if startTLS {
|
|
capabilities = append(capabilities, "250-STARTTLS")
|
|
}
|
|
if auth != "" {
|
|
capabilities = append(capabilities, "250-AUTH "+auth)
|
|
}
|
|
capabilities = append(capabilities, "250 OK")
|
|
if err := smtpReplies(writer, capabilities...); err != nil {
|
|
return err
|
|
}
|
|
if startTLS {
|
|
if line, err = smtpCommand(reader); err != nil || line != "STARTTLS" {
|
|
return errors.New("expected STARTTLS")
|
|
}
|
|
if err := smtpReply(writer, "220 ready for TLS"); err != nil {
|
|
return err
|
|
}
|
|
conn = tls.Server(conn, s.tls)
|
|
if err := conn.(*tls.Conn).Handshake(); err != nil {
|
|
return err
|
|
}
|
|
reader, writer = bufio.NewReader(conn), bufio.NewWriter(conn)
|
|
if line, err = smtpCommand(reader); err != nil || !strings.HasPrefix(line, "EHLO ") {
|
|
return errors.New("expected EHLO after STARTTLS")
|
|
}
|
|
capabilities = []string{"250-fake"}
|
|
if auth != "" {
|
|
capabilities = append(capabilities, "250-AUTH "+auth)
|
|
}
|
|
capabilities = append(capabilities, "250 OK")
|
|
if err := smtpReplies(writer, capabilities...); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := smtpAuthenticate(reader, writer, auth); err != nil {
|
|
return err
|
|
}
|
|
if line, err = smtpCommand(reader); err != nil || !strings.HasPrefix(line, "MAIL FROM:<sender@example.test>") {
|
|
return errors.New("expected MAIL FROM")
|
|
}
|
|
if err := smtpReply(writer, "250 sender ok"); err != nil {
|
|
return err
|
|
}
|
|
if line, err = smtpCommand(reader); err != nil || !strings.HasPrefix(line, "RCPT TO:<recipient@example.test>") {
|
|
return errors.New("expected RCPT TO")
|
|
}
|
|
if err := smtpReply(writer, "250 recipient ok"); err != nil {
|
|
return err
|
|
}
|
|
if line, err = smtpCommand(reader); err != nil || line != "DATA" {
|
|
return errors.New("expected DATA")
|
|
}
|
|
if err := smtpReply(writer, "354 send data"); err != nil {
|
|
return err
|
|
}
|
|
dataLines := 0
|
|
for {
|
|
line, err = smtpCommand(reader)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if line == "." {
|
|
break
|
|
}
|
|
dataLines++
|
|
}
|
|
if dataLines == 0 {
|
|
return errors.New("expected non-empty DATA payload")
|
|
}
|
|
if err := smtpReply(writer, "250 queued"); err != nil {
|
|
return err
|
|
}
|
|
line, err = smtpCommand(reader)
|
|
if err != nil || line != "QUIT" {
|
|
return errors.New("expected QUIT")
|
|
}
|
|
return smtpReply(writer, "221 bye")
|
|
}
|
|
|
|
func smtpAuthenticate(reader *bufio.Reader, writer *bufio.Writer, auth string) error {
|
|
if auth == "" {
|
|
return nil
|
|
}
|
|
line, err := smtpCommand(reader)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if strings.Contains(auth, "CRAM-MD5") {
|
|
if line != "AUTH CRAM-MD5" {
|
|
return errors.New("expected capability-selected CRAM-MD5 authentication")
|
|
}
|
|
challenge := []byte("fake-cram-challenge")
|
|
if err := smtpReply(writer, "334 "+base64.StdEncoding.EncodeToString(challenge)); err != nil {
|
|
return err
|
|
}
|
|
response, err := smtpCommand(reader)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
decoded, err := base64.StdEncoding.DecodeString(response)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
mac := hmac.New(md5.New, []byte("password")) //nolint:gosec // CRAM-MD5 is an SMTP protocol requirement.
|
|
_, _ = mac.Write(challenge)
|
|
if string(decoded) != "user "+fmtHex(mac.Sum(nil)) {
|
|
return errors.New("invalid CRAM-MD5 response")
|
|
}
|
|
return smtpReply(writer, "235 authenticated")
|
|
}
|
|
if line != "AUTH LOGIN" {
|
|
return errors.New("expected capability-selected LOGIN authentication")
|
|
}
|
|
if err := smtpReply(writer, "334 VXNlcm5hbWU6"); err != nil {
|
|
return err
|
|
}
|
|
if line, err = smtpCommand(reader); err != nil || line != base64.StdEncoding.EncodeToString([]byte("user")) {
|
|
return errors.New("invalid LOGIN username")
|
|
}
|
|
if err := smtpReply(writer, "334 UGFzc3dvcmQ6"); err != nil {
|
|
return err
|
|
}
|
|
if line, err = smtpCommand(reader); err != nil || line != base64.StdEncoding.EncodeToString([]byte("password")) {
|
|
return errors.New("invalid LOGIN password")
|
|
}
|
|
return smtpReply(writer, "235 authenticated")
|
|
}
|
|
|
|
func TestSendEmailWithCredentialContextImplicitTLS(t *testing.T) {
|
|
server := newFakeSMTPServer(t, true, false, false, "CRAM-MD5 PLAIN")
|
|
cred := fakeSMTPCredential(t, server.listener.Addr().String())
|
|
require.NoError(t, sendEmailWithCredentialContext(context.Background(), "recipient@example.test", "subject", "body", "<b>body</b>", cred, true))
|
|
}
|
|
|
|
func TestSendEmailWithCredentialContextSTARTTLSAndLogin(t *testing.T) {
|
|
server := newFakeSMTPServer(t, false, true, false, "LOGIN PLAIN")
|
|
cred := fakeSMTPCredential(t, server.listener.Addr().String())
|
|
require.NoError(t, SendEmailWithCredentialContext(context.Background(), "recipient@example.test", "subject", "body", "<b>body</b>", cred))
|
|
}
|
|
|
|
func TestSendEmailWithCredentialContextDeadlineInterruptsSMTPCommand(t *testing.T) {
|
|
server := newFakeSMTPServer(t, false, false, true, "")
|
|
cred := fakeSMTPCredential(t, server.listener.Addr().String())
|
|
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond)
|
|
defer cancel()
|
|
err := SendEmailWithCredentialContext(ctx, "recipient@example.test", "subject", "body", "<b>body</b>", cred)
|
|
require.ErrorIs(t, err, context.DeadlineExceeded)
|
|
}
|
|
|
|
func fakeSMTPCredential(t *testing.T, address string) *wire.SMTPCredential {
|
|
t.Helper()
|
|
host, port, err := net.SplitHostPort(address)
|
|
require.NoError(t, err)
|
|
portNumber, err := net.LookupPort("tcp", port)
|
|
require.NoError(t, err)
|
|
return &wire.SMTPCredential{Server: host, Port: portNumber, Login: "user", Password: "password", FromAddress: "sender@example.test", InsecureSkipVerify: true}
|
|
}
|
|
|
|
func fakeSMTPTLSConfig(t *testing.T) *tls.Config {
|
|
t.Helper()
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
require.NoError(t, err)
|
|
template := x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "fake smtp"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour)}
|
|
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
|
|
require.NoError(t, err)
|
|
certificate, err := tls.X509KeyPair(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
|
|
require.NoError(t, err)
|
|
return &tls.Config{Certificates: []tls.Certificate{certificate}}
|
|
}
|
|
|
|
func smtpCommand(reader *bufio.Reader) (string, error) {
|
|
line, err := reader.ReadString('\n')
|
|
return strings.TrimRight(line, "\r\n"), err
|
|
}
|
|
|
|
func smtpReply(writer *bufio.Writer, line string) error { return smtpReplies(writer, line) }
|
|
|
|
func smtpReplies(writer *bufio.Writer, lines ...string) error {
|
|
for _, line := range lines {
|
|
if _, err := writer.WriteString(line + "\r\n"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return writer.Flush()
|
|
}
|
|
|
|
func fmtHex(value []byte) string {
|
|
const hex = "0123456789abcdef"
|
|
result := make([]byte, len(value)*2)
|
|
for i, b := range value {
|
|
result[i*2], result[i*2+1] = hex[b>>4], hex[b&0x0f]
|
|
}
|
|
return string(result)
|
|
}
|