#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
@@ -0,0 +1,135 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
"lambdamail/protocols/internal/domain/entity"
|
||||
"lambdamail/protocols/internal/infrastructure/acme"
|
||||
"lambdamail/protocols/internal/infrastructure/cloudflare"
|
||||
"lambdamail/protocols/internal/infrastructure/postgres"
|
||||
"lambdamail/protocols/internal/infrastructure/vault"
|
||||
)
|
||||
|
||||
// startAcmeManager brings up mode B of PLAN.md section 8.3.
|
||||
func startAcmeManager(ctx context.Context, cfg config, pool *pgxpool.Pool, secretVault *vault.SecretVault) (port.CertProvider, error) {
|
||||
directoryURL := cfg.AcmeDirectoryURL
|
||||
if directoryURL == "" {
|
||||
directoryURL = acme.LetsEncryptProduction
|
||||
}
|
||||
|
||||
store := postgres.NewAcmeRepository(pool, secretVault, directoryURL)
|
||||
issuer := acme.NewIssuer(store, cfg.AcmeEmail, directoryURL, cfg.CloudflareToken)
|
||||
|
||||
publisher := &tlsaPublisher{
|
||||
adapter: cloudflare.NewCloudflareAdapter(cfg.CloudflareToken),
|
||||
domain: cfg.domain(),
|
||||
}
|
||||
|
||||
// The hosts that need a certificate are the mail host itself plus the two
|
||||
// service names of PLAN.md section 7.3, which have to present a valid
|
||||
// certificate for MTA-STS and autoconfig to work at all.
|
||||
extraHosts := []string{
|
||||
"mta-sts." + cfg.domain(),
|
||||
"autoconfig." + cfg.domain(),
|
||||
}
|
||||
|
||||
manager := acme.NewManager(issuer, store, store, publisher, cfg.PrimaryMailHost, extraHosts, cfg.OutboundDane)
|
||||
|
||||
// Reconciling every six hours is frequent enough for a 90 day certificate
|
||||
// and for the propagation waits the rollover schedules.
|
||||
if err := manager.Start(ctx, 6*time.Hour); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Printf("lambdamail-protocols managing its own certificate for %s (DANE: %v)", cfg.PrimaryMailHost, cfg.OutboundDane)
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
// tlsaPublisher writes the DANE associations the rollover decides on.
|
||||
//
|
||||
// It replaces the full set every time rather than computing a delta: the
|
||||
// rollover always hands over the complete desired state, and a delta would
|
||||
// risk removing an association that is still needed.
|
||||
type tlsaPublisher struct {
|
||||
adapter *cloudflare.CloudflareAdapter
|
||||
domain string
|
||||
}
|
||||
|
||||
func (p *tlsaPublisher) PublishTlsa(ctx context.Context, mailHost string, digests []string) error {
|
||||
if len(digests) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
zoneID, err := p.adapter.GetZoneID(ctx, p.domain)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve zone for TLSA publication: %w", err)
|
||||
}
|
||||
|
||||
existing, err := p.adapter.ListRecords(ctx, zoneID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list records for TLSA publication: %w", err)
|
||||
}
|
||||
|
||||
name := fmt.Sprintf("_25._tcp.%s", mailHost)
|
||||
desired := map[string]bool{}
|
||||
for _, digest := range digests {
|
||||
desired[fmt.Sprintf("3 1 1 %s", digest)] = true
|
||||
}
|
||||
|
||||
// Create the ones that are missing before removing anything, so there is
|
||||
// never a moment with fewer associations published than required.
|
||||
published := map[string]string{}
|
||||
for _, record := range existing {
|
||||
if record.Type == "TLSA" && equalFoldTrim(record.Name, name) {
|
||||
published[record.Value] = record.ID
|
||||
}
|
||||
}
|
||||
|
||||
for value := range desired {
|
||||
if _, alreadyThere := published[value]; alreadyThere {
|
||||
continue
|
||||
}
|
||||
record := entityTlsaRecord(name, value)
|
||||
if err := p.adapter.CreateRecord(ctx, zoneID, record); err != nil {
|
||||
return fmt.Errorf("publish TLSA %s: %w", value, err)
|
||||
}
|
||||
log.Printf("acme: published TLSA association %s", value)
|
||||
}
|
||||
|
||||
for value, id := range published {
|
||||
if desired[value] {
|
||||
continue
|
||||
}
|
||||
if err := p.adapter.DeleteRecord(ctx, zoneID, id); err != nil {
|
||||
// A stale association is harmless to delivery - it only means one
|
||||
// extra candidate a validator may match - so this is logged
|
||||
// rather than treated as a failure.
|
||||
log.Printf("acme: could not remove the retired TLSA association %s: %v", value, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("acme: retired TLSA association %s", value)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func entityTlsaRecord(name, value string) entity.DnsRecord {
|
||||
return entity.DnsRecord{
|
||||
Type: "TLSA",
|
||||
Name: name,
|
||||
Value: value,
|
||||
TTL: 1,
|
||||
Comment: "LambdaMail DANE TLSA Record",
|
||||
}
|
||||
}
|
||||
|
||||
func equalFoldTrim(a, b string) bool {
|
||||
return strings.EqualFold(strings.TrimSuffix(a, "."), strings.TrimSuffix(b, "."))
|
||||
}
|
||||
Reference in new issue
Block a user