#18: implement SMTP Submission (587/465), SASL PLAIN/LOGIN auth and preflight diagnostics
This commit is contained in:
1 parent
7249419efc
commit
d56f29d084
70 files changed
+5163
-659
No files matched your search
@@ -6,10 +6,15 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// MailboxAuth is the subset of mailbox data needed to verify a login.
|
||||
// MailboxAuth is the subset of mailbox data needed to verify a login and,
|
||||
// once authenticated, to apply the sender policy of PLAN.md section 5.2.
|
||||
type MailboxAuth struct {
|
||||
ID uuid.UUID
|
||||
PasswordHash string
|
||||
EmailAddress string
|
||||
DomainName string
|
||||
// MaxRecipientsPerHour caps a compromised account's blast radius.
|
||||
MaxRecipientsPerHour int
|
||||
}
|
||||
|
||||
// AuthRepository resolves a login address to its stored credential.
|
||||
|
||||
@@ -4,11 +4,11 @@ import "context"
|
||||
|
||||
// ImapFolderRecord is the IMAP-relevant subset of a folder row.
|
||||
type ImapFolderRecord struct {
|
||||
ID string
|
||||
Name string
|
||||
UIDNext uint32
|
||||
UIDValidity uint32
|
||||
NumMessages uint32
|
||||
ID string
|
||||
Name string
|
||||
UIDNext uint32
|
||||
UIDValidity uint32
|
||||
NumMessages uint32
|
||||
HighestModSeq uint64
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@ package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"lambdamail/protocols/internal/domain/entity"
|
||||
)
|
||||
@@ -10,4 +13,9 @@ type OutboundRepository interface {
|
||||
Enqueue(ctx context.Context, job *entity.OutboundJob) error
|
||||
FetchNextReady(ctx context.Context, workerID string, limit int) ([]*entity.OutboundJob, error)
|
||||
UpdateJob(ctx context.Context, job *entity.OutboundJob) error
|
||||
|
||||
// CountRecipientsSince reports how many recipients a mailbox has queued
|
||||
// since a point in time. It backs the per-mailbox send limit that keeps a
|
||||
// compromised account from becoming a spam source (PLAN.md section 5.2).
|
||||
CountRecipientsSince(ctx context.Context, mailboxID uuid.UUID, since time.Time) (int, error)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
)
|
||||
|
||||
type stubAuthenticator struct {
|
||||
result port.InboundAuthResult
|
||||
seen port.InboundAuthInput
|
||||
}
|
||||
|
||||
func (s *stubAuthenticator) Authenticate(_ context.Context, input port.InboundAuthInput) port.InboundAuthResult {
|
||||
s.seen = input
|
||||
return s.result
|
||||
}
|
||||
|
||||
func inboundInput(recipients int) ProcessInboundEmailInput {
|
||||
in := ProcessInboundEmailInput{
|
||||
Sender: "sender@sender.test",
|
||||
Body: bytes.NewBufferString("From: sender@sender.test\r\n\r\nbody"),
|
||||
}
|
||||
for i := 0; i < recipients; i++ {
|
||||
in.Recipients = append(in.Recipients, port.MailboxRecord{ID: uuid.New()})
|
||||
in.RecipientAddresses = append(in.RecipientAddresses, "rcpt@example.test")
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
// The verdicts must reach the database, otherwise the webmail and the DMARC
|
||||
// dashboard have nothing to show (PLAN.md section 6.1).
|
||||
func TestUseCase_Handle_PersistsAuthenticationVerdicts(t *testing.T) {
|
||||
messages := &recordingMessageRepository{}
|
||||
uc := NewProcessInboundEmailUseCase(nil, &capturingBlobStorage{}, messages)
|
||||
uc.SetAuthenticator(&stubAuthenticator{result: port.InboundAuthResult{
|
||||
SPF: port.AuthResultPass, DKIM: port.AuthResultPass, DMARC: port.AuthResultPass,
|
||||
}})
|
||||
|
||||
if err := uc.Handle(context.Background(), inboundInput(1)); err != nil {
|
||||
t.Fatalf("Handle: %v", err)
|
||||
}
|
||||
|
||||
got := messages.persisted[0]
|
||||
if got.SPFResult != "pass" || got.DKIMResult != "pass" || got.DMARCResult != "pass" {
|
||||
t.Errorf("stored verdicts = %q/%q/%q, want pass/pass/pass", got.SPFResult, got.DKIMResult, got.DMARCResult)
|
||||
}
|
||||
}
|
||||
|
||||
// RFC 8601: our own checks must be recorded in the delivered message so the
|
||||
// spam filter and the user can see them.
|
||||
func TestUseCase_Handle_PrependsAuthenticationResultsHeader(t *testing.T) {
|
||||
blobs := &capturingBlobStorage{}
|
||||
uc := NewProcessInboundEmailUseCase(nil, blobs, &recordingMessageRepository{})
|
||||
uc.SetAuthenticator(&stubAuthenticator{result: port.InboundAuthResult{
|
||||
SPF: port.AuthResultPass, DKIM: port.AuthResultNone, DMARC: port.AuthResultPass,
|
||||
AuthenticationResults: "mail.lambdamail.test; spf=pass; dmarc=pass",
|
||||
}})
|
||||
|
||||
if err := uc.Handle(context.Background(), inboundInput(1)); err != nil {
|
||||
t.Fatalf("Handle: %v", err)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(string(blobs.stored), "Authentication-Results: mail.lambdamail.test") {
|
||||
t.Errorf("stored message does not lead with Authentication-Results:\n%s", blobs.stored)
|
||||
}
|
||||
}
|
||||
|
||||
// A sender that published p=reject asked for exactly this. Accepting the
|
||||
// message instead would defeat the policy the sender is paying for.
|
||||
func TestUseCase_Handle_RejectsOnDmarcFailWithRejectPolicy(t *testing.T) {
|
||||
messages := &recordingMessageRepository{}
|
||||
uc := NewProcessInboundEmailUseCase(nil, &capturingBlobStorage{}, messages)
|
||||
uc.SetAuthenticator(&stubAuthenticator{result: port.InboundAuthResult{
|
||||
SPF: port.AuthResultFail, DKIM: port.AuthResultNone,
|
||||
DMARC: port.AuthResultFail, DmarcPolicy: "reject",
|
||||
}})
|
||||
|
||||
err := uc.Handle(context.Background(), inboundInput(1))
|
||||
if err == nil {
|
||||
t.Fatal("expected the message to be refused")
|
||||
}
|
||||
if !strings.HasPrefix(err.Error(), "550 ") {
|
||||
t.Errorf("error = %q, want a permanent 550 rejection", err)
|
||||
}
|
||||
if len(messages.persisted) != 0 {
|
||||
t.Error("a DMARC-rejected message was stored anyway")
|
||||
}
|
||||
}
|
||||
|
||||
// Quarantine is a filing decision, not a refusal: the message is accepted and
|
||||
// the spam filter decides where it lands.
|
||||
func TestUseCase_Handle_AcceptsOnDmarcFailWithQuarantinePolicy(t *testing.T) {
|
||||
messages := &recordingMessageRepository{}
|
||||
uc := NewProcessInboundEmailUseCase(nil, &capturingBlobStorage{}, messages)
|
||||
uc.SetAuthenticator(&stubAuthenticator{result: port.InboundAuthResult{
|
||||
SPF: port.AuthResultFail, DMARC: port.AuthResultFail, DmarcPolicy: "quarantine",
|
||||
}})
|
||||
|
||||
if err := uc.Handle(context.Background(), inboundInput(1)); err != nil {
|
||||
t.Fatalf("Handle: %v", err)
|
||||
}
|
||||
if len(messages.persisted) != 1 {
|
||||
t.Errorf("persisted %d messages, want 1", len(messages.persisted))
|
||||
}
|
||||
}
|
||||
|
||||
// SPF is defined over the peer address and the HELO name, so both have to
|
||||
// reach the authenticator.
|
||||
func TestUseCase_Handle_PassesPeerIdentityToAuthenticator(t *testing.T) {
|
||||
auth := &stubAuthenticator{result: port.InboundAuthResult{SPF: port.AuthResultNone}}
|
||||
uc := NewProcessInboundEmailUseCase(nil, &capturingBlobStorage{}, &recordingMessageRepository{})
|
||||
uc.SetAuthenticator(auth)
|
||||
|
||||
in := inboundInput(1)
|
||||
in.ClientIP = parseTestIP("192.0.2.10")
|
||||
in.HeloDomain = "mx.sender.test"
|
||||
|
||||
if err := uc.Handle(context.Background(), in); err != nil {
|
||||
t.Fatalf("Handle: %v", err)
|
||||
}
|
||||
|
||||
if auth.seen.ClientIP.String() != "192.0.2.10" {
|
||||
t.Errorf("ClientIP = %v, want 192.0.2.10", auth.seen.ClientIP)
|
||||
}
|
||||
if auth.seen.HeloDomain != "mx.sender.test" {
|
||||
t.Errorf("HeloDomain = %q, want mx.sender.test", auth.seen.HeloDomain)
|
||||
}
|
||||
if auth.seen.EnvelopeFrom != "sender@sender.test" {
|
||||
t.Errorf("EnvelopeFrom = %q", auth.seen.EnvelopeFrom)
|
||||
}
|
||||
}
|
||||
|
||||
func parseTestIP(s string) net.IP { return net.ParseIP(s) }
|
||||
@@ -0,0 +1,99 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
"lambdamail/protocols/internal/domain/valueobject"
|
||||
)
|
||||
|
||||
type capturingBlobStorage struct {
|
||||
stored []byte
|
||||
}
|
||||
|
||||
func (c *capturingBlobStorage) Store(_ context.Context, r io.Reader) (port.BlobRef, error) {
|
||||
b, err := io.ReadAll(r)
|
||||
c.stored = b
|
||||
return port.BlobRef{ID: uuid.New(), SizeBytes: int64(len(b))}, err
|
||||
}
|
||||
|
||||
type stubScanner struct {
|
||||
result *valueobject.ScanResult
|
||||
}
|
||||
|
||||
func (s *stubScanner) Scan(_ context.Context, _ port.ScanInput) (*valueobject.ScanResult, error) {
|
||||
return s.result, nil
|
||||
}
|
||||
|
||||
// The scan verdict is only actionable by the user - and by their Sieve rules -
|
||||
// if it reaches the stored message. PLAN.md section 6.1 persists the EML
|
||||
// together with the verdicts, so the spam headers the scanner produced must be
|
||||
// present in the delivered message.
|
||||
func TestUseCase_Handle_WritesScanHeadersIntoStoredMessage(t *testing.T) {
|
||||
blobs := &capturingBlobStorage{}
|
||||
scanner := &stubScanner{result: &valueobject.ScanResult{
|
||||
Verdict: valueobject.ScanVerdictSpamJunk,
|
||||
Score: 9.5,
|
||||
HeadersToAdd: map[string]string{
|
||||
"X-Spam-Flag": "YES",
|
||||
"X-Spam-Score": "9.50",
|
||||
},
|
||||
}}
|
||||
|
||||
uc := NewProcessInboundEmailUseCase(nil, blobs, &recordingMessageRepository{})
|
||||
uc.SetScanner(scanner)
|
||||
|
||||
err := uc.Handle(context.Background(), ProcessInboundEmailInput{
|
||||
Sender: "spammer@example.test",
|
||||
Recipients: []port.MailboxRecord{{ID: uuid.New()}},
|
||||
RecipientAddresses: []string{"victim@example.test"},
|
||||
Body: bytes.NewBufferString("Subject: buy now\r\n\r\nbody"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Handle: %v", err)
|
||||
}
|
||||
|
||||
stored := string(blobs.stored)
|
||||
for _, want := range []string{"X-Spam-Flag: YES", "X-Spam-Score: 9.50"} {
|
||||
if !strings.Contains(stored, want) {
|
||||
t.Errorf("stored message is missing %q:\n%s", want, stored)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(stored, "Subject: buy now") {
|
||||
t.Errorf("original headers were lost:\n%s", stored)
|
||||
}
|
||||
// Added headers must precede the original ones and stay inside the header
|
||||
// block, i.e. before the first empty line.
|
||||
headerBlock, _, found := strings.Cut(stored, "\r\n\r\n")
|
||||
if !found {
|
||||
t.Fatalf("stored message has no header/body separator:\n%s", stored)
|
||||
}
|
||||
if !strings.Contains(headerBlock, "X-Spam-Flag: YES") {
|
||||
t.Errorf("scan headers leaked into the body:\n%s", stored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUseCase_Handle_LeavesMessageUntouchedWithoutScanHeaders(t *testing.T) {
|
||||
blobs := &capturingBlobStorage{}
|
||||
uc := NewProcessInboundEmailUseCase(nil, blobs, &recordingMessageRepository{})
|
||||
|
||||
original := "Subject: hi\r\n\r\nbody"
|
||||
err := uc.Handle(context.Background(), ProcessInboundEmailInput{
|
||||
Recipients: []port.MailboxRecord{{ID: uuid.New()}},
|
||||
RecipientAddresses: []string{"a@example.test"},
|
||||
Body: bytes.NewBufferString(original),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Handle: %v", err)
|
||||
}
|
||||
|
||||
if string(blobs.stored) != original {
|
||||
t.Errorf("message was rewritten without a scanner: got %q, want %q", blobs.stored, original)
|
||||
}
|
||||
}
|
||||
@@ -12,10 +12,10 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrScriptNotFound = errors.New("script does not exist")
|
||||
ErrActiveScriptDelete = errors.New("cannot delete active script")
|
||||
ErrScriptNotFound = errors.New("script does not exist")
|
||||
ErrActiveScriptDelete = errors.New("cannot delete active script")
|
||||
ErrScriptAlreadyExists = errors.New("script with new name already exists")
|
||||
ErrScriptQuotaExceeded = errors.New("script size exceeds storage limit")
|
||||
ErrScriptQuotaExceeded = errors.New("script size exceeds storage limit")
|
||||
)
|
||||
|
||||
const MaxScriptSizeBytes = 1024 * 1024 // 1 MB limit per Sieve script
|
||||
|
||||
@@ -135,7 +135,7 @@ func TestManageSieveSessionUseCase_Put_Get_List_SetActive_Delete(t *testing.T) {
|
||||
t.Fatalf("SetActive failed: %v", err)
|
||||
}
|
||||
|
||||
// 6. Delete active script fails per RFC 5804 §2.7
|
||||
// 6. Delete active script fails per RFC 5804 section 2.7
|
||||
if err := uc.DeleteScript(ctx, gotID, "main_rules"); !errors.Is(err, ErrActiveScriptDelete) {
|
||||
t.Fatalf("expected ErrActiveScriptDelete, got %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
"lambdamail/protocols/internal/domain/entity"
|
||||
)
|
||||
|
||||
// startFakeMX serves one SMTP conversation, answering MAIL FROM with the given
|
||||
// reply so the worker can be driven through a real permanent/temporary failure.
|
||||
func startFakeMX(t *testing.T, mailReply string) string {
|
||||
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() })
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func(c net.Conn) {
|
||||
defer c.Close()
|
||||
c.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
r := bufio.NewReader(c)
|
||||
c.Write([]byte("220 fake.mx 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"), strings.HasPrefix(cmd, "HELO"):
|
||||
c.Write([]byte("250 fake.mx\r\n"))
|
||||
case strings.HasPrefix(cmd, "MAIL FROM"):
|
||||
c.Write([]byte(mailReply + "\r\n"))
|
||||
case strings.HasPrefix(cmd, "QUIT"):
|
||||
c.Write([]byte("221 Bye\r\n"))
|
||||
return
|
||||
default:
|
||||
c.Write([]byte("250 Ok\r\n"))
|
||||
}
|
||||
}
|
||||
}(conn)
|
||||
}
|
||||
}()
|
||||
|
||||
return ln.Addr().String()
|
||||
}
|
||||
|
||||
func newTestJob() *entity.OutboundJob {
|
||||
mbID := uuid.New()
|
||||
return &entity.OutboundJob{
|
||||
ID: uuid.New(),
|
||||
MailboxID: &mbID,
|
||||
BlobID: uuid.New(),
|
||||
EnvelopeFrom: "user@domain.test",
|
||||
EnvelopeTo: "rcpt@remote.test",
|
||||
DestinationDomain: "remote.test",
|
||||
Status: entity.OutboundJobStatusQueued,
|
||||
ExpiresAt: time.Now().Add(5 * 24 * time.Hour),
|
||||
}
|
||||
}
|
||||
|
||||
// PLAN.md section 6.3: a 5xx is a permanent failure and must bounce
|
||||
// immediately with a DSN, not sit in the queue retrying for five days.
|
||||
func TestOutboundWorker_PermanentSmtpFailureBouncesImmediately(t *testing.T) {
|
||||
addr := startFakeMX(t, "550 5.1.1 User unknown")
|
||||
|
||||
repo := &fakeOutboundRepo{}
|
||||
repo.Enqueue(context.Background(), newTestJob())
|
||||
|
||||
worker := NewOutboundWorkerUseCase(
|
||||
repo,
|
||||
&fakeMXResolver{hosts: []string{addr}},
|
||||
&fakeBlobReader{payload: []byte("From: a\r\nTo: b\r\n\r\nTest")},
|
||||
nil, nil, "mail.local",
|
||||
)
|
||||
|
||||
if _, err := worker.ProcessBatch(context.Background(), "w1", 10); err != nil {
|
||||
t.Fatalf("ProcessBatch: %v", err)
|
||||
}
|
||||
|
||||
got := repo.jobs[0]
|
||||
if got.Status != entity.OutboundJobStatusBounced {
|
||||
t.Errorf("status = %s, want BOUNCED for a 5xx reply", got.Status)
|
||||
}
|
||||
if got.LastSmtpCode != "550" {
|
||||
t.Errorf("LastSmtpCode = %q, want %q", got.LastSmtpCode, "550")
|
||||
}
|
||||
}
|
||||
|
||||
// A 4xx is temporary and must be deferred for a later retry.
|
||||
func TestOutboundWorker_TemporarySmtpFailureIsDeferred(t *testing.T) {
|
||||
addr := startFakeMX(t, "451 4.3.0 Try later")
|
||||
|
||||
repo := &fakeOutboundRepo{}
|
||||
repo.Enqueue(context.Background(), newTestJob())
|
||||
|
||||
worker := NewOutboundWorkerUseCase(
|
||||
repo,
|
||||
&fakeMXResolver{hosts: []string{addr}},
|
||||
&fakeBlobReader{payload: []byte("From: a\r\nTo: b\r\n\r\nTest")},
|
||||
nil, nil, "mail.local",
|
||||
)
|
||||
|
||||
if _, err := worker.ProcessBatch(context.Background(), "w1", 10); err != nil {
|
||||
t.Fatalf("ProcessBatch: %v", err)
|
||||
}
|
||||
|
||||
got := repo.jobs[0]
|
||||
if got.Status != entity.OutboundJobStatusDeferred {
|
||||
t.Errorf("status = %s, want DEFERRED for a 4xx reply", got.Status)
|
||||
}
|
||||
if got.LastSmtpCode != "451" {
|
||||
t.Errorf("LastSmtpCode = %q, want %q", got.LastSmtpCode, "451")
|
||||
}
|
||||
}
|
||||
|
||||
// The DSN is delivered back to the original sender. When that sender address is
|
||||
// an alias fanning out to several mailboxes, the recipient records and their
|
||||
// envelope addresses must stay aligned - otherwise the delivery indexes past
|
||||
// the end of the address list and takes the worker down.
|
||||
func TestOutboundWorker_DsnToFanOutSenderDeliversToEveryTarget(t *testing.T) {
|
||||
addr := startFakeMX(t, "550 5.1.1 User unknown")
|
||||
|
||||
repo := &fakeOutboundRepo{}
|
||||
repo.Enqueue(context.Background(), newTestJob())
|
||||
|
||||
mailboxes := &fakeAliasFanOutRepository{targets: []port.MailboxRecord{
|
||||
{ID: uuid.New(), QuotaBytes: 1000},
|
||||
{ID: uuid.New(), QuotaBytes: 1000},
|
||||
}}
|
||||
messages := &recordingMessageRepository{}
|
||||
inbound := NewProcessInboundEmailUseCase(mailboxes, &stubBlobStorage{ref: port.BlobRef{ID: uuid.New()}}, messages)
|
||||
|
||||
worker := NewOutboundWorkerUseCase(
|
||||
repo,
|
||||
&fakeMXResolver{hosts: []string{addr}},
|
||||
&fakeBlobReader{payload: []byte("From: a\r\nTo: b\r\n\r\nTest")},
|
||||
inbound, mailboxes, "mail.local",
|
||||
)
|
||||
|
||||
if _, err := worker.ProcessBatch(context.Background(), "w1", 10); err != nil {
|
||||
t.Fatalf("ProcessBatch: %v", err)
|
||||
}
|
||||
|
||||
if len(messages.persisted) != 2 {
|
||||
t.Fatalf("DSN persisted to %d mailboxes, want 2 (one per fan-out target)", len(messages.persisted))
|
||||
}
|
||||
for i, p := range messages.persisted {
|
||||
if p.RecipientAddress != "user@domain.test" {
|
||||
t.Errorf("persisted[%d].RecipientAddress = %q, want the original sender", i, p.RecipientAddress)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"lambdamail/protocols/internal/domain/entity"
|
||||
)
|
||||
|
||||
// startPlaintextMX serves an SMTP conversation that never offers STARTTLS,
|
||||
// which is what a downgrade attack looks like from our side.
|
||||
func startPlaintextMX(t *testing.T) string {
|
||||
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() })
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func(c net.Conn) {
|
||||
defer c.Close()
|
||||
c.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
r := bufio.NewReader(c)
|
||||
c.Write([]byte("220 plain.mx 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"):
|
||||
// Deliberately no STARTTLS in the extension list.
|
||||
c.Write([]byte("250-plain.mx\r\n250 SIZE 10240000\r\n"))
|
||||
case cmd == "DATA":
|
||||
c.Write([]byte("354 End data with <CR><LF>.<CR><LF>\r\n"))
|
||||
for {
|
||||
body, err := r.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(body) == "." {
|
||||
break
|
||||
}
|
||||
}
|
||||
c.Write([]byte("250 2.0.0 Ok: queued\r\n"))
|
||||
case strings.HasPrefix(cmd, "QUIT"):
|
||||
c.Write([]byte("221 Bye\r\n"))
|
||||
return
|
||||
default:
|
||||
c.Write([]byte("250 Ok\r\n"))
|
||||
}
|
||||
}
|
||||
}(conn)
|
||||
}
|
||||
}()
|
||||
|
||||
return ln.Addr().String()
|
||||
}
|
||||
|
||||
type stubPolicyResolver struct {
|
||||
policy entity.TLSPolicy
|
||||
err error
|
||||
}
|
||||
|
||||
func (s stubPolicyResolver) Resolve(_ context.Context, _ string, _ string) (entity.TLSPolicy, error) {
|
||||
return s.policy, s.err
|
||||
}
|
||||
|
||||
func runWorkerWithPolicy(t *testing.T, mxAddr string, resolver stubPolicyResolver) *entity.OutboundJob {
|
||||
t.Helper()
|
||||
|
||||
repo := &fakeOutboundRepo{}
|
||||
repo.Enqueue(context.Background(), newTestJob())
|
||||
|
||||
worker := NewOutboundWorkerUseCase(
|
||||
repo,
|
||||
&fakeMXResolver{hosts: []string{mxAddr}},
|
||||
&fakeBlobReader{payload: []byte("From: a\r\nTo: b\r\n\r\nTest")},
|
||||
nil, nil, "mail.local",
|
||||
)
|
||||
worker.SetPolicyResolver(resolver)
|
||||
|
||||
if _, err := worker.ProcessBatch(context.Background(), "w1", 10); err != nil {
|
||||
t.Fatalf("ProcessBatch: %v", err)
|
||||
}
|
||||
return repo.jobs[0]
|
||||
}
|
||||
|
||||
// PLAN.md section 6.2: when the destination's policy demands validated TLS
|
||||
// and it cannot be had, the message is deferred - never delivered in the
|
||||
// clear. This is the anti-downgrade guarantee.
|
||||
func TestOutboundWorker_DefersRatherThanDowngradeUnderEnforcePolicy(t *testing.T) {
|
||||
addr := startPlaintextMX(t)
|
||||
|
||||
got := runWorkerWithPolicy(t, addr, stubPolicyResolver{
|
||||
policy: entity.NewMtaStsPolicy([]string{"*"}, true),
|
||||
})
|
||||
|
||||
if got.Status != entity.OutboundJobStatusDeferred {
|
||||
t.Errorf("status = %s, want DEFERRED", got.Status)
|
||||
}
|
||||
if got.LastSmtpCode != "451" {
|
||||
t.Errorf("LastSmtpCode = %q, want 451", got.LastSmtpCode)
|
||||
}
|
||||
if !strings.Contains(got.LastError, "4.7.5") {
|
||||
t.Errorf("LastError should carry the 4.7.5 status: %q", got.LastError)
|
||||
}
|
||||
if got.TlsPolicyUsed != "none" {
|
||||
t.Errorf("TlsPolicyUsed = %q, want none", got.TlsPolicyUsed)
|
||||
}
|
||||
}
|
||||
|
||||
// The same MX, with no policy published, is delivered to over plaintext:
|
||||
// refusing here would lose legitimate mail (RFC 7435).
|
||||
func TestOutboundWorker_DeliversOpportunisticallyWithoutPolicy(t *testing.T) {
|
||||
addr := startPlaintextMX(t)
|
||||
|
||||
got := runWorkerWithPolicy(t, addr, stubPolicyResolver{
|
||||
policy: entity.NewTLSPolicy(false, false),
|
||||
})
|
||||
|
||||
if got.Status != entity.OutboundJobStatusDelivered {
|
||||
t.Errorf("status = %s, want DELIVERED (last error: %s)", got.Status, got.LastError)
|
||||
}
|
||||
if got.TlsPolicyUsed != entity.TLSModeOpportunistic {
|
||||
t.Errorf("TlsPolicyUsed = %q, want opportunistic", got.TlsPolicyUsed)
|
||||
}
|
||||
}
|
||||
|
||||
// An MX outside the policy's mx set must not be used at all
|
||||
// (RFC 8461 section 4.1).
|
||||
func TestOutboundWorker_RefusesMxOutsidePolicy(t *testing.T) {
|
||||
addr := startPlaintextMX(t)
|
||||
|
||||
got := runWorkerWithPolicy(t, addr, stubPolicyResolver{
|
||||
policy: entity.NewMtaStsPolicy([]string{"only.allowed.test"}, true),
|
||||
})
|
||||
|
||||
if got.Status != entity.OutboundJobStatusDeferred {
|
||||
t.Errorf("status = %s, want DEFERRED", got.Status)
|
||||
}
|
||||
if !strings.Contains(got.LastError, "not listed in the MTA-STS policy") {
|
||||
t.Errorf("LastError = %q, want it to name the policy mismatch", got.LastError)
|
||||
}
|
||||
}
|
||||
|
||||
// A policy that exists but cannot be evaluated is a reason to wait, not to
|
||||
// downgrade.
|
||||
func TestOutboundWorker_DefersWhenPolicyCannotBeResolved(t *testing.T) {
|
||||
addr := startPlaintextMX(t)
|
||||
|
||||
got := runWorkerWithPolicy(t, addr, stubPolicyResolver{
|
||||
err: fmt.Errorf("TLSA records are not DNSSEC-validated"),
|
||||
})
|
||||
|
||||
if got.Status != entity.OutboundJobStatusDeferred {
|
||||
t.Errorf("status = %s, want DEFERRED", got.Status)
|
||||
}
|
||||
if !strings.Contains(got.LastError, "DNSSEC") {
|
||||
t.Errorf("LastError = %q, want the resolution failure to be recorded", got.LastError)
|
||||
}
|
||||
}
|
||||
|
||||
// A testing-mode policy reports but must not block delivery.
|
||||
func TestOutboundWorker_TestingModePolicyStillDelivers(t *testing.T) {
|
||||
addr := startPlaintextMX(t)
|
||||
|
||||
got := runWorkerWithPolicy(t, addr, stubPolicyResolver{
|
||||
policy: entity.NewMtaStsPolicy([]string{"only.allowed.test"}, false),
|
||||
})
|
||||
|
||||
if got.Status != entity.OutboundJobStatusDelivered {
|
||||
t.Errorf("status = %s, want DELIVERED under mode: testing (last error: %s)", got.Status, got.LastError)
|
||||
}
|
||||
if got.TlsPolicyUsed != entity.TLSModeMtaSts {
|
||||
t.Errorf("TlsPolicyUsed = %q, want mta-sts", got.TlsPolicyUsed)
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,13 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"net/textproto"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -15,13 +19,35 @@ import (
|
||||
"lambdamail/protocols/internal/domain/valueobject"
|
||||
)
|
||||
|
||||
// ErrPolicyDowngradeRefused is returned when a destination's published TLS
|
||||
// policy could not be satisfied. PLAN.md section 6.2 is explicit: the message
|
||||
// is deferred with 4.7.5, never delivered over an unvalidated channel.
|
||||
var ErrPolicyDowngradeRefused = errors.New("outbound: refusing to deliver without the TLS validation the destination requires")
|
||||
|
||||
type OutboundWorkerUseCase struct {
|
||||
outboundRepo port.OutboundRepository
|
||||
mxResolver port.MXResolver
|
||||
blobReader port.BlobReader
|
||||
inboundUC *ProcessInboundEmailUseCase
|
||||
mailboxes port.MailboxRepository
|
||||
localHost string
|
||||
outboundRepo port.OutboundRepository
|
||||
mxResolver port.MXResolver
|
||||
blobReader port.BlobReader
|
||||
inboundUC *ProcessInboundEmailUseCase
|
||||
mailboxes port.MailboxRepository
|
||||
policyResolver port.TLSPolicyResolver
|
||||
relay RelayConfig
|
||||
localHost string
|
||||
}
|
||||
|
||||
// SetRelay routes delivery through a smarthost (PLAN.md section 10.4). This is
|
||||
// the fallback for hosts whose provider blocks outbound port 25, and it
|
||||
// replaces MX resolution entirely: every message goes to the relay.
|
||||
func (w *OutboundWorkerUseCase) SetRelay(relay RelayConfig) {
|
||||
w.relay = relay
|
||||
}
|
||||
|
||||
// SetPolicyResolver enables DANE and MTA-STS on the delivery path. Without
|
||||
// one the worker falls back to opportunistic TLS, which is correct for a
|
||||
// destination that publishes no policy but must never be the choice for one
|
||||
// that does.
|
||||
func (w *OutboundWorkerUseCase) SetPolicyResolver(resolver port.TLSPolicyResolver) {
|
||||
w.policyResolver = resolver
|
||||
}
|
||||
|
||||
func NewOutboundWorkerUseCase(
|
||||
@@ -72,6 +98,21 @@ func (w *OutboundWorkerUseCase) processSingleJob(ctx context.Context, job *entit
|
||||
return
|
||||
}
|
||||
|
||||
// With a smarthost every message goes to the relay regardless of
|
||||
// destination, and the relay owns transport security from there on.
|
||||
if w.relay.Configured() {
|
||||
if err := w.deliverViaRelay(job.EnvelopeFrom, job.EnvelopeTo, payload); err != nil {
|
||||
w.finishFailed(ctx, job, err)
|
||||
return
|
||||
}
|
||||
job.Status = entity.OutboundJobStatusDelivered
|
||||
job.LastError = ""
|
||||
job.LastSmtpCode = ""
|
||||
job.TlsPolicyUsed = entity.TLSModeRelay
|
||||
_ = w.outboundRepo.UpdateJob(ctx, job)
|
||||
return
|
||||
}
|
||||
|
||||
mxHosts, err := w.mxResolver.LookupMX(ctx, job.DestinationDomain)
|
||||
if err != nil || len(mxHosts) == 0 {
|
||||
mxHosts = []string{job.DestinationDomain}
|
||||
@@ -79,10 +120,28 @@ func (w *OutboundWorkerUseCase) processSingleJob(ctx context.Context, job *entit
|
||||
|
||||
var lastErr error
|
||||
delivered := false
|
||||
policyUsed := entity.TLSModeOpportunistic
|
||||
|
||||
for _, mxHost := range mxHosts {
|
||||
if err := w.deliverToMX(ctx, mxHost, job.EnvelopeFrom, job.EnvelopeTo, payload); err == nil {
|
||||
policy, err := w.resolvePolicy(ctx, job.DestinationDomain, mxHost)
|
||||
if err != nil {
|
||||
// A published policy we cannot evaluate is a reason to wait, not
|
||||
// a reason to send in the clear.
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
// RFC 8461 section 4.1: a host outside the policy's mx set must not
|
||||
// be used while that policy is in force.
|
||||
if policy.RequiresValidation() && !policy.CoversHost(mxHost) {
|
||||
lastErr = fmt.Errorf("%w: %s is not listed in the MTA-STS policy of %s",
|
||||
ErrPolicyDowngradeRefused, mxHost, job.DestinationDomain)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := w.deliverToMX(ctx, mxHost, job.EnvelopeFrom, job.EnvelopeTo, payload, policy); err == nil {
|
||||
delivered = true
|
||||
policyUsed = policy.Effective()
|
||||
break
|
||||
} else {
|
||||
lastErr = err
|
||||
@@ -92,18 +151,52 @@ func (w *OutboundWorkerUseCase) processSingleJob(ctx context.Context, job *entit
|
||||
if delivered {
|
||||
job.Status = entity.OutboundJobStatusDelivered
|
||||
job.LastError = ""
|
||||
job.TlsPolicyUsed = "opportunistic"
|
||||
job.LastSmtpCode = ""
|
||||
job.TlsPolicyUsed = policyUsed
|
||||
_ = w.outboundRepo.UpdateJob(ctx, job)
|
||||
return
|
||||
}
|
||||
|
||||
w.finishFailed(ctx, job, lastErr)
|
||||
}
|
||||
|
||||
// finishFailed classifies a delivery failure and records the outcome.
|
||||
func (w *OutboundWorkerUseCase) finishFailed(ctx context.Context, job *entity.OutboundJob, lastErr error) {
|
||||
errStr := "delivery failed"
|
||||
if lastErr != nil {
|
||||
errStr = lastErr.Error()
|
||||
}
|
||||
|
||||
isPermanent := strings.HasPrefix(errStr, "5")
|
||||
w.handleFailure(ctx, job, errStr, isPermanent)
|
||||
// The remote reply code decides the outcome (PLAN.md section 6.3): 5xx is
|
||||
// a permanent failure that bounces now, anything else - including a dial
|
||||
// or TLS error, which carries no reply code at all - is retried.
|
||||
code := smtpReplyCode(lastErr)
|
||||
if code > 0 {
|
||||
job.LastSmtpCode = strconv.Itoa(code)
|
||||
}
|
||||
|
||||
// A refused downgrade is always temporary: the destination may fix its
|
||||
// certificate, and bouncing would hand the attacker the outcome they
|
||||
// wanted (PLAN.md section 6.2).
|
||||
if errors.Is(lastErr, ErrPolicyDowngradeRefused) {
|
||||
job.LastSmtpCode = "451"
|
||||
job.TlsPolicyUsed = "none"
|
||||
w.handleFailure(ctx, job, "4.7.5 "+errStr, false)
|
||||
return
|
||||
}
|
||||
|
||||
w.handleFailure(ctx, job, errStr, code >= 500 && code < 600)
|
||||
}
|
||||
|
||||
// smtpReplyCode extracts the reply code from an error returned by net/smtp,
|
||||
// which wraps remote replies in *textproto.Error. It returns 0 when the
|
||||
// failure happened before any reply was read.
|
||||
func smtpReplyCode(err error) int {
|
||||
var protoErr *textproto.Error
|
||||
if errors.As(err, &protoErr) {
|
||||
return protoErr.Code
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (w *OutboundWorkerUseCase) handleFailure(ctx context.Context, job *entity.OutboundJob, errStr string, isPermanent bool) {
|
||||
@@ -141,20 +234,87 @@ func (w *OutboundWorkerUseCase) sendDsn(ctx context.Context, action valueobject.
|
||||
return
|
||||
}
|
||||
|
||||
if w.mailboxes != nil {
|
||||
targets, err := w.mailboxes.ResolveDeliveryTargets(ctx, job.EnvelopeFrom)
|
||||
if err == nil && len(targets) > 0 {
|
||||
_ = w.inboundUC.Handle(ctx, ProcessInboundEmailInput{
|
||||
Sender: "postmaster@lambdamail.local",
|
||||
Recipients: targets,
|
||||
RecipientAddresses: []string{job.EnvelopeFrom},
|
||||
Body: bytes.NewReader(dsnBytes),
|
||||
})
|
||||
}
|
||||
if w.mailboxes == nil {
|
||||
return
|
||||
}
|
||||
|
||||
targets, err := w.mailboxes.ResolveDeliveryTargets(ctx, job.EnvelopeFrom)
|
||||
if err != nil || len(targets) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Handle indexes the two slices in lockstep, so the envelope address is
|
||||
// repeated once per resolved mailbox. An alias sender fans out to several
|
||||
// mailboxes under the same address.
|
||||
addresses := make([]string, len(targets))
|
||||
for i := range targets {
|
||||
addresses[i] = job.EnvelopeFrom
|
||||
}
|
||||
|
||||
_ = w.inboundUC.Handle(ctx, ProcessInboundEmailInput{
|
||||
Sender: "postmaster@lambdamail.local",
|
||||
Recipients: targets,
|
||||
RecipientAddresses: addresses,
|
||||
Body: bytes.NewReader(dsnBytes),
|
||||
})
|
||||
}
|
||||
|
||||
func (w *OutboundWorkerUseCase) deliverToMX(ctx context.Context, mxHost string, from string, to string, payload []byte) error {
|
||||
// resolvePolicy asks the resolver what transport security the destination
|
||||
// requires. With no resolver configured every destination is opportunistic.
|
||||
func (w *OutboundWorkerUseCase) resolvePolicy(ctx context.Context, destinationDomain, mxHost string) (entity.TLSPolicy, error) {
|
||||
if w.policyResolver == nil {
|
||||
return entity.NewTLSPolicy(false, false), nil
|
||||
}
|
||||
return w.policyResolver.Resolve(ctx, destinationDomain, mxHost)
|
||||
}
|
||||
|
||||
// tlsConfigFor turns a policy into a TLS configuration.
|
||||
//
|
||||
// Opportunistic delivery deliberately skips verification: RFC 7435 prefers an
|
||||
// unauthenticated encrypted channel to a cleartext one, and the alternative -
|
||||
// refusing every destination with a self-signed certificate - would lose
|
||||
// legitimate mail. When a policy is in force the verification is real, and a
|
||||
// failure surfaces as a refusal to deliver.
|
||||
func tlsConfigFor(mxHost string, policy entity.TLSPolicy) *tls.Config {
|
||||
config := &tls.Config{
|
||||
ServerName: strings.TrimSuffix(mxHost, "."),
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
|
||||
if !policy.RequiresValidation() {
|
||||
config.InsecureSkipVerify = true
|
||||
return config
|
||||
}
|
||||
|
||||
if policy.Effective() == entity.TLSModeDane {
|
||||
// DANE authenticates the certificate directly against the
|
||||
// DNSSEC-signed association, so the PKIX chain and the name are not
|
||||
// what is being trusted (RFC 7672 section 3.1). Verification is done
|
||||
// by hand below instead of by the standard chain builder.
|
||||
config.InsecureSkipVerify = true
|
||||
config.VerifyPeerCertificate = func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
|
||||
chain := make([]*x509.Certificate, 0, len(rawCerts))
|
||||
for _, raw := range rawCerts {
|
||||
cert, err := x509.ParseCertificate(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse peer certificate: %w", err)
|
||||
}
|
||||
chain = append(chain, cert)
|
||||
}
|
||||
if !policy.MatchesCertificate(chain) {
|
||||
return fmt.Errorf("%w: certificate of %s matches no TLSA record", ErrPolicyDowngradeRefused, mxHost)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
// MTA-STS in enforce mode requires a normal, publicly trusted PKIX
|
||||
// validation against the MX name (RFC 8461 section 4.1).
|
||||
return config
|
||||
}
|
||||
|
||||
func (w *OutboundWorkerUseCase) deliverToMX(ctx context.Context, mxHost string, from string, to string, payload []byte, policy entity.TLSPolicy) error {
|
||||
addr := mxHost
|
||||
if _, _, err := net.SplitHostPort(mxHost); err != nil {
|
||||
addr = net.JoinHostPort(mxHost, "25")
|
||||
@@ -177,11 +337,20 @@ func (w *OutboundWorkerUseCase) deliverToMX(ctx context.Context, mxHost string,
|
||||
return fmt.Errorf("EHLO failed: %w", err)
|
||||
}
|
||||
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
config := &tls.Config{InsecureSkipVerify: true, ServerName: mxHost}
|
||||
if err := client.StartTLS(config); err != nil {
|
||||
starttlsOffered, _ := client.Extension("STARTTLS")
|
||||
if starttlsOffered {
|
||||
if err := client.StartTLS(tlsConfigFor(mxHost, policy)); err != nil {
|
||||
if policy.RequiresValidation() {
|
||||
return fmt.Errorf("%w: STARTTLS to %s failed validation: %v", ErrPolicyDowngradeRefused, mxHost, err)
|
||||
}
|
||||
return fmt.Errorf("STARTTLS failed: %w", err)
|
||||
}
|
||||
} else if policy.RequiresValidation() {
|
||||
// The destination's own published policy says it speaks TLS. A peer
|
||||
// that does not offer STARTTLS is either broken or being stripped,
|
||||
// and handing it the message either way is exactly the downgrade the
|
||||
// policy exists to prevent.
|
||||
return fmt.Errorf("%w: %s did not offer STARTTLS", ErrPolicyDowngradeRefused, mxHost)
|
||||
}
|
||||
|
||||
if err := client.Mail(from); err != nil {
|
||||
|
||||
@@ -30,6 +30,10 @@ func (f *fakeOutboundRepo) FetchNextReady(_ context.Context, _ string, _ int) ([
|
||||
return ready, nil
|
||||
}
|
||||
|
||||
func (f *fakeOutboundRepo) CountRecipientsSince(_ context.Context, _ uuid.UUID, _ time.Time) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (f *fakeOutboundRepo) UpdateJob(_ context.Context, job *entity.OutboundJob) error {
|
||||
for i, j := range f.jobs {
|
||||
if j.ID == job.ID {
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Severity mirrors the table in PLAN.md section 15.
|
||||
type Severity string
|
||||
|
||||
const (
|
||||
SeverityCritical Severity = "CRITICAL"
|
||||
SeverityHigh Severity = "HIGH"
|
||||
SeverityMedium Severity = "MEDIUM"
|
||||
)
|
||||
|
||||
// CheckStatus is the outcome of one preflight check.
|
||||
type CheckStatus string
|
||||
|
||||
const (
|
||||
CheckPassed CheckStatus = "PASS"
|
||||
CheckFailed CheckStatus = "FAIL"
|
||||
CheckSkipped CheckStatus = "SKIP"
|
||||
)
|
||||
|
||||
// CheckResult is one line of the preflight report.
|
||||
type CheckResult struct {
|
||||
Name string
|
||||
Status CheckStatus
|
||||
Severity Severity
|
||||
Detail string
|
||||
// Remedy is the operator action that fixes it. Several of these checks
|
||||
// cover steps PLAN.md section 7.4 lists as not automatable, so telling
|
||||
// the operator exactly what to do is the point of the check.
|
||||
Remedy string
|
||||
}
|
||||
|
||||
// PreflightReport aggregates the checks.
|
||||
type PreflightReport struct {
|
||||
Results []CheckResult
|
||||
}
|
||||
|
||||
// Blocking reports whether any critical check failed. PLAN.md section 15 uses
|
||||
// this to keep a domain out of the VERIFIED state.
|
||||
func (r PreflightReport) Blocking() bool {
|
||||
for _, result := range r.Results {
|
||||
if result.Status == CheckFailed && result.Severity == SeverityCritical {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (r PreflightReport) String() string {
|
||||
var sb strings.Builder
|
||||
for _, result := range r.Results {
|
||||
fmt.Fprintf(&sb, "[%-4s] %-10s %s", result.Status, result.Severity, result.Name)
|
||||
if result.Detail != "" {
|
||||
fmt.Fprintf(&sb, "\n %s", result.Detail)
|
||||
}
|
||||
if result.Status == CheckFailed && result.Remedy != "" {
|
||||
fmt.Fprintf(&sb, "\n remedy: %s", result.Remedy)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// PreflightDeps are the environment probes the checks need. They are injected
|
||||
// so the whole preflight is testable without touching the network.
|
||||
type PreflightDeps struct {
|
||||
// DialPort25 reports whether outbound port 25 reaches a known MX.
|
||||
DialPort25 func(ctx context.Context, address string) error
|
||||
// LookupAddr resolves the PTR of an IP.
|
||||
LookupAddr func(ctx context.Context, ip string) ([]string, error)
|
||||
// LookupHost resolves a name to addresses.
|
||||
LookupHost func(ctx context.Context, host string) ([]string, error)
|
||||
// LookupTXT resolves TXT records, used for the DNSBL checks.
|
||||
LookupTXT func(ctx context.Context, name string) ([]string, error)
|
||||
// HasCertificateFor reports whether a certificate is loaded for a host.
|
||||
HasCertificateFor func(host string) bool
|
||||
// FetchMtaSts fetches the served MTA-STS policy over HTTPS.
|
||||
FetchMtaSts func(ctx context.Context, domain string) error
|
||||
// FreeDiskPercent reports the free space on the mail spool.
|
||||
FreeDiskPercent func() (float64, error)
|
||||
// Now is the local clock; drift against NTP breaks DKIM and TOTP.
|
||||
Now func() time.Time
|
||||
// NetworkTime returns a trusted time to compare the local clock against.
|
||||
NetworkTime func(ctx context.Context) (time.Time, error)
|
||||
}
|
||||
|
||||
// PreflightInput describes the deployment being checked.
|
||||
type PreflightInput struct {
|
||||
PrimaryMailHost string
|
||||
MailDomain string
|
||||
PublicIPv4 string
|
||||
PublicIPv6 string
|
||||
DaneEnabled bool
|
||||
TLSMode string
|
||||
SmarthostSet bool
|
||||
}
|
||||
|
||||
// PreflightUseCase implements PLAN.md section 15.
|
||||
type PreflightUseCase struct {
|
||||
deps PreflightDeps
|
||||
}
|
||||
|
||||
func NewPreflightUseCase(deps PreflightDeps) *PreflightUseCase {
|
||||
return &PreflightUseCase{deps: deps}
|
||||
}
|
||||
|
||||
// wellKnownMX is the probe target of PLAN.md section 15. Google is used
|
||||
// because it is the destination most likely to matter and least likely to be
|
||||
// down.
|
||||
const wellKnownMX = "gmail-smtp-in.l.google.com:25"
|
||||
|
||||
// dnsblZones are the reputation lists PLAN.md section 5.2 names.
|
||||
var dnsblZones = map[string]string{
|
||||
"zen.spamhaus.org": "Spamhaus ZEN",
|
||||
"b.barracudacentral.org": "Barracuda",
|
||||
"dnsbl.sorbs.net": "SORBS",
|
||||
}
|
||||
|
||||
func (uc *PreflightUseCase) Execute(ctx context.Context, input PreflightInput) PreflightReport {
|
||||
report := PreflightReport{}
|
||||
add := func(r CheckResult) { report.Results = append(report.Results, r) }
|
||||
|
||||
add(uc.checkPort25(ctx, input))
|
||||
add(uc.checkReverseDNS(ctx, input))
|
||||
add(uc.checkForwardConfirmedReverseDNS(ctx, input))
|
||||
add(uc.checkBlocklists(ctx, input))
|
||||
add(uc.checkCertificate(input))
|
||||
add(uc.checkDaneRequiresAcmeMode(input))
|
||||
add(uc.checkMtaStsEndpoint(ctx, input))
|
||||
add(uc.checkClockDrift(ctx))
|
||||
add(uc.checkDiskSpace())
|
||||
add(uc.checkIPv6Coherence(ctx, input))
|
||||
|
||||
return report
|
||||
}
|
||||
|
||||
func (uc *PreflightUseCase) checkPort25(ctx context.Context, input PreflightInput) CheckResult {
|
||||
const name = "Outbound port 25 reaches a public MX"
|
||||
|
||||
if input.SmarthostSet {
|
||||
return CheckResult{
|
||||
Name: name, Status: CheckSkipped, Severity: SeverityCritical,
|
||||
Detail: "a smarthost is configured, so direct delivery on port 25 is not used",
|
||||
}
|
||||
}
|
||||
if uc.deps.DialPort25 == nil {
|
||||
return skipped(name, SeverityCritical)
|
||||
}
|
||||
|
||||
if err := uc.deps.DialPort25(ctx, wellKnownMX); err != nil {
|
||||
return CheckResult{
|
||||
Name: name, Status: CheckFailed, Severity: SeverityCritical,
|
||||
Detail: fmt.Sprintf("could not reach %s: %v", wellKnownMX, err),
|
||||
Remedy: "most VPS providers block egress on port 25 by default; ask the provider to unblock it, or configure RELAY_HOST to deliver through a smarthost",
|
||||
}
|
||||
}
|
||||
return passed(name, SeverityCritical, "")
|
||||
}
|
||||
|
||||
func (uc *PreflightUseCase) checkReverseDNS(ctx context.Context, input PreflightInput) CheckResult {
|
||||
const name = "PTR of the public IP resolves to the mail host"
|
||||
|
||||
if uc.deps.LookupAddr == nil || input.PublicIPv4 == "" {
|
||||
return skipped(name, SeverityCritical)
|
||||
}
|
||||
|
||||
names, err := uc.deps.LookupAddr(ctx, input.PublicIPv4)
|
||||
if err != nil || len(names) == 0 {
|
||||
return CheckResult{
|
||||
Name: name, Status: CheckFailed, Severity: SeverityCritical,
|
||||
Detail: fmt.Sprintf("no PTR record for %s", input.PublicIPv4),
|
||||
Remedy: "set the PTR in the hosting provider's panel; reverse delegation belongs to the owner of the IP block, not to the DNS provider",
|
||||
}
|
||||
}
|
||||
|
||||
for _, candidate := range names {
|
||||
if strings.EqualFold(strings.TrimSuffix(candidate, "."), input.PrimaryMailHost) {
|
||||
return passed(name, SeverityCritical, "")
|
||||
}
|
||||
}
|
||||
|
||||
return CheckResult{
|
||||
Name: name, Status: CheckFailed, Severity: SeverityCritical,
|
||||
Detail: fmt.Sprintf("PTR of %s is %v, want %s", input.PublicIPv4, names, input.PrimaryMailHost),
|
||||
Remedy: "point the PTR at " + input.PrimaryMailHost + " in the hosting provider's panel",
|
||||
}
|
||||
}
|
||||
|
||||
func (uc *PreflightUseCase) checkForwardConfirmedReverseDNS(ctx context.Context, input PreflightInput) CheckResult {
|
||||
const name = "FCrDNS: the mail host resolves back to the public IP"
|
||||
|
||||
if uc.deps.LookupHost == nil || input.PublicIPv4 == "" {
|
||||
return skipped(name, SeverityCritical)
|
||||
}
|
||||
|
||||
addresses, err := uc.deps.LookupHost(ctx, input.PrimaryMailHost)
|
||||
if err != nil {
|
||||
return CheckResult{
|
||||
Name: name, Status: CheckFailed, Severity: SeverityCritical,
|
||||
Detail: fmt.Sprintf("cannot resolve %s: %v", input.PrimaryMailHost, err),
|
||||
Remedy: "publish the A record for " + input.PrimaryMailHost,
|
||||
}
|
||||
}
|
||||
|
||||
for _, address := range addresses {
|
||||
if address == input.PublicIPv4 {
|
||||
return passed(name, SeverityCritical, "")
|
||||
}
|
||||
}
|
||||
|
||||
return CheckResult{
|
||||
Name: name, Status: CheckFailed, Severity: SeverityCritical,
|
||||
Detail: fmt.Sprintf("%s resolves to %v, not to %s", input.PrimaryMailHost, addresses, input.PublicIPv4),
|
||||
Remedy: "make the A record and the PTR agree; Gmail and Outlook reject or spam-file mail without forward-confirmed reverse DNS",
|
||||
}
|
||||
}
|
||||
|
||||
func (uc *PreflightUseCase) checkBlocklists(ctx context.Context, input PreflightInput) CheckResult {
|
||||
const name = "Public IP is not on a major blocklist"
|
||||
|
||||
if uc.deps.LookupTXT == nil || input.PublicIPv4 == "" {
|
||||
return skipped(name, SeverityCritical)
|
||||
}
|
||||
|
||||
reversed := reverseIPv4(input.PublicIPv4)
|
||||
if reversed == "" {
|
||||
return skipped(name, SeverityCritical)
|
||||
}
|
||||
|
||||
var listed []string
|
||||
// Sorted so the report is stable between runs.
|
||||
zones := make([]string, 0, len(dnsblZones))
|
||||
for zone := range dnsblZones {
|
||||
zones = append(zones, zone)
|
||||
}
|
||||
sort.Strings(zones)
|
||||
|
||||
for _, zone := range zones {
|
||||
if records, err := uc.deps.LookupTXT(ctx, reversed+"."+zone); err == nil && len(records) > 0 {
|
||||
listed = append(listed, dnsblZones[zone])
|
||||
}
|
||||
}
|
||||
|
||||
if len(listed) > 0 {
|
||||
return CheckResult{
|
||||
Name: name, Status: CheckFailed, Severity: SeverityCritical,
|
||||
Detail: fmt.Sprintf("%s is listed on: %s", input.PublicIPv4, strings.Join(listed, ", ")),
|
||||
Remedy: "request delisting with each provider before sending, or move to an IP with clean reputation",
|
||||
}
|
||||
}
|
||||
return passed(name, SeverityCritical, "")
|
||||
}
|
||||
|
||||
func (uc *PreflightUseCase) checkCertificate(input PreflightInput) CheckResult {
|
||||
const name = "A TLS certificate is loaded for the mail host"
|
||||
|
||||
if uc.deps.HasCertificateFor == nil {
|
||||
return skipped(name, SeverityCritical)
|
||||
}
|
||||
if uc.deps.HasCertificateFor(input.PrimaryMailHost) {
|
||||
return passed(name, SeverityCritical, "")
|
||||
}
|
||||
|
||||
return CheckResult{
|
||||
Name: name, Status: CheckFailed, Severity: SeverityCritical,
|
||||
Detail: fmt.Sprintf("no certificate for %s", input.PrimaryMailHost),
|
||||
Remedy: "Traefik only requests certificates for hosts it routes; declare an HTTP router with Host(`" + input.PrimaryMailHost + "`) so ACME issues one",
|
||||
}
|
||||
}
|
||||
|
||||
func (uc *PreflightUseCase) checkDaneRequiresAcmeMode(input PreflightInput) CheckResult {
|
||||
const name = "DANE is only enabled in self-managed ACME mode"
|
||||
|
||||
if !input.DaneEnabled {
|
||||
return CheckResult{
|
||||
Name: name, Status: CheckSkipped, Severity: SeverityCritical,
|
||||
Detail: "DANE is disabled",
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(input.TLSMode, "acme") {
|
||||
return passed(name, SeverityCritical, "")
|
||||
}
|
||||
|
||||
return CheckResult{
|
||||
Name: name, Status: CheckFailed, Severity: SeverityCritical,
|
||||
Detail: "DANE is enabled while certificates come from Traefik",
|
||||
Remedy: "Traefik cannot sign a CSR with a pre-generated key, so the TLSA record would stop matching at the next renewal and every validating MTA would reject mail; set TLS_MODE=acme or disable DANE",
|
||||
}
|
||||
}
|
||||
|
||||
func (uc *PreflightUseCase) checkMtaStsEndpoint(ctx context.Context, input PreflightInput) CheckResult {
|
||||
const name = "MTA-STS policy is served over HTTPS with a valid certificate"
|
||||
|
||||
if uc.deps.FetchMtaSts == nil || input.MailDomain == "" {
|
||||
return skipped(name, SeverityHigh)
|
||||
}
|
||||
if err := uc.deps.FetchMtaSts(ctx, input.MailDomain); err != nil {
|
||||
return CheckResult{
|
||||
Name: name, Status: CheckFailed, Severity: SeverityHigh,
|
||||
Detail: fmt.Sprintf("cannot fetch the policy for %s: %v", input.MailDomain, err),
|
||||
Remedy: "publish the mta-sts host and route it through the proxy with a valid certificate; senders that cannot fetch the policy fall back to unprotected delivery",
|
||||
}
|
||||
}
|
||||
return passed(name, SeverityHigh, "")
|
||||
}
|
||||
|
||||
func (uc *PreflightUseCase) checkClockDrift(ctx context.Context) CheckResult {
|
||||
const name = "System clock is synchronised"
|
||||
const maxDrift = 5 * time.Second
|
||||
|
||||
if uc.deps.NetworkTime == nil || uc.deps.Now == nil {
|
||||
return skipped(name, SeverityHigh)
|
||||
}
|
||||
|
||||
networkTime, err := uc.deps.NetworkTime(ctx)
|
||||
if err != nil {
|
||||
return skipped(name, SeverityHigh)
|
||||
}
|
||||
|
||||
drift := uc.deps.Now().Sub(networkTime)
|
||||
if drift < 0 {
|
||||
drift = -drift
|
||||
}
|
||||
if drift > maxDrift {
|
||||
return CheckResult{
|
||||
Name: name, Status: CheckFailed, Severity: SeverityHigh,
|
||||
Detail: fmt.Sprintf("local clock is off by %v", drift.Round(time.Millisecond)),
|
||||
Remedy: "enable NTP on the host; DKIM signature timestamps and TOTP codes both depend on an accurate clock",
|
||||
}
|
||||
}
|
||||
return passed(name, SeverityHigh, fmt.Sprintf("drift %v", drift.Round(time.Millisecond)))
|
||||
}
|
||||
|
||||
func (uc *PreflightUseCase) checkDiskSpace() CheckResult {
|
||||
const name = "Mail spool has more than 20% free space"
|
||||
|
||||
if uc.deps.FreeDiskPercent == nil {
|
||||
return skipped(name, SeverityMedium)
|
||||
}
|
||||
|
||||
free, err := uc.deps.FreeDiskPercent()
|
||||
if err != nil {
|
||||
return skipped(name, SeverityMedium)
|
||||
}
|
||||
if free < 20 {
|
||||
return CheckResult{
|
||||
Name: name, Status: CheckFailed, Severity: SeverityMedium,
|
||||
Detail: fmt.Sprintf("%.1f%% free", free),
|
||||
Remedy: "free space or grow the volume; a full spool means accepted mail cannot be written and has to be refused",
|
||||
}
|
||||
}
|
||||
return passed(name, SeverityMedium, fmt.Sprintf("%.1f%% free", free))
|
||||
}
|
||||
|
||||
func (uc *PreflightUseCase) checkIPv6Coherence(ctx context.Context, input PreflightInput) CheckResult {
|
||||
const name = "IPv6 is either fully configured or disabled"
|
||||
|
||||
if input.PublicIPv6 == "" {
|
||||
return passed(name, SeverityMedium, "IPv6 is disabled")
|
||||
}
|
||||
if uc.deps.LookupAddr == nil {
|
||||
return skipped(name, SeverityMedium)
|
||||
}
|
||||
|
||||
names, err := uc.deps.LookupAddr(ctx, input.PublicIPv6)
|
||||
if err != nil || len(names) == 0 {
|
||||
return CheckResult{
|
||||
Name: name, Status: CheckFailed, Severity: SeverityMedium,
|
||||
Detail: fmt.Sprintf("no PTR for %s", input.PublicIPv6),
|
||||
Remedy: "publish a PTR for the IPv6 address or unset PUBLIC_IPV6; half-configured IPv6 is a common cause of rejection",
|
||||
}
|
||||
}
|
||||
return passed(name, SeverityMedium, "")
|
||||
}
|
||||
|
||||
func passed(name string, severity Severity, detail string) CheckResult {
|
||||
return CheckResult{Name: name, Status: CheckPassed, Severity: severity, Detail: detail}
|
||||
}
|
||||
|
||||
func skipped(name string, severity Severity) CheckResult {
|
||||
return CheckResult{
|
||||
Name: name, Status: CheckSkipped, Severity: severity,
|
||||
Detail: "not enough configuration to run this check",
|
||||
}
|
||||
}
|
||||
|
||||
// reverseIPv4 turns "192.0.2.10" into "10.2.0.192", the form DNSBL queries
|
||||
// take. It returns an empty string for anything that is not an IPv4 address.
|
||||
func reverseIPv4(address string) string {
|
||||
ip := net.ParseIP(address).To4()
|
||||
if ip == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d.%d.%d.%d", ip[3], ip[2], ip[1], ip[0])
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// healthyDeps is a deployment where every probe succeeds; individual tests
|
||||
// break exactly one thing so the failure being asserted is unambiguous.
|
||||
func healthyDeps() PreflightDeps {
|
||||
now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
|
||||
return PreflightDeps{
|
||||
DialPort25: func(context.Context, string) error { return nil },
|
||||
LookupAddr: func(_ context.Context, _ string) ([]string, error) { return []string{"mail.example.test."}, nil },
|
||||
LookupHost: func(_ context.Context, _ string) ([]string, error) { return []string{"192.0.2.10"}, nil },
|
||||
LookupTXT: func(_ context.Context, _ string) ([]string, error) { return nil, errors.New("NXDOMAIN") },
|
||||
HasCertificateFor: func(string) bool { return true },
|
||||
FetchMtaSts: func(context.Context, string) error { return nil },
|
||||
FreeDiskPercent: func() (float64, error) { return 65, nil },
|
||||
Now: func() time.Time { return now },
|
||||
NetworkTime: func(context.Context) (time.Time, error) { return now, nil },
|
||||
}
|
||||
}
|
||||
|
||||
func healthyInput() PreflightInput {
|
||||
return PreflightInput{
|
||||
PrimaryMailHost: "mail.example.test",
|
||||
MailDomain: "example.test",
|
||||
PublicIPv4: "192.0.2.10",
|
||||
TLSMode: "traefik",
|
||||
}
|
||||
}
|
||||
|
||||
func findCheck(t *testing.T, report PreflightReport, substring string) CheckResult {
|
||||
t.Helper()
|
||||
for _, result := range report.Results {
|
||||
if strings.Contains(result.Name, substring) {
|
||||
return result
|
||||
}
|
||||
}
|
||||
t.Fatalf("no check matching %q in report:\n%s", substring, report)
|
||||
return CheckResult{}
|
||||
}
|
||||
|
||||
func TestPreflight_HealthyDeploymentDoesNotBlock(t *testing.T) {
|
||||
report := NewPreflightUseCase(healthyDeps()).Execute(context.Background(), healthyInput())
|
||||
|
||||
if report.Blocking() {
|
||||
t.Errorf("a healthy deployment was reported as blocking:\n%s", report)
|
||||
}
|
||||
}
|
||||
|
||||
// PLAN.md premise 1: without outbound port 25 there is no direct delivery.
|
||||
func TestPreflight_BlockedPort25IsCriticalAndSuggestsSmarthost(t *testing.T) {
|
||||
deps := healthyDeps()
|
||||
deps.DialPort25 = func(context.Context, string) error { return errors.New("connection timed out") }
|
||||
|
||||
report := NewPreflightUseCase(deps).Execute(context.Background(), healthyInput())
|
||||
check := findCheck(t, report, "port 25")
|
||||
|
||||
if check.Status != CheckFailed || check.Severity != SeverityCritical {
|
||||
t.Errorf("check = %+v, want a critical failure", check)
|
||||
}
|
||||
if !strings.Contains(check.Remedy, "smarthost") {
|
||||
t.Errorf("remedy should point at the smarthost fallback: %q", check.Remedy)
|
||||
}
|
||||
if !report.Blocking() {
|
||||
t.Error("a blocked port 25 must block")
|
||||
}
|
||||
}
|
||||
|
||||
// With a smarthost configured, direct port 25 delivery is not used, so the
|
||||
// check must not block the deployment.
|
||||
func TestPreflight_SmarthostSkipsPort25Check(t *testing.T) {
|
||||
deps := healthyDeps()
|
||||
deps.DialPort25 = func(context.Context, string) error { return errors.New("blocked") }
|
||||
|
||||
input := healthyInput()
|
||||
input.SmarthostSet = true
|
||||
|
||||
report := NewPreflightUseCase(deps).Execute(context.Background(), input)
|
||||
|
||||
if findCheck(t, report, "port 25").Status != CheckSkipped {
|
||||
t.Error("the port 25 check should be skipped when a smarthost is configured")
|
||||
}
|
||||
if report.Blocking() {
|
||||
t.Errorf("a smarthost deployment was blocked:\n%s", report)
|
||||
}
|
||||
}
|
||||
|
||||
// PLAN.md premise 2: PTR delegation belongs to the hosting provider, so the
|
||||
// remedy has to say so.
|
||||
func TestPreflight_MissingPtrIsCriticalAndNamesTheProvider(t *testing.T) {
|
||||
deps := healthyDeps()
|
||||
deps.LookupAddr = func(context.Context, string) ([]string, error) { return nil, errors.New("NXDOMAIN") }
|
||||
|
||||
report := NewPreflightUseCase(deps).Execute(context.Background(), healthyInput())
|
||||
check := findCheck(t, report, "PTR")
|
||||
|
||||
if check.Status != CheckFailed || check.Severity != SeverityCritical {
|
||||
t.Errorf("check = %+v, want a critical failure", check)
|
||||
}
|
||||
if !strings.Contains(check.Remedy, "hosting provider") {
|
||||
t.Errorf("remedy should name the hosting provider: %q", check.Remedy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflight_ForwardConfirmedReverseDnsMismatchIsCritical(t *testing.T) {
|
||||
deps := healthyDeps()
|
||||
deps.LookupHost = func(context.Context, string) ([]string, error) { return []string{"198.51.100.1"}, nil }
|
||||
|
||||
report := NewPreflightUseCase(deps).Execute(context.Background(), healthyInput())
|
||||
|
||||
if check := findCheck(t, report, "FCrDNS"); check.Status != CheckFailed {
|
||||
t.Errorf("check = %+v, want a failure when the A record and the PTR disagree", check)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflight_ListedIpIsCriticalAndNamesTheList(t *testing.T) {
|
||||
deps := healthyDeps()
|
||||
deps.LookupTXT = func(_ context.Context, name string) ([]string, error) {
|
||||
if strings.Contains(name, "zen.spamhaus.org") {
|
||||
return []string{"https://www.spamhaus.org/query/ip/192.0.2.10"}, nil
|
||||
}
|
||||
return nil, errors.New("NXDOMAIN")
|
||||
}
|
||||
|
||||
report := NewPreflightUseCase(deps).Execute(context.Background(), healthyInput())
|
||||
check := findCheck(t, report, "blocklist")
|
||||
|
||||
if check.Status != CheckFailed || check.Severity != SeverityCritical {
|
||||
t.Errorf("check = %+v, want a critical failure", check)
|
||||
}
|
||||
if !strings.Contains(check.Detail, "Spamhaus") {
|
||||
t.Errorf("detail should name the list: %q", check.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
// PLAN.md section 8.2 trap 2: Traefik only holds certificates for hosts it
|
||||
// routes, so the remedy has to explain the missing router.
|
||||
func TestPreflight_MissingCertificateExplainsTheTraefikRouter(t *testing.T) {
|
||||
deps := healthyDeps()
|
||||
deps.HasCertificateFor = func(string) bool { return false }
|
||||
|
||||
report := NewPreflightUseCase(deps).Execute(context.Background(), healthyInput())
|
||||
check := findCheck(t, report, "certificate is loaded")
|
||||
|
||||
if check.Status != CheckFailed || check.Severity != SeverityCritical {
|
||||
t.Errorf("check = %+v, want a critical failure", check)
|
||||
}
|
||||
if !strings.Contains(check.Remedy, "router") {
|
||||
t.Errorf("remedy should explain the missing router: %q", check.Remedy)
|
||||
}
|
||||
}
|
||||
|
||||
// PLAN.md section 5.1: enabling DANE under Traefik-managed certificates
|
||||
// guarantees breakage at the next renewal, so the preflight must refuse it.
|
||||
func TestPreflight_DaneUnderTraefikModeIsRefused(t *testing.T) {
|
||||
input := healthyInput()
|
||||
input.DaneEnabled = true
|
||||
input.TLSMode = "traefik"
|
||||
|
||||
report := NewPreflightUseCase(healthyDeps()).Execute(context.Background(), input)
|
||||
check := findCheck(t, report, "DANE")
|
||||
|
||||
if check.Status != CheckFailed || check.Severity != SeverityCritical {
|
||||
t.Errorf("check = %+v, want a critical failure", check)
|
||||
}
|
||||
if !report.Blocking() {
|
||||
t.Error("DANE under Traefik mode must block the deployment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflight_DaneUnderAcmeModePasses(t *testing.T) {
|
||||
input := healthyInput()
|
||||
input.DaneEnabled = true
|
||||
input.TLSMode = "acme"
|
||||
|
||||
report := NewPreflightUseCase(healthyDeps()).Execute(context.Background(), input)
|
||||
|
||||
if check := findCheck(t, report, "DANE"); check.Status != CheckPassed {
|
||||
t.Errorf("check = %+v, want a pass under TLS_MODE=acme", check)
|
||||
}
|
||||
}
|
||||
|
||||
// DKIM signatures and TOTP codes both break on a drifting clock, but neither
|
||||
// loses mail outright, so this is high rather than critical.
|
||||
func TestPreflight_ClockDriftIsHighNotCritical(t *testing.T) {
|
||||
deps := healthyDeps()
|
||||
local := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
|
||||
deps.Now = func() time.Time { return local.Add(90 * time.Second) }
|
||||
deps.NetworkTime = func(context.Context) (time.Time, error) { return local, nil }
|
||||
|
||||
report := NewPreflightUseCase(deps).Execute(context.Background(), healthyInput())
|
||||
check := findCheck(t, report, "clock")
|
||||
|
||||
if check.Status != CheckFailed || check.Severity != SeverityHigh {
|
||||
t.Errorf("check = %+v, want a high-severity failure", check)
|
||||
}
|
||||
if report.Blocking() {
|
||||
t.Error("clock drift alone must not block the deployment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflight_LowDiskIsMediumSeverity(t *testing.T) {
|
||||
deps := healthyDeps()
|
||||
deps.FreeDiskPercent = func() (float64, error) { return 4, nil }
|
||||
|
||||
report := NewPreflightUseCase(deps).Execute(context.Background(), healthyInput())
|
||||
check := findCheck(t, report, "free space")
|
||||
|
||||
if check.Status != CheckFailed || check.Severity != SeverityMedium {
|
||||
t.Errorf("check = %+v, want a medium-severity failure", check)
|
||||
}
|
||||
}
|
||||
|
||||
// PLAN.md premise 5: half-configured IPv6 is a common cause of rejection, and
|
||||
// disabling it is a valid answer.
|
||||
func TestPreflight_IPv6WithoutPtrFailsButDisabledIPv6Passes(t *testing.T) {
|
||||
deps := healthyDeps()
|
||||
deps.LookupAddr = func(_ context.Context, ip string) ([]string, error) {
|
||||
if strings.Contains(ip, ":") {
|
||||
return nil, errors.New("NXDOMAIN")
|
||||
}
|
||||
return []string{"mail.example.test."}, nil
|
||||
}
|
||||
|
||||
withIPv6 := healthyInput()
|
||||
withIPv6.PublicIPv6 = "2001:db8::1"
|
||||
|
||||
report := NewPreflightUseCase(deps).Execute(context.Background(), withIPv6)
|
||||
if check := findCheck(t, report, "IPv6"); check.Status != CheckFailed {
|
||||
t.Errorf("check = %+v, want a failure for IPv6 without a PTR", check)
|
||||
}
|
||||
|
||||
report = NewPreflightUseCase(deps).Execute(context.Background(), healthyInput())
|
||||
if check := findCheck(t, report, "IPv6"); check.Status != CheckPassed {
|
||||
t.Errorf("check = %+v, want a pass when IPv6 is disabled", check)
|
||||
}
|
||||
}
|
||||
|
||||
// A probe that cannot run is reported as skipped, never silently as a pass.
|
||||
func TestPreflight_MissingProbesAreSkippedNotPassed(t *testing.T) {
|
||||
report := NewPreflightUseCase(PreflightDeps{}).Execute(context.Background(), healthyInput())
|
||||
|
||||
for _, result := range report.Results {
|
||||
if result.Status == CheckPassed && result.Name != "IPv6 is either fully configured or disabled" {
|
||||
t.Errorf("check %q passed with no probe configured", result.Name)
|
||||
}
|
||||
}
|
||||
if report.Blocking() {
|
||||
t.Error("skipped checks must not block")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReverseIPv4(t *testing.T) {
|
||||
if got := reverseIPv4("192.0.2.10"); got != "10.2.0.192" {
|
||||
t.Errorf("reverseIPv4 = %q, want 10.2.0.192", got)
|
||||
}
|
||||
if got := reverseIPv4("2001:db8::1"); got != "" {
|
||||
t.Errorf("reverseIPv4 of an IPv6 address = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,11 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"sort"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
"lambdamail/protocols/internal/domain/valueobject"
|
||||
@@ -31,6 +35,11 @@ type ProcessInboundEmailInput struct {
|
||||
Recipients []port.MailboxRecord
|
||||
RecipientAddresses []string
|
||||
Body io.Reader
|
||||
// ClientIP and HeloDomain identify the peer. SPF is defined over them
|
||||
// (RFC 7208 section 2.3), so a delivery without them can only ever
|
||||
// produce a "none" verdict.
|
||||
ClientIP net.IP
|
||||
HeloDomain string
|
||||
}
|
||||
|
||||
// ProcessInboundEmailUseCase implements the inbound half of PLAN.md section
|
||||
@@ -39,6 +48,8 @@ type ProcessInboundEmailInput struct {
|
||||
// later sub-projects).
|
||||
type ProcessInboundEmailUseCase struct {
|
||||
scanner port.ContentScanner
|
||||
authenticator port.MailAuthenticator
|
||||
arcSealer port.ArcSealer
|
||||
mailboxes port.MailboxRepository
|
||||
blobs port.BlobStorage
|
||||
messages port.InboundMessageRepository
|
||||
@@ -54,11 +65,49 @@ func (uc *ProcessInboundEmailUseCase) SetScanner(scanner port.ContentScanner) {
|
||||
uc.scanner = scanner
|
||||
}
|
||||
|
||||
func (uc *ProcessInboundEmailUseCase) SetAuthenticator(authenticator port.MailAuthenticator) {
|
||||
uc.authenticator = authenticator
|
||||
}
|
||||
|
||||
// SetArcSealer enables ARC sealing of accepted messages. It is only worth
|
||||
// enabling on a host that forwards mail onward (PLAN.md section 5).
|
||||
func (uc *ProcessInboundEmailUseCase) SetArcSealer(sealer port.ArcSealer) {
|
||||
uc.arcSealer = sealer
|
||||
}
|
||||
|
||||
func (uc *ProcessInboundEmailUseCase) SetTrackerManager(tm *MailboxTrackerManager, folders port.ImapFolderRepository) {
|
||||
uc.trackerManager = tm
|
||||
uc.folders = folders
|
||||
}
|
||||
|
||||
// prependHeaders inserts the scanner's verdict headers at the top of the
|
||||
// message. A receiving MTA adds its trace headers above the existing ones
|
||||
// (RFC 5321 section 4.4), which also keeps the original bytes - and therefore
|
||||
// any DKIM signature over them - untouched.
|
||||
func prependHeaders(payload []byte, headers map[string]string) []byte {
|
||||
if len(headers) == 0 {
|
||||
return payload
|
||||
}
|
||||
|
||||
// Sorted so the delivered message is byte-for-byte reproducible instead of
|
||||
// depending on Go's randomized map iteration order.
|
||||
names := make([]string, 0, len(headers))
|
||||
for name := range headers {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
var buf bytes.Buffer
|
||||
for _, name := range names {
|
||||
buf.WriteString(name)
|
||||
buf.WriteString(": ")
|
||||
buf.WriteString(headers[name])
|
||||
buf.WriteString("\r\n")
|
||||
}
|
||||
buf.Write(payload)
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// ResolveRecipient is called from the SMTP session's RCPT TO handler. It
|
||||
// returns every mailbox address should be delivered to (more than one for
|
||||
// an alias that fans out), or ErrRecipientNotFound / ErrMailboxQuotaExceeded.
|
||||
@@ -84,12 +133,68 @@ func (uc *ProcessInboundEmailUseCase) ResolveRecipient(ctx context.Context, addr
|
||||
// the same blob (PLAN.md section 9's dedup design: 1 email to 50
|
||||
// recipients = 1 file).
|
||||
func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessInboundEmailInput) error {
|
||||
// The two slices are indexed in lockstep below: one envelope address per
|
||||
// resolved mailbox, repeated when an alias fans out. Refusing a mismatch
|
||||
// turns a caller's bug into an error instead of an out-of-range panic that
|
||||
// would take down the calling worker.
|
||||
if len(input.Recipients) != len(input.RecipientAddresses) {
|
||||
return fmt.Errorf(
|
||||
"process inbound email: %d recipients but %d envelope addresses",
|
||||
len(input.Recipients), len(input.RecipientAddresses),
|
||||
)
|
||||
}
|
||||
|
||||
payload, err := io.ReadAll(input.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targetFolder := "INBOX"
|
||||
authResult := port.InboundAuthResult{
|
||||
SPF: port.AuthResultNone,
|
||||
DKIM: port.AuthResultNone,
|
||||
DMARC: port.AuthResultNone,
|
||||
}
|
||||
|
||||
// Authentication runs before the content scanners so that the spam filter
|
||||
// sees our Authentication-Results header and can score on it
|
||||
// (PLAN.md section 6.1).
|
||||
if uc.authenticator != nil {
|
||||
authResult = uc.authenticator.Authenticate(ctx, port.InboundAuthInput{
|
||||
ClientIP: input.ClientIP,
|
||||
HeloDomain: input.HeloDomain,
|
||||
EnvelopeFrom: input.Sender,
|
||||
Message: payload,
|
||||
})
|
||||
|
||||
// Honouring a published "p=reject" is the whole point of the sender
|
||||
// having published it. Quarantine is left to the spam filter, which
|
||||
// files rather than refuses.
|
||||
if authResult.DMARC == port.AuthResultFail && authResult.DmarcPolicy == "reject" {
|
||||
return errors.New("550 5.7.1 DMARC policy rejects this message")
|
||||
}
|
||||
|
||||
if authResult.AuthenticationResults != "" {
|
||||
payload = prependHeaders(payload, map[string]string{
|
||||
"Authentication-Results": authResult.AuthenticationResults,
|
||||
})
|
||||
}
|
||||
|
||||
// The ARC set has to be built over the message as it arrived, with
|
||||
// this hop's verdict already known, so it is sealed here rather than
|
||||
// after the content scanners rewrite headers.
|
||||
if uc.arcSealer != nil {
|
||||
sealed, err := uc.arcSealer.Seal(ctx, payload, authResult)
|
||||
if err != nil {
|
||||
// A sealing failure must not lose the message: it is delivered
|
||||
// without the ARC set, which is exactly the state it would
|
||||
// have been in had sealing not been enabled.
|
||||
log.Printf("arc: could not seal message from %s: %v", input.Sender, err)
|
||||
} else {
|
||||
payload = sealed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if uc.scanner != nil {
|
||||
recipientAddr := ""
|
||||
@@ -115,6 +220,7 @@ func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessI
|
||||
case valueobject.ScanVerdictSpamJunk:
|
||||
targetFolder = "Junk"
|
||||
}
|
||||
payload = prependHeaders(payload, scanRes.HeadersToAdd)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,9 +236,9 @@ func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessI
|
||||
SenderAddress: input.Sender,
|
||||
RecipientAddress: input.RecipientAddresses[i],
|
||||
TargetFolderName: targetFolder,
|
||||
SPFResult: "none",
|
||||
DKIMResult: "none",
|
||||
DMARCResult: "none",
|
||||
SPFResult: authResult.SPF,
|
||||
DKIMResult: authResult.DKIM,
|
||||
DMARCResult: authResult.DMARC,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"lambdamail/protocols/internal/domain/entity"
|
||||
)
|
||||
|
||||
func conflictTestInput() SyncDnsRecordsInput {
|
||||
return SyncDnsRecordsInput{
|
||||
DomainName: "example.test",
|
||||
MailHost: "mail.example.test",
|
||||
ServerIPv4: "192.0.2.1",
|
||||
RsaDkimPubKey: "rsaKey",
|
||||
EdDkimPubKey: "edKey",
|
||||
}
|
||||
}
|
||||
|
||||
// PLAN.md section 7.5: pre-existing records that are not ours are never
|
||||
// touched, only reported as conflicts. A third-party verification TXT sits at
|
||||
// the zone apex under the same type and name as our SPF record, so a reconciler
|
||||
// keyed only on type+name would overwrite it and break the other service.
|
||||
func TestSyncDnsRecords_ForeignApexTxtIsPreserved(t *testing.T) {
|
||||
dns := newFakeDnsProvider()
|
||||
dns.seed(entity.DnsRecord{
|
||||
Type: "TXT",
|
||||
Name: "example.test",
|
||||
Value: "google-site-verification=abc123",
|
||||
})
|
||||
|
||||
uc := NewSyncDnsRecordsUseCase(dns, nil)
|
||||
out, err := uc.Execute(context.Background(), conflictTestInput())
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
apexTxt := dns.find("TXT", "example.test")
|
||||
if len(apexTxt) != 2 {
|
||||
t.Fatalf("expected the foreign TXT and our SPF to coexist, got %d records", len(apexTxt))
|
||||
}
|
||||
|
||||
var foundForeign, foundSPF bool
|
||||
for _, r := range apexTxt {
|
||||
if r.Value == "google-site-verification=abc123" {
|
||||
foundForeign = true
|
||||
}
|
||||
if strings.HasPrefix(r.Value, "v=spf1") {
|
||||
foundSPF = true
|
||||
}
|
||||
}
|
||||
if !foundForeign {
|
||||
t.Error("the foreign verification TXT was destroyed")
|
||||
}
|
||||
if !foundSPF {
|
||||
t.Error("the SPF record was not created alongside the foreign apex TXT")
|
||||
}
|
||||
if out.ConflictCount != 0 {
|
||||
t.Errorf("a coexisting foreign TXT is not a conflict, got ConflictCount=%d", out.ConflictCount)
|
||||
}
|
||||
}
|
||||
|
||||
// A name that admits only one value (the mail host A record) already pointing
|
||||
// somewhere else is a genuine conflict: silently repointing it could take down
|
||||
// an unrelated service.
|
||||
func TestSyncDnsRecords_ForeignARecordIsReportedAsConflict(t *testing.T) {
|
||||
dns := newFakeDnsProvider()
|
||||
dns.seed(entity.DnsRecord{
|
||||
Type: "A",
|
||||
Name: "mail.example.test",
|
||||
Value: "198.51.100.9",
|
||||
Comment: "managed by something else",
|
||||
})
|
||||
|
||||
uc := NewSyncDnsRecordsUseCase(dns, nil)
|
||||
out, err := uc.Execute(context.Background(), conflictTestInput())
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
records := dns.find("A", "mail.example.test")
|
||||
if len(records) != 1 || records[0].Value != "198.51.100.9" {
|
||||
t.Errorf("foreign A record was not left untouched: %+v", records)
|
||||
}
|
||||
if out.ConflictCount != 1 {
|
||||
t.Fatalf("ConflictCount = %d, want 1", out.ConflictCount)
|
||||
}
|
||||
if out.Status != SyncStatusPartial {
|
||||
t.Errorf("Status = %s, want %s", out.Status, SyncStatusPartial)
|
||||
}
|
||||
if !strings.Contains(out.Conflicts[0].String(), "198.51.100.9") {
|
||||
t.Errorf("conflict report does not name the blocking value: %s", out.Conflicts[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Our own records must still be updated in place when they drift.
|
||||
func TestSyncDnsRecords_OwnRecordDriftIsCorrected(t *testing.T) {
|
||||
dns := newFakeDnsProvider()
|
||||
uc := NewSyncDnsRecordsUseCase(dns, nil)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := uc.Execute(ctx, conflictTestInput()); err != nil {
|
||||
t.Fatalf("initial Execute: %v", err)
|
||||
}
|
||||
|
||||
for i, r := range dns.records {
|
||||
if r.Type == "A" && r.Name == "mail.example.test" {
|
||||
dns.records[i].Value = "203.0.113.7"
|
||||
}
|
||||
}
|
||||
|
||||
out, err := uc.Execute(ctx, conflictTestInput())
|
||||
if err != nil {
|
||||
t.Fatalf("second Execute: %v", err)
|
||||
}
|
||||
|
||||
if out.UpdatedCount != 1 || out.ConflictCount != 0 {
|
||||
t.Errorf("updated=%d conflicts=%d, want updated=1 conflicts=0", out.UpdatedCount, out.ConflictCount)
|
||||
}
|
||||
if records := dns.find("A", "mail.example.test"); records[0].Value != "192.0.2.1" {
|
||||
t.Errorf("drifted A record was not corrected: got %q", records[0].Value)
|
||||
}
|
||||
if out.Status != SyncStatusVerified {
|
||||
t.Errorf("Status = %s, want %s", out.Status, SyncStatusVerified)
|
||||
}
|
||||
}
|
||||
|
||||
type stubDnsVerifier struct {
|
||||
invisible map[string]bool
|
||||
}
|
||||
|
||||
func (s stubDnsVerifier) VerifyRecord(_ context.Context, record entity.DnsRecord) (bool, string) {
|
||||
if s.invisible[record.Name] {
|
||||
return false, "no answer"
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// PLAN.md section 7.5 verifies through public resolvers after writing. A
|
||||
// record the provider accepted but nobody can resolve is drift, not success.
|
||||
func TestSyncDnsRecords_UnresolvableRecordIsReportedAsDrift(t *testing.T) {
|
||||
dns := newFakeDnsProvider()
|
||||
uc := NewSyncDnsRecordsUseCase(dns, nil)
|
||||
uc.SetVerifier(stubDnsVerifier{invisible: map[string]bool{"mail.example.test": true}})
|
||||
|
||||
out, err := uc.Execute(context.Background(), conflictTestInput())
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
if out.Status != SyncStatusDrift {
|
||||
t.Errorf("Status = %s, want DRIFT", out.Status)
|
||||
}
|
||||
if len(out.Unverified) == 0 {
|
||||
t.Fatal("the unresolvable record was not reported")
|
||||
}
|
||||
if !strings.Contains(out.Unverified[0], "mail.example.test") {
|
||||
t.Errorf("Unverified = %v, want it to name the record", out.Unverified)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncDnsRecords_AllRecordsVisibleIsVerified(t *testing.T) {
|
||||
dns := newFakeDnsProvider()
|
||||
uc := NewSyncDnsRecordsUseCase(dns, nil)
|
||||
uc.SetVerifier(stubDnsVerifier{})
|
||||
|
||||
out, err := uc.Execute(context.Background(), conflictTestInput())
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
if out.Status != SyncStatusVerified {
|
||||
t.Errorf("Status = %s, want VERIFIED", out.Status)
|
||||
}
|
||||
if len(out.Unverified) != 0 {
|
||||
t.Errorf("Unverified = %v, want empty", out.Unverified)
|
||||
}
|
||||
}
|
||||
@@ -9,28 +9,68 @@ import (
|
||||
"lambdamail/protocols/internal/domain/entity"
|
||||
)
|
||||
|
||||
// SyncStatus is the outcome reported to the admin console (PLAN.md section 7.5).
|
||||
type SyncStatus string
|
||||
|
||||
const (
|
||||
SyncStatusVerified SyncStatus = "VERIFIED"
|
||||
SyncStatusPartial SyncStatus = "PARTIAL"
|
||||
SyncStatusDrift SyncStatus = "DRIFT"
|
||||
)
|
||||
|
||||
// ownedRecordCommentPrefix marks the records this reconciler manages. Anything
|
||||
// in the zone without it that would collide with a desired record belongs to
|
||||
// somebody else and is reported instead of overwritten.
|
||||
const ownedRecordCommentPrefix = "LambdaMail"
|
||||
|
||||
type SyncDnsRecordsInput struct {
|
||||
DomainName string
|
||||
MailHost string
|
||||
ServerIPv4 string
|
||||
ServerIPv6 string
|
||||
RsaDkimPubKey string
|
||||
EdDkimPubKey string
|
||||
DomainName string
|
||||
MailHost string
|
||||
ServerIPv4 string
|
||||
ServerIPv6 string
|
||||
RsaDkimPubKey string
|
||||
EdDkimPubKey string
|
||||
// TlsaHash publishes a single DANE association. TlsaHashes supersedes it
|
||||
// and is what a rollover uses, since it can carry the current and the
|
||||
// next key at once.
|
||||
TlsaHash string
|
||||
TlsaHashes []string
|
||||
DaneEnabled bool
|
||||
RelaySpfInclude string
|
||||
AdminEmailAddress string
|
||||
}
|
||||
|
||||
// SyncConflict describes a record that could not be reconciled because a
|
||||
// foreign record already occupies its name.
|
||||
type SyncConflict struct {
|
||||
Type string
|
||||
Name string
|
||||
Existing string
|
||||
Desired string
|
||||
}
|
||||
|
||||
func (c SyncConflict) String() string {
|
||||
return fmt.Sprintf("%s %s: existing %q blocks desired %q", c.Type, c.Name, c.Existing, c.Desired)
|
||||
}
|
||||
|
||||
type SyncDnsRecordsOutput struct {
|
||||
ZoneID string
|
||||
Status SyncStatus
|
||||
CreatedCount int
|
||||
UpdatedCount int
|
||||
UnchangedCount int
|
||||
ConflictCount int
|
||||
Conflicts []SyncConflict
|
||||
Errors []string
|
||||
// Unverified lists records that were written but could not be read back
|
||||
// through the public resolvers.
|
||||
Unverified []string
|
||||
}
|
||||
|
||||
type SyncDnsRecordsUseCase struct {
|
||||
dnsProvider port.DnsProvider
|
||||
aliasRepo port.SystemAliasRepository
|
||||
verifier port.DnsVerifier
|
||||
}
|
||||
|
||||
func NewSyncDnsRecordsUseCase(dnsProvider port.DnsProvider, aliasRepo port.SystemAliasRepository) *SyncDnsRecordsUseCase {
|
||||
@@ -40,21 +80,35 @@ func NewSyncDnsRecordsUseCase(dnsProvider port.DnsProvider, aliasRepo port.Syste
|
||||
}
|
||||
}
|
||||
|
||||
// SetVerifier enables the independent check of PLAN.md section 7.5: after
|
||||
// writing, the records are read back through public resolvers. The provider
|
||||
// accepting a write only proves the API accepted it, not that the record
|
||||
// resolves for anyone else.
|
||||
func (uc *SyncDnsRecordsUseCase) SetVerifier(verifier port.DnsVerifier) {
|
||||
uc.verifier = verifier
|
||||
}
|
||||
|
||||
func (uc *SyncDnsRecordsUseCase) Execute(ctx context.Context, input SyncDnsRecordsInput) (*SyncDnsRecordsOutput, error) {
|
||||
if input.DomainName == "" {
|
||||
return nil, fmt.Errorf("domain name is required")
|
||||
}
|
||||
|
||||
desiredSpecs := entity.Build13DnsRecordSpecs(
|
||||
input.DomainName,
|
||||
input.MailHost,
|
||||
input.ServerIPv4,
|
||||
input.ServerIPv6,
|
||||
input.RsaDkimPubKey,
|
||||
input.EdDkimPubKey,
|
||||
input.TlsaHash,
|
||||
input.DaneEnabled,
|
||||
)
|
||||
tlsaHashes := input.TlsaHashes
|
||||
if len(tlsaHashes) == 0 && input.TlsaHash != "" {
|
||||
tlsaHashes = []string{input.TlsaHash}
|
||||
}
|
||||
|
||||
desiredSpecs := entity.BuildDnsRecordSpecs(entity.DnsRecordSpec{
|
||||
DomainName: input.DomainName,
|
||||
MailHost: input.MailHost,
|
||||
ServerIPv4: input.ServerIPv4,
|
||||
ServerIPv6: input.ServerIPv6,
|
||||
RsaDkimPubKey: input.RsaDkimPubKey,
|
||||
EdDkimPubKey: input.EdDkimPubKey,
|
||||
TlsaHashes: tlsaHashes,
|
||||
DaneEnabled: input.DaneEnabled,
|
||||
RelaySpfInclude: input.RelaySpfInclude,
|
||||
})
|
||||
|
||||
zoneID, err := uc.dnsProvider.GetZoneID(ctx, input.DomainName)
|
||||
if err != nil {
|
||||
@@ -66,31 +120,48 @@ func (uc *SyncDnsRecordsUseCase) Execute(ctx context.Context, input SyncDnsRecor
|
||||
return nil, fmt.Errorf("list dns records for zone %s: %w", zoneID, err)
|
||||
}
|
||||
|
||||
existingMap := make(map[string]entity.DnsRecord)
|
||||
// A single type+name can legitimately hold several records (multiple TXT at
|
||||
// the zone apex is the common case), so the index maps to every candidate.
|
||||
existingByKey := make(map[string][]entity.DnsRecord)
|
||||
for _, rec := range existingRecords {
|
||||
key := fmt.Sprintf("%s:%s", strings.ToUpper(rec.Type), strings.ToLower(rec.Name))
|
||||
existingMap[key] = rec
|
||||
key := recordKey(rec.Type, rec.Name)
|
||||
existingByKey[key] = append(existingByKey[key], rec)
|
||||
}
|
||||
|
||||
output := &SyncDnsRecordsOutput{ZoneID: zoneID}
|
||||
|
||||
for _, spec := range desiredSpecs {
|
||||
key := fmt.Sprintf("%s:%s", strings.ToUpper(spec.Type), strings.ToLower(spec.Name))
|
||||
existing, found := existingMap[key]
|
||||
candidates := existingByKey[recordKey(spec.Type, spec.Name)]
|
||||
match, foreign := classifyCandidates(spec, candidates)
|
||||
|
||||
if !found {
|
||||
if err := uc.dnsProvider.CreateRecord(ctx, zoneID, spec); err != nil {
|
||||
return nil, fmt.Errorf("create dns record %s %s: %w", spec.Type, spec.Name, err)
|
||||
switch {
|
||||
case match != nil:
|
||||
if spec.EqualsNormalized(*match) {
|
||||
output.UnchangedCount++
|
||||
continue
|
||||
}
|
||||
output.CreatedCount++
|
||||
} else if !spec.EqualsNormalized(existing) {
|
||||
spec.ID = existing.ID
|
||||
spec.ID = match.ID
|
||||
if err := uc.dnsProvider.UpdateRecord(ctx, zoneID, spec); err != nil {
|
||||
return nil, fmt.Errorf("update dns record %s %s: %w", spec.Type, spec.Name, err)
|
||||
output.Errors = append(output.Errors, fmt.Sprintf("update %s %s: %v", spec.Type, spec.Name, err))
|
||||
continue
|
||||
}
|
||||
output.UpdatedCount++
|
||||
} else {
|
||||
output.UnchangedCount++
|
||||
|
||||
case foreign != nil:
|
||||
output.ConflictCount++
|
||||
output.Conflicts = append(output.Conflicts, SyncConflict{
|
||||
Type: spec.Type,
|
||||
Name: spec.Name,
|
||||
Existing: foreign.Value,
|
||||
Desired: spec.Value,
|
||||
})
|
||||
|
||||
default:
|
||||
if err := uc.dnsProvider.CreateRecord(ctx, zoneID, spec); err != nil {
|
||||
output.Errors = append(output.Errors, fmt.Sprintf("create %s %s: %v", spec.Type, spec.Name, err))
|
||||
continue
|
||||
}
|
||||
output.CreatedCount++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,5 +171,104 @@ func (uc *SyncDnsRecordsUseCase) Execute(ctx context.Context, input SyncDnsRecor
|
||||
}
|
||||
}
|
||||
|
||||
output.Unverified = uc.verifyPublished(ctx, desiredSpecs)
|
||||
|
||||
output.Status = SyncStatusVerified
|
||||
switch {
|
||||
case output.ConflictCount > 0 || len(output.Errors) > 0:
|
||||
output.Status = SyncStatusPartial
|
||||
case len(output.Unverified) > 0:
|
||||
// The records were written but the world cannot see them yet. That is
|
||||
// drift, not success: reporting VERIFIED here is what would let a
|
||||
// broken zone look healthy.
|
||||
output.Status = SyncStatusDrift
|
||||
}
|
||||
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// verifyPublished re-reads every desired record through public resolvers and
|
||||
// returns the ones that did not check out.
|
||||
func (uc *SyncDnsRecordsUseCase) verifyPublished(ctx context.Context, specs []entity.DnsRecord) []string {
|
||||
if uc.verifier == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var unverified []string
|
||||
for _, spec := range specs {
|
||||
visible, detail := uc.verifier.VerifyRecord(ctx, spec)
|
||||
if visible {
|
||||
continue
|
||||
}
|
||||
entry := fmt.Sprintf("%s %s", spec.Type, spec.Name)
|
||||
if detail != "" {
|
||||
entry += ": " + detail
|
||||
}
|
||||
unverified = append(unverified, entry)
|
||||
}
|
||||
return unverified
|
||||
}
|
||||
|
||||
func recordKey(recType, name string) string {
|
||||
return strings.ToUpper(recType) + ":" + strings.ToLower(strings.TrimSuffix(name, "."))
|
||||
}
|
||||
|
||||
// classifyCandidates picks, among the records already published under a
|
||||
// desired record's type and name, the one this reconciler owns. It returns
|
||||
// either that match, or - when no record is ours and the name admits only a
|
||||
// single value - the foreign record blocking it.
|
||||
func classifyCandidates(spec entity.DnsRecord, candidates []entity.DnsRecord) (match *entity.DnsRecord, blocking *entity.DnsRecord) {
|
||||
for i := range candidates {
|
||||
if isOurRecord(spec, candidates[i]) {
|
||||
return &candidates[i], nil
|
||||
}
|
||||
}
|
||||
if len(candidates) > 0 && !allowsCoexistence(spec.Type) {
|
||||
return nil, &candidates[0]
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// isOurRecord decides ownership. The record comment is the primary marker, but
|
||||
// it is not preserved by every provider or migration path, so TXT records fall
|
||||
// back to their protocol tag: only one "v=spf1" record may exist per name, so a
|
||||
// record carrying our tag is unambiguously the one we manage.
|
||||
func isOurRecord(spec, existing entity.DnsRecord) bool {
|
||||
if strings.HasPrefix(existing.Comment, ownedRecordCommentPrefix) {
|
||||
return true
|
||||
}
|
||||
if strings.EqualFold(spec.Type, "TXT") {
|
||||
tag := txtTag(spec.Value)
|
||||
return tag != "" && strings.EqualFold(txtTag(existing.Value), tag)
|
||||
}
|
||||
// For single-valued names an identical value means the record is already
|
||||
// what we want, whoever created it.
|
||||
return strings.TrimSpace(spec.Value) == strings.TrimSpace(existing.Value)
|
||||
}
|
||||
|
||||
// txtTag extracts the leading "v=<protocol>" tag that identifies which mail
|
||||
// protocol a TXT record belongs to (RFC 7208 SPF, RFC 6376 DKIM, RFC 7489
|
||||
// DMARC, RFC 8461 MTA-STS, RFC 8460 TLS-RPT).
|
||||
func txtTag(value string) string {
|
||||
trimmed := strings.TrimSpace(strings.Trim(value, `"`))
|
||||
if !strings.HasPrefix(strings.ToLower(trimmed), "v=") {
|
||||
return ""
|
||||
}
|
||||
rest := trimmed[2:]
|
||||
if idx := strings.IndexAny(rest, "; \t"); idx >= 0 {
|
||||
rest = rest[:idx]
|
||||
}
|
||||
return strings.ToLower(rest)
|
||||
}
|
||||
|
||||
// allowsCoexistence reports whether several records may share a type and name.
|
||||
// TXT is the mail-relevant case: verification tokens from unrelated services
|
||||
// live alongside SPF at the zone apex and must not be disturbed.
|
||||
func allowsCoexistence(recType string) bool {
|
||||
switch strings.ToUpper(recType) {
|
||||
case "TXT", "MX", "TLSA":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,35 @@ import (
|
||||
"lambdamail/protocols/internal/domain/entity"
|
||||
)
|
||||
|
||||
// fakeDnsProvider models a real zone: several records may share a type and
|
||||
// name (multiple TXT at the apex), so records are held in a list keyed by id.
|
||||
type fakeDnsProvider struct {
|
||||
records map[string]entity.DnsRecord // key -> record
|
||||
records []entity.DnsRecord
|
||||
nextID int
|
||||
}
|
||||
|
||||
func newFakeDnsProvider() *fakeDnsProvider {
|
||||
return &fakeDnsProvider{records: make(map[string]entity.DnsRecord)}
|
||||
return &fakeDnsProvider{}
|
||||
}
|
||||
|
||||
// seed publishes a pre-existing record that this reconciler did not create.
|
||||
func (f *fakeDnsProvider) seed(record entity.DnsRecord) {
|
||||
if record.ID == "" {
|
||||
f.nextID++
|
||||
record.ID = fmt.Sprintf("seed_%d", f.nextID)
|
||||
}
|
||||
f.records = append(f.records, record)
|
||||
}
|
||||
|
||||
// find returns the records published under a type and name.
|
||||
func (f *fakeDnsProvider) find(recType, name string) []entity.DnsRecord {
|
||||
var out []entity.DnsRecord
|
||||
for _, r := range f.records {
|
||||
if strings.EqualFold(r.Type, recType) && strings.EqualFold(r.Name, name) {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (f *fakeDnsProvider) GetZoneID(_ context.Context, domainName string) (string, error) {
|
||||
@@ -22,31 +45,31 @@ func (f *fakeDnsProvider) GetZoneID(_ context.Context, domainName string) (strin
|
||||
}
|
||||
|
||||
func (f *fakeDnsProvider) ListRecords(_ context.Context, _ string) ([]entity.DnsRecord, error) {
|
||||
var out []entity.DnsRecord
|
||||
for _, r := range f.records {
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, nil
|
||||
return append([]entity.DnsRecord(nil), f.records...), nil
|
||||
}
|
||||
|
||||
func (f *fakeDnsProvider) CreateRecord(_ context.Context, _ string, record entity.DnsRecord) error {
|
||||
key := fmt.Sprintf("%s:%s", strings.ToUpper(record.Type), strings.ToLower(record.Name))
|
||||
record.ID = fmt.Sprintf("id_%s", key)
|
||||
f.records[key] = record
|
||||
f.nextID++
|
||||
record.ID = fmt.Sprintf("id_%d", f.nextID)
|
||||
f.records = append(f.records, record)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeDnsProvider) UpdateRecord(_ context.Context, _ string, record entity.DnsRecord) error {
|
||||
key := fmt.Sprintf("%s:%s", strings.ToUpper(record.Type), strings.ToLower(record.Name))
|
||||
f.records[key] = record
|
||||
return nil
|
||||
for i, r := range f.records {
|
||||
if r.ID == record.ID {
|
||||
f.records[i] = record
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("record %s not found", record.ID)
|
||||
}
|
||||
|
||||
func (f *fakeDnsProvider) DeleteRecord(_ context.Context, _ string, recordID string) error {
|
||||
for k, r := range f.records {
|
||||
for i, r := range f.records {
|
||||
if r.ID == recordID {
|
||||
delete(f.records, k)
|
||||
break
|
||||
f.records = append(f.records[:i], f.records[i+1:]...)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -61,7 +84,11 @@ func (f *fakeSystemAliasRepository) EnsureSystemAliases(_ context.Context, _ str
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestSyncDnsRecordsUseCase_Creates13RecordsAndAliases(t *testing.T) {
|
||||
// The full desired state is the 13 numbered records of PLAN.md section 7.1
|
||||
// plus the three client-autoconfiguration records of section 7.2.
|
||||
const expectedRecordCount = 16
|
||||
|
||||
func TestSyncDnsRecordsUseCase_CreatesFullRecordSetAndAliases(t *testing.T) {
|
||||
dns := newFakeDnsProvider()
|
||||
alias := &fakeSystemAliasRepository{}
|
||||
uc := NewSyncDnsRecordsUseCase(dns, alias)
|
||||
@@ -85,8 +112,8 @@ func TestSyncDnsRecordsUseCase_Creates13RecordsAndAliases(t *testing.T) {
|
||||
t.Fatalf("Execute failed: %v", err)
|
||||
}
|
||||
|
||||
if out.CreatedCount != 13 {
|
||||
t.Errorf("CreatedCount = %d, want 13", out.CreatedCount)
|
||||
if out.CreatedCount != expectedRecordCount {
|
||||
t.Errorf("CreatedCount = %d, want %d", out.CreatedCount, expectedRecordCount)
|
||||
}
|
||||
if !alias.ensured {
|
||||
t.Error("expected system aliases to be ensured")
|
||||
@@ -98,7 +125,7 @@ func TestSyncDnsRecordsUseCase_Creates13RecordsAndAliases(t *testing.T) {
|
||||
t.Fatalf("Execute 2 failed: %v", err)
|
||||
}
|
||||
|
||||
if out2.UnchangedCount != 13 || out2.CreatedCount != 0 || out2.UpdatedCount != 0 {
|
||||
t.Errorf("expected 13 unchanged, got created=%d, updated=%d, unchanged=%d", out2.CreatedCount, out2.UpdatedCount, out2.UnchangedCount)
|
||||
if out2.UnchangedCount != expectedRecordCount || out2.CreatedCount != 0 || out2.UpdatedCount != 0 {
|
||||
t.Errorf("expected %d unchanged, got created=%d, updated=%d, unchanged=%d", expectedRecordCount, out2.CreatedCount, out2.UpdatedCount, out2.UnchangedCount)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user