Files
LambdaMail/services/protocols/internal/application/usecase/relay_test.go
T

310 lines
8.4 KiB
Go

package usecase
import (
"bufio"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"math/big"
"net"
"strings"
"testing"
"time"
"lambdamail/protocols/internal/domain/entity"
)
// fakeRelay serves one SMTP conversation with STARTTLS and AUTH, recording
// what the client sent.
type fakeRelay struct {
addr string
authSeen chan string
mailFrom chan string
offerTLS bool
certificate tls.Certificate
}
func startFakeRelay(t *testing.T, offerTLS bool) *fakeRelay {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { ln.Close() })
relay := &fakeRelay{
addr: ln.Addr().String(),
authSeen: make(chan string, 1),
mailFrom: make(chan string, 1),
offerTLS: offerTLS,
}
if offerTLS {
relay.certificate = relayTestCertificate(t)
}
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go relay.serve(conn)
}
}()
return relay
}
func (f *fakeRelay) serve(conn net.Conn) {
defer conn.Close()
conn.SetDeadline(time.Now().Add(10 * time.Second))
r := bufio.NewReader(conn)
conn.Write([]byte("220 relay.test ESMTP\r\n"))
for {
line, err := r.ReadString('\n')
if err != nil {
return
}
cmd := strings.ToUpper(strings.TrimSpace(line))
switch {
case strings.HasPrefix(cmd, "EHLO"):
if f.offerTLS {
conn.Write([]byte("250-relay.test\r\n250-STARTTLS\r\n250 AUTH PLAIN LOGIN\r\n"))
} else {
conn.Write([]byte("250-relay.test\r\n250 AUTH PLAIN LOGIN\r\n"))
}
case cmd == "STARTTLS":
conn.Write([]byte("220 Ready to start TLS\r\n"))
tlsConn := tls.Server(conn, &tls.Config{Certificates: []tls.Certificate{f.certificate}})
if err := tlsConn.Handshake(); err != nil {
return
}
conn = tlsConn
r = bufio.NewReader(conn)
case strings.HasPrefix(cmd, "AUTH PLAIN"):
payload := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line)[len("AUTH PLAIN"):], " "))
if decoded, err := base64.StdEncoding.DecodeString(payload); err == nil {
select {
case f.authSeen <- strings.ReplaceAll(string(decoded), "\x00", "|"):
default:
}
}
conn.Write([]byte("235 2.7.0 Authentication successful\r\n"))
case strings.HasPrefix(cmd, "MAIL FROM"):
select {
case f.mailFrom <- strings.TrimSpace(line):
default:
}
conn.Write([]byte("250 Ok\r\n"))
case cmd == "DATA":
conn.Write([]byte("354 Send it\r\n"))
for {
body, err := r.ReadString('\n')
if err != nil {
return
}
if strings.TrimSpace(body) == "." {
break
}
}
conn.Write([]byte("250 2.0.0 Queued\r\n"))
case cmd == "QUIT":
conn.Write([]byte("221 Bye\r\n"))
return
default:
conn.Write([]byte("250 Ok\r\n"))
}
}
}
func relayWorker(t *testing.T, relay RelayConfig) (*OutboundWorkerUseCase, *fakeOutboundRepo) {
t.Helper()
repo := &fakeOutboundRepo{}
repo.Enqueue(context.Background(), newTestJob())
worker := NewOutboundWorkerUseCase(
repo,
// A resolver that would fail loudly if it were consulted: with a
// relay configured, MX resolution must not happen at all.
&fakeMXResolver{hosts: []string{"127.0.0.1:1"}},
&fakeBlobReader{payload: []byte("From: a\r\nTo: b\r\n\r\nTest")},
nil, nil, "mail.local",
)
worker.SetRelay(relay)
return worker, repo
}
// PLAN.md section 10.4: with a smarthost every message leaves through the
// relay, whatever the destination domain is.
func TestOutboundWorker_DeliversThroughRelayInsteadOfMX(t *testing.T) {
relay := startFakeRelay(t, true)
host, portStr, _ := net.SplitHostPort(relay.addr)
worker, repo := relayWorker(t, RelayConfig{
Host: host,
Port: atoiOrZero(portStr),
Username: "relay-user",
Password: "relay-pass",
RootCAs: relayTestRoots,
})
if _, err := worker.ProcessBatch(context.Background(), "w1", 10); err != nil {
t.Fatalf("ProcessBatch: %v", err)
}
job := repo.jobs[0]
if job.Status != entity.OutboundJobStatusDelivered {
t.Fatalf("status = %s, want DELIVERED (last error: %s)", job.Status, job.LastError)
}
if job.TlsPolicyUsed != entity.TLSModeRelay {
t.Errorf("TlsPolicyUsed = %q, want relay", job.TlsPolicyUsed)
}
select {
case credentials := <-relay.authSeen:
if !strings.Contains(credentials, "relay-user") || !strings.Contains(credentials, "relay-pass") {
t.Errorf("relay credentials = %q", credentials)
}
case <-time.After(2 * time.Second):
t.Error("the worker never authenticated against the relay")
}
select {
case from := <-relay.mailFrom:
if !strings.Contains(from, "user@domain.test") {
t.Errorf("MAIL FROM = %q, want the original envelope sender", from)
}
case <-time.After(2 * time.Second):
t.Error("the relay never received MAIL FROM")
}
}
// Sending the relay credentials over a cleartext link would leak them; the
// message is deferred instead.
func TestOutboundWorker_RefusesRelayCredentialsWithoutStartTLS(t *testing.T) {
relay := startFakeRelay(t, false)
host, portStr, _ := net.SplitHostPort(relay.addr)
worker, repo := relayWorker(t, RelayConfig{
Host: host, Port: atoiOrZero(portStr),
Username: "relay-user", Password: "relay-pass",
})
if _, err := worker.ProcessBatch(context.Background(), "w1", 10); err != nil {
t.Fatalf("ProcessBatch: %v", err)
}
job := repo.jobs[0]
if job.Status != entity.OutboundJobStatusDeferred {
t.Errorf("status = %s, want DEFERRED", job.Status)
}
if !strings.Contains(job.LastError, "STARTTLS") {
t.Errorf("LastError = %q, want it to name the missing STARTTLS", job.LastError)
}
}
// An unreachable relay defers rather than bounces: the relay may come back.
func TestOutboundWorker_UnreachableRelayDefers(t *testing.T) {
worker, repo := relayWorker(t, RelayConfig{Host: "127.0.0.1", Port: 1})
if _, err := worker.ProcessBatch(context.Background(), "w1", 10); err != nil {
t.Fatalf("ProcessBatch: %v", err)
}
if job := repo.jobs[0]; job.Status != entity.OutboundJobStatusDeferred {
t.Errorf("status = %s, want DEFERRED", job.Status)
}
}
func TestRelayConfig_AddressDefaultsToSubmissionPort(t *testing.T) {
if got := (RelayConfig{Host: "relay.example.net"}).Address(); got != "relay.example.net:587" {
t.Errorf("Address = %q, want relay.example.net:587", got)
}
if got := (RelayConfig{Host: "relay.example.net", Port: 2525}).Address(); got != "relay.example.net:2525" {
t.Errorf("Address = %q, want relay.example.net:2525", got)
}
}
// PLAN.md section 10.4: the published SPF must authorise the relay, otherwise
// the hard fail rejects everything the relay forwards.
func TestRelayConfig_SpfInclude(t *testing.T) {
cases := map[string]string{
"smtp.sendgrid.net": "include:sendgrid.net",
"relay.example.net": "include:example.net",
"example.net": "include:example.net",
"": "",
}
for host, want := range cases {
if got := (RelayConfig{Host: host}).SpfInclude(); got != want {
t.Errorf("SpfInclude(%q) = %q, want %q", host, got, want)
}
}
}
func atoiOrZero(s string) int {
value := 0
for _, c := range s {
if c < '0' || c > '9' {
return 0
}
value = value*10 + int(c-'0')
}
return value
}
// relayTestCertificate issues a certificate for 127.0.0.1 and registers it as
// a trusted root for the test, so the relay connection is verified for real
// rather than with verification switched off.
func relayTestCertificate(t *testing.T) tls.Certificate {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "relay.test"},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
DNSNames: []string{"127.0.0.1", "relay.test"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
BasicConstraintsValid: true,
IsCA: true,
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatalf("create certificate: %v", err)
}
parsed, err := x509.ParseCertificate(der)
if err != nil {
t.Fatalf("parse certificate: %v", err)
}
relayTestRoots = x509.NewCertPool()
relayTestRoots.AddCert(parsed)
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}
}
// relayTestRoots holds the CA pool for the certificate issued above.
var relayTestRoots *x509.CertPool