#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, "."))
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// config is the resolved environment of PLAN.md section 13.
|
||||
type config struct {
|
||||
DatabaseURL string
|
||||
PrimaryMailHost string
|
||||
MailDomain string
|
||||
SpoolDir string
|
||||
|
||||
MasterKey string
|
||||
|
||||
TLSMode string
|
||||
TraefikAcmeDir string
|
||||
TraefikAcmeFile string
|
||||
CertPollInterval time.Duration
|
||||
StaticCertPath string
|
||||
StaticCertKeyPath string
|
||||
|
||||
RspamdURL string
|
||||
ClamavAddr string
|
||||
|
||||
MtaStsMode string
|
||||
OutboundDane bool
|
||||
OutboundMtaSts bool
|
||||
DnssecResolver string
|
||||
MaxMessageBytes int64
|
||||
|
||||
RelayHost string
|
||||
RelayPort int
|
||||
RelayUser string
|
||||
RelayPass string
|
||||
|
||||
// ArcSealEnabled adds an ARC set to accepted messages. It is off by
|
||||
// default because it only earns its keep on a host that forwards mail
|
||||
// onward; sealing mail we are the final destination for changes the
|
||||
// stored message for no benefit (PLAN.md section 5).
|
||||
ArcSealEnabled bool
|
||||
|
||||
// AcmeEmail and AcmeDirectoryURL configure mode B (PLAN.md section 8.3).
|
||||
AcmeEmail string
|
||||
AcmeDirectoryURL string
|
||||
|
||||
CloudflareToken string
|
||||
PublicIPv4 string
|
||||
PublicIPv6 string
|
||||
DnsSyncInterval time.Duration
|
||||
}
|
||||
|
||||
func loadConfig() config {
|
||||
return config{
|
||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||
PrimaryMailHost: envOrDefault("PRIMARY_MAIL_HOST", "mail.localhost"),
|
||||
MailDomain: os.Getenv("MAIL_DOMAIN"),
|
||||
SpoolDir: envOrDefault("PROTOCOLS_SPOOL_DIR", "/var/lambdamail/spool"),
|
||||
|
||||
MasterKey: os.Getenv("LAMBDAMAIL_MASTER_KEY"),
|
||||
|
||||
TLSMode: strings.ToLower(envOrDefault("TLS_MODE", "traefik")),
|
||||
TraefikAcmeDir: os.Getenv("TRAEFIK_ACME_DIR"),
|
||||
TraefikAcmeFile: envOrDefault("TRAEFIK_ACME_FILENAME", "acme.json"),
|
||||
CertPollInterval: time.Duration(envInt("CERT_POLL_INTERVAL_SECONDS", 60)) * time.Second,
|
||||
StaticCertPath: os.Getenv("PROTOCOLS_CERT_PATH"),
|
||||
StaticCertKeyPath: os.Getenv("PROTOCOLS_KEY_PATH"),
|
||||
|
||||
RspamdURL: os.Getenv("RSPAMD_URL"),
|
||||
ClamavAddr: os.Getenv("CLAMAV_ADDR"),
|
||||
|
||||
MtaStsMode: envOrDefault("MTA_STS_MODE", "testing"),
|
||||
// DANE stays off unless explicitly enabled: PLAN.md section 5.1
|
||||
// explains why publishing TLSA under Traefik-managed certificates
|
||||
// breaks mail sixty days later.
|
||||
OutboundDane: envBool("OUTBOUND_DANE_ENABLED", false),
|
||||
OutboundMtaSts: envBool("OUTBOUND_MTA_STS_ENABLED", true),
|
||||
DnssecResolver: envOrDefault("DNSSEC_RESOLVER", "1.1.1.1:53"),
|
||||
MaxMessageBytes: int64(envInt("MAX_MESSAGE_BYTES", 52428800)),
|
||||
|
||||
RelayHost: os.Getenv("RELAY_HOST"),
|
||||
RelayPort: envInt("RELAY_PORT", 587),
|
||||
RelayUser: os.Getenv("RELAY_USER"),
|
||||
RelayPass: os.Getenv("RELAY_PASS"),
|
||||
|
||||
ArcSealEnabled: envBool("ARC_SEAL_ENABLED", false),
|
||||
|
||||
AcmeEmail: os.Getenv("ACME_EMAIL"),
|
||||
AcmeDirectoryURL: os.Getenv("ACME_DIRECTORY_URL"),
|
||||
|
||||
CloudflareToken: os.Getenv("CLOUDFLARE_API_TOKEN"),
|
||||
PublicIPv4: os.Getenv("PUBLIC_IPV4"),
|
||||
PublicIPv6: os.Getenv("PUBLIC_IPV6"),
|
||||
// PLAN.md section 7.5 reconciles every six hours to detect drift.
|
||||
DnsSyncInterval: time.Duration(envInt("DNS_SYNC_INTERVAL_SECONDS", 6*60*60)) * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// domainOfMailHost derives the mail domain from the mail host when it was not
|
||||
// configured explicitly: "mail.example.com" yields "example.com".
|
||||
func (c config) domain() string {
|
||||
if c.MailDomain != "" {
|
||||
return c.MailDomain
|
||||
}
|
||||
if _, rest, found := strings.Cut(c.PrimaryMailHost, "."); found {
|
||||
return rest
|
||||
}
|
||||
return c.PrimaryMailHost
|
||||
}
|
||||
|
||||
func envOrDefault(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envInt(key string, fallback int) int {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if parsed, err := strconv.Atoi(v); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envBool(key string, fallback bool) bool {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if parsed, err := strconv.ParseBool(v); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build !unix
|
||||
|
||||
package main
|
||||
|
||||
import "errors"
|
||||
|
||||
// freeDiskPercent has no portable implementation outside unix. Returning an
|
||||
// error makes the preflight report the check as skipped rather than inventing
|
||||
// a number.
|
||||
func freeDiskPercent(string) (float64, error) {
|
||||
return 0, errors.New("free disk space check is only implemented on unix")
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//go:build unix
|
||||
|
||||
package main
|
||||
|
||||
import "syscall"
|
||||
|
||||
// freeDiskPercent reports the free space on the filesystem holding path.
|
||||
// PLAN.md section 15 treats a nearly full spool as a medium-severity finding:
|
||||
// once it fills, accepted mail cannot be written and has to be refused.
|
||||
func freeDiskPercent(path string) (float64, error) {
|
||||
var stat syscall.Statfs_t
|
||||
if err := syscall.Statfs(path, &stat); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
total := stat.Blocks
|
||||
if total == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
// Bavail rather than Bfree: the blocks reserved for root are not usable
|
||||
// by this process.
|
||||
return float64(stat.Bavail) / float64(total) * 100, nil
|
||||
}
|
||||
@@ -1,86 +1,430 @@
|
||||
// Command lambdamail-protocols is the entry point for the protocols service.
|
||||
//
|
||||
// Subcommands:
|
||||
//
|
||||
// (none) run every protocol listener
|
||||
// preflight run the environment checks of PLAN.md section 15 and exit
|
||||
// healthcheck probe the local HTTP health endpoint and exit
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-imap/v2"
|
||||
"github.com/emersion/go-imap/v2/imapserver"
|
||||
gosmtp "github.com/emersion/go-smtp"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
"lambdamail/protocols/internal/application/usecase"
|
||||
"lambdamail/protocols/internal/domain/valueobject"
|
||||
"lambdamail/protocols/internal/infrastructure/arc"
|
||||
"lambdamail/protocols/internal/infrastructure/clamav"
|
||||
"lambdamail/protocols/internal/infrastructure/cloudflare"
|
||||
"lambdamail/protocols/internal/infrastructure/diskstorage"
|
||||
"lambdamail/protocols/internal/infrastructure/dkim"
|
||||
"lambdamail/protocols/internal/infrastructure/mailauth"
|
||||
"lambdamail/protocols/internal/infrastructure/netdns"
|
||||
"lambdamail/protocols/internal/infrastructure/postgres"
|
||||
"lambdamail/protocols/internal/infrastructure/rspamd"
|
||||
tlsprovider "lambdamail/protocols/internal/infrastructure/tls"
|
||||
imappresentation "lambdamail/protocols/internal/presentation/imap"
|
||||
"lambdamail/protocols/internal/infrastructure/tlspolicy"
|
||||
"lambdamail/protocols/internal/infrastructure/vault"
|
||||
httppresentation "lambdamail/protocols/internal/presentation/http"
|
||||
imappresentation "lambdamail/protocols/internal/presentation/imap"
|
||||
managesievepresentation "lambdamail/protocols/internal/presentation/managesieve"
|
||||
pop3presentation "lambdamail/protocols/internal/presentation/pop3"
|
||||
smtppresentation "lambdamail/protocols/internal/presentation/smtp"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
cfg := loadConfig()
|
||||
|
||||
pool, err := postgres.NewPool(ctx, os.Getenv("DATABASE_URL"))
|
||||
if len(os.Args) > 1 {
|
||||
switch os.Args[1] {
|
||||
case "preflight":
|
||||
runPreflight(cfg)
|
||||
return
|
||||
case "healthcheck":
|
||||
runHealthcheck()
|
||||
return
|
||||
default:
|
||||
log.Fatalf("unknown subcommand %q (expected preflight or healthcheck)", os.Args[1])
|
||||
}
|
||||
}
|
||||
|
||||
run(cfg)
|
||||
}
|
||||
|
||||
func run(cfg config) {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
pool, err := postgres.NewPool(ctx, cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
log.Fatalf("open database pool: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
spoolDir := envOrDefault("PROTOCOLS_SPOOL_DIR", "/var/lambdamail/spool")
|
||||
if err := os.MkdirAll(spoolDir, 0o755); err != nil {
|
||||
log.Fatalf("create spool dir %s: %v", spoolDir, err)
|
||||
if err := os.MkdirAll(cfg.SpoolDir, 0o755); err != nil {
|
||||
log.Fatalf("create spool dir %s: %v", cfg.SpoolDir, err)
|
||||
}
|
||||
|
||||
secretVault, vaultErr := vault.New(cfg.MasterKey)
|
||||
certProvider := buildCertProvider(ctx, cfg, pool, secretVault, vaultErr)
|
||||
|
||||
// ---------------------------------------------------------------- repos
|
||||
mailboxes := postgres.NewMailboxRepository(pool)
|
||||
blobs := diskstorage.NewLocalDiskBlobStorage(pool, spoolDir)
|
||||
blobs := diskstorage.NewLocalDiskBlobStorage(pool, cfg.SpoolDir)
|
||||
blobReader := diskstorage.NewLocalDiskBlobReader(pool)
|
||||
messages := postgres.NewInboundMessageRepository(pool)
|
||||
useCase := usecase.NewProcessInboundEmailUseCase(mailboxes, blobs, messages)
|
||||
authRepo := postgres.NewAuthRepository(pool)
|
||||
imapFolders := postgres.NewImapFolderRepository(pool)
|
||||
messageQuery := postgres.NewMessageQueryRepository(pool)
|
||||
flagRepo := postgres.NewFlagRepository(pool)
|
||||
expungeRepo := postgres.NewExpungeRepository(pool)
|
||||
copyRepo := postgres.NewCopyRepository(pool)
|
||||
sieveRepo := postgres.NewSieveRepository(pool)
|
||||
reportRepo := postgres.NewReportRepository(pool)
|
||||
outboundRepo := postgres.NewOutboundRepository(pool)
|
||||
aliasRepo := postgres.NewAliasRepository(pool)
|
||||
|
||||
clamavAddr := os.Getenv("CLAMAV_ADDR")
|
||||
rspamdURL := os.Getenv("RSPAMD_URL")
|
||||
if clamavAddr != "" || rspamdURL != "" {
|
||||
var scanners []port.ContentScanner
|
||||
if clamavAddr != "" {
|
||||
scanners = append(scanners, clamav.NewClamAVAdapter(clamavAddr))
|
||||
}
|
||||
if rspamdURL != "" {
|
||||
scanners = append(scanners, rspamd.NewRspamdAdapter(rspamdURL))
|
||||
}
|
||||
pipeline := usecase.NewScanningPipeline(scanners...)
|
||||
useCase.SetScanner(pipeline)
|
||||
log.Printf("lambdamail-protocols antispam/antivirus scanner enabled (clamav: %s, rspamd: %s)", clamavAddr, rspamdURL)
|
||||
// The vault is required for DKIM: without it the private keys could not
|
||||
// be stored, and unsigned outbound mail fails the DMARC alignment that
|
||||
// PLAN.md section 5 depends on.
|
||||
var dkimRepo *postgres.DkimRepository
|
||||
if vaultErr != nil {
|
||||
log.Printf("WARNING: %v - DKIM signing is disabled, outbound mail will not be signed", vaultErr)
|
||||
} else {
|
||||
dkimRepo = postgres.NewDkimRepository(pool, secretVault)
|
||||
}
|
||||
|
||||
certProvider, err := tlsprovider.NewLocalFileCertProvider(
|
||||
os.Getenv("PROTOCOLS_CERT_PATH"),
|
||||
os.Getenv("PROTOCOLS_KEY_PATH"),
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("WARNING: could not load configured TLS certificate (%v) - falling back to an ephemeral self-signed certificate; this is degraded mode, not suitable for real deployments", err)
|
||||
certProvider, err = tlsprovider.NewEphemeralSelfSignedCertProvider()
|
||||
// ------------------------------------------------------------- inbound
|
||||
inboundUC := usecase.NewProcessInboundEmailUseCase(mailboxes, blobs, messages)
|
||||
inboundUC.SetAuthenticator(mailauth.NewAuthenticator(cfg.PrimaryMailHost))
|
||||
|
||||
if scanner := buildScanner(cfg); scanner != nil {
|
||||
inboundUC.SetScanner(scanner)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ outbound
|
||||
var signer port.DkimSigner
|
||||
if dkimRepo != nil {
|
||||
signer = dkim.NewSigner(dkimRepo)
|
||||
}
|
||||
submissionUC := usecase.NewProcessOutboundEmailUseCase(authRepo, outboundRepo, blobs, signer)
|
||||
|
||||
mxResolver := netdns.NewNetMXResolver()
|
||||
outboundWorker := usecase.NewOutboundWorkerUseCase(outboundRepo, mxResolver, blobReader, inboundUC, mailboxes, cfg.PrimaryMailHost)
|
||||
outboundWorker.SetPolicyResolver(buildPolicyResolver(cfg))
|
||||
if relay := relayConfig(cfg); relay.Configured() {
|
||||
outboundWorker.SetRelay(relay)
|
||||
log.Printf("lambdamail-protocols delivering through the smarthost %s", relay.Address())
|
||||
}
|
||||
|
||||
// ARC needs a signing key, so it can only be enabled where DKIM is.
|
||||
if cfg.ArcSealEnabled && dkimRepo != nil {
|
||||
if sealer := buildArcSealer(ctx, cfg, dkimRepo); sealer != nil {
|
||||
inboundUC.SetArcSealer(sealer)
|
||||
log.Printf("lambdamail-protocols ARC sealing enabled for %s", cfg.domain())
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------- access UCs
|
||||
imapUseCase := usecase.NewImapSessionUseCase(authRepo, imapFolders, messageQuery, flagRepo, blobReader, expungeRepo, copyRepo)
|
||||
inboundUC.SetTrackerManager(imapUseCase.GetTrackerManager(), imapFolders)
|
||||
pop3UseCase := usecase.NewPop3SessionUseCase(authRepo, imapFolders, messageQuery, blobReader, expungeRepo)
|
||||
sieveUseCase := usecase.NewManageSieveSessionUseCase(authRepo, sieveRepo)
|
||||
|
||||
// ----------------------------------------------------------- listeners
|
||||
startSMTPListeners(cfg, certProvider, inboundUC, submissionUC)
|
||||
startIMAPListeners(cfg, certProvider, imapUseCase)
|
||||
startPOP3Listeners(certProvider, pop3UseCase)
|
||||
startManageSieveListener(certProvider, sieveUseCase)
|
||||
|
||||
// ------------------------------------------------------- background jobs
|
||||
go runDeliveryWorker(ctx, outboundWorker)
|
||||
if cfg.CloudflareToken != "" {
|
||||
go runDnsReconciler(ctx, cfg, aliasRepo, dkimRepo)
|
||||
} else {
|
||||
log.Printf("CLOUDFLARE_API_TOKEN is not set: DNS reconciliation is disabled")
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- HTTP API
|
||||
router := httppresentation.NewRouter(usecase.NewIngestReportsUseCase(reportRepo), func() error { return pool.Ping(ctx) })
|
||||
applyMtaStsMode(router, cfg)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: ":8080",
|
||||
Handler: router,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
_ = server.Shutdown(shutdownCtx)
|
||||
}()
|
||||
|
||||
log.Printf("lambdamail-protocols HTTP service listening on %s", server.Addr)
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("http serve: %v", err)
|
||||
}
|
||||
log.Printf("lambdamail-protocols shut down")
|
||||
}
|
||||
|
||||
// buildCertProvider picks the certificate source. Mode "traefik" reads the
|
||||
// store Traefik maintains and keeps watching it, because the certificate is
|
||||
// replaced every sixty days and a stale one takes every listener down
|
||||
// (PLAN.md section 8).
|
||||
func buildCertProvider(ctx context.Context, cfg config, pool *pgxpool.Pool, secretVault *vault.SecretVault, vaultErr error) port.CertProvider {
|
||||
// Mode B: LambdaMail obtains the certificate itself. This is the only
|
||||
// mode DANE can run in, because it is the only one where the key for the
|
||||
// next certificate exists before that certificate does
|
||||
// (PLAN.md sections 5.1 and 8.3).
|
||||
if cfg.TLSMode == "acme" {
|
||||
if vaultErr != nil {
|
||||
log.Fatalf("TLS_MODE=acme needs LAMBDAMAIL_MASTER_KEY to seal the account and certificate keys: %v", vaultErr)
|
||||
}
|
||||
if cfg.CloudflareToken == "" {
|
||||
log.Fatalf("TLS_MODE=acme needs CLOUDFLARE_API_TOKEN: the DNS-01 challenge is answered through the Cloudflare API")
|
||||
}
|
||||
|
||||
provider, err := startAcmeManager(ctx, cfg, pool, secretVault)
|
||||
if err != nil {
|
||||
log.Fatalf("generate fallback self-signed certificate: %v", err)
|
||||
log.Fatalf("start self-managed ACME: %v", err)
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
if cfg.TraefikAcmeDir != "" {
|
||||
watcher, err := tlsprovider.NewAcmeCertWatcher(cfg.TraefikAcmeDir, cfg.TraefikAcmeFile, cfg.PrimaryMailHost)
|
||||
if err == nil {
|
||||
if !watcher.HasCertificateFor(cfg.PrimaryMailHost) {
|
||||
log.Printf("CRITICAL: the ACME store has no certificate for %s - declare a Traefik router for that host so ACME issues one (PLAN.md section 8.2)", cfg.PrimaryMailHost)
|
||||
}
|
||||
go watcher.Watch(ctx.Done(), cfg.CertPollInterval)
|
||||
log.Printf("lambdamail-protocols reading TLS certificates from %s (polling every %s)", cfg.TraefikAcmeDir, cfg.CertPollInterval)
|
||||
return watcher
|
||||
}
|
||||
log.Printf("WARNING: could not read the ACME store (%v) - falling back", err)
|
||||
}
|
||||
|
||||
if cfg.StaticCertPath != "" {
|
||||
if provider, err := tlsprovider.NewLocalFileCertProvider(cfg.StaticCertPath, cfg.StaticCertKeyPath); err == nil {
|
||||
return provider
|
||||
} else {
|
||||
log.Printf("WARNING: could not load the configured TLS certificate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
backend := smtppresentation.NewBackend(useCase)
|
||||
smtpServer := gosmtp.NewServer(backend)
|
||||
smtpServer.Addr = ":25"
|
||||
smtpServer.Domain = envOrDefault("PRIMARY_MAIL_HOST", "mail.localhost")
|
||||
smtpServer.MaxMessageBytes = 52428800 // 50 MB default; per-domain limit is a future iteration
|
||||
smtpServer.MaxRecipients = 100
|
||||
smtpServer.TLSConfig = &tls.Config{
|
||||
log.Printf("CRITICAL: falling back to an ephemeral self-signed certificate; this is degraded mode, not suitable for a real deployment")
|
||||
provider, err := tlsprovider.NewEphemeralSelfSignedCertProvider()
|
||||
if err != nil {
|
||||
log.Fatalf("generate fallback self-signed certificate: %v", err)
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
func relayConfig(cfg config) usecase.RelayConfig {
|
||||
return usecase.RelayConfig{
|
||||
Host: cfg.RelayHost,
|
||||
Port: cfg.RelayPort,
|
||||
Username: cfg.RelayUser,
|
||||
Password: cfg.RelayPass,
|
||||
}
|
||||
}
|
||||
|
||||
// buildArcSealer signs ARC sets with the domain's RSA DKIM key, which is what
|
||||
// a verifier resolves through the same selector.
|
||||
func buildArcSealer(ctx context.Context, cfg config, keys port.DkimKeyRepository) port.ArcSealer {
|
||||
domain := cfg.domain()
|
||||
|
||||
active, err := keys.FindActiveKeys(ctx, domain)
|
||||
if err != nil || len(active) == 0 {
|
||||
log.Printf("WARNING: ARC sealing requested but no DKIM key is active for %s yet; it will start once one is provisioned", domain)
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, key := range active {
|
||||
signer, err := dkim.ParsePrivateKey(key.PrivateKeyPEM)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
return arc.NewSealer(domain, key.Selector, cfg.PrimaryMailHost, signer)
|
||||
}
|
||||
|
||||
log.Printf("WARNING: none of the DKIM keys for %s could be used for ARC", domain)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildScanner(cfg config) port.ContentScanner {
|
||||
var scanners []port.ContentScanner
|
||||
if cfg.ClamavAddr != "" {
|
||||
scanners = append(scanners, clamav.NewClamAVAdapter(cfg.ClamavAddr))
|
||||
}
|
||||
if cfg.RspamdURL != "" {
|
||||
scanners = append(scanners, rspamd.NewRspamdAdapter(cfg.RspamdURL))
|
||||
}
|
||||
if len(scanners) == 0 {
|
||||
return nil
|
||||
}
|
||||
log.Printf("lambdamail-protocols content scanning enabled (clamav: %q, rspamd: %q)", cfg.ClamavAddr, cfg.RspamdURL)
|
||||
return usecase.NewScanningPipeline(scanners...)
|
||||
}
|
||||
|
||||
// buildPolicyResolver assembles the outbound transport security policy of
|
||||
// PLAN.md section 6.2.
|
||||
func buildPolicyResolver(cfg config) port.TLSPolicyResolver {
|
||||
// With a smarthost the relay takes responsibility for transport security
|
||||
// (PLAN.md section 10.4), so applying DANE and MTA-STS here would only
|
||||
// evaluate policies against the wrong hop.
|
||||
if cfg.RelayHost != "" {
|
||||
log.Printf("lambdamail-protocols relaying through %s: DANE and MTA-STS are handled by the relay", cfg.RelayHost)
|
||||
return tlspolicy.NewResolver(tlspolicy.WithMtaSts(false), tlspolicy.WithDane(false, nil))
|
||||
}
|
||||
|
||||
options := []tlspolicy.Option{tlspolicy.WithMtaSts(cfg.OutboundMtaSts)}
|
||||
if cfg.OutboundDane {
|
||||
options = append(options, tlspolicy.WithDane(true, netdns.NewTlsaResolver(cfg.DnssecResolver)))
|
||||
}
|
||||
|
||||
log.Printf("lambdamail-protocols outbound TLS policy: dane=%v mta-sts=%v", cfg.OutboundDane, cfg.OutboundMtaSts)
|
||||
return tlspolicy.NewResolver(options...)
|
||||
}
|
||||
|
||||
func applyMtaStsMode(router *httppresentation.Router, cfg config) {
|
||||
mode := valueobject.MtaStsMode(cfg.MtaStsMode)
|
||||
switch mode {
|
||||
case valueobject.MtaStsModeTesting, valueobject.MtaStsModeEnforce, valueobject.MtaStsModeNone:
|
||||
router.SetMtaStsMode(mode)
|
||||
log.Printf("lambdamail-protocols MTA-STS policy mode: %s", mode)
|
||||
default:
|
||||
log.Printf("WARNING: ignoring unknown MTA_STS_MODE %q, serving %q", mode, valueobject.MtaStsModeTesting)
|
||||
}
|
||||
}
|
||||
|
||||
// startSMTPListeners brings up the three SMTP ports of PLAN.md section 4. The
|
||||
// MX port and the submission ports run different backends: port 25 never
|
||||
// authenticates and only accepts local mail, while submission always
|
||||
// authenticates and relays outward.
|
||||
func startSMTPListeners(cfg config, certProvider port.CertProvider, inboundUC *usecase.ProcessInboundEmailUseCase, submissionUC *usecase.ProcessOutboundEmailUseCase) {
|
||||
tlsConfig := mailTLSConfig(certProvider)
|
||||
|
||||
// Port 25: STARTTLS is always advertised but never required. RFC 5321
|
||||
// obliges an MX to accept cleartext, and refusing it loses legitimate
|
||||
// mail without improving any audit score (PLAN.md section 4).
|
||||
mx := gosmtp.NewServer(smtppresentation.NewBackend(inboundUC))
|
||||
mx.Addr = ":25"
|
||||
mx.Domain = cfg.PrimaryMailHost
|
||||
mx.MaxMessageBytes = cfg.MaxMessageBytes
|
||||
mx.MaxRecipients = 100
|
||||
mx.ReadTimeout = 5 * time.Minute
|
||||
mx.WriteTimeout = 5 * time.Minute
|
||||
mx.TLSConfig = tlsConfig
|
||||
serveInBackground("SMTP MX", ":25", mx.ListenAndServe)
|
||||
|
||||
// Port 587: submission over STARTTLS. AllowInsecureAuth stays false, so
|
||||
// go-smtp withholds the AUTH capability until the connection is
|
||||
// encrypted - credentials never travel in the clear.
|
||||
submission := gosmtp.NewServer(smtppresentation.NewSubmissionBackend(submissionUC))
|
||||
submission.Addr = ":587"
|
||||
submission.Domain = cfg.PrimaryMailHost
|
||||
submission.MaxMessageBytes = cfg.MaxMessageBytes
|
||||
submission.MaxRecipients = 100
|
||||
submission.ReadTimeout = 5 * time.Minute
|
||||
submission.WriteTimeout = 5 * time.Minute
|
||||
submission.TLSConfig = tlsConfig
|
||||
submission.AllowInsecureAuth = false
|
||||
serveInBackground("SMTP submission", ":587", submission.ListenAndServe)
|
||||
|
||||
// Port 465: implicit TLS (RFC 8314), the recommended submission port.
|
||||
submissions := gosmtp.NewServer(smtppresentation.NewSubmissionBackend(submissionUC))
|
||||
submissions.Addr = ":465"
|
||||
submissions.Domain = cfg.PrimaryMailHost
|
||||
submissions.MaxMessageBytes = cfg.MaxMessageBytes
|
||||
submissions.MaxRecipients = 100
|
||||
submissions.ReadTimeout = 5 * time.Minute
|
||||
submissions.WriteTimeout = 5 * time.Minute
|
||||
submissions.TLSConfig = tlsConfig
|
||||
serveInBackground("SMTPS submission", ":465", submissions.ListenAndServeTLS)
|
||||
}
|
||||
|
||||
func startIMAPListeners(cfg config, certProvider port.CertProvider, imapUseCase *usecase.ImapSessionUseCase) {
|
||||
newServer := func() *imapserver.Server {
|
||||
return imapserver.New(&imapserver.Options{
|
||||
NewSession: func(c *imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) {
|
||||
return imappresentation.NewSession(c, imapUseCase)
|
||||
},
|
||||
Caps: imap.CapSet{
|
||||
imap.CapIMAP4rev1: {},
|
||||
imap.CapMove: {},
|
||||
imap.CapUIDPlus: {},
|
||||
imap.CapIdle: {},
|
||||
imap.CapCondStore: {},
|
||||
},
|
||||
TLSConfig: mailTLSConfig(certProvider),
|
||||
// LOGINDISABLED is advertised before TLS: the client must
|
||||
// upgrade before it may send credentials (PLAN.md section 4).
|
||||
InsecureAuth: false,
|
||||
})
|
||||
}
|
||||
|
||||
serveInBackground("IMAP", ":143", func() error { return newServer().ListenAndServe(":143") })
|
||||
serveInBackground("IMAPS", ":993", func() error {
|
||||
return serveImplicitTLS(":993", mailTLSConfig(certProvider), newServer().Serve)
|
||||
})
|
||||
}
|
||||
|
||||
func startPOP3Listeners(certProvider port.CertProvider, pop3UseCase *usecase.Pop3SessionUseCase) {
|
||||
serveInBackground("POP3", ":110", pop3presentation.NewServer(":110", pop3UseCase, certProvider).ListenAndServe)
|
||||
serveInBackground("POP3S", ":995", func() error {
|
||||
server := pop3presentation.NewServer(":995", pop3UseCase, certProvider)
|
||||
return serveImplicitTLS(":995", mailTLSConfig(certProvider), server.Serve)
|
||||
})
|
||||
}
|
||||
|
||||
func startManageSieveListener(certProvider port.CertProvider, sieveUseCase *usecase.ManageSieveSessionUseCase) {
|
||||
serveInBackground("ManageSieve", ":4190", managesievepresentation.NewServer(":4190", sieveUseCase, certProvider).ListenAndServe)
|
||||
}
|
||||
|
||||
// serveImplicitTLS wraps a plain listener in TLS, which is what the implicit
|
||||
// ports (465, 993, 995) need: the handshake happens before any protocol
|
||||
// greeting rather than after a STARTTLS command.
|
||||
func serveImplicitTLS(addr string, tlsConfig *tls.Config, serve func(net.Listener) error) error {
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return serve(tls.NewListener(ln, tlsConfig))
|
||||
}
|
||||
|
||||
// serveInBackground runs a listener and logs a fatal error if it dies. A
|
||||
// protocol port that silently fails to bind would look healthy while being
|
||||
// unreachable, so failure is loud.
|
||||
func serveInBackground(name, addr string, serve func() error) {
|
||||
go func() {
|
||||
log.Printf("lambdamail-protocols %s listening on %s", name, addr)
|
||||
if err := serve(); err != nil {
|
||||
log.Fatalf("%s serve on %s: %v", name, addr, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// mailTLSConfig is the cipher policy of PLAN.md section 4: TLS 1.2 floor,
|
||||
// forward secrecy only, and the three curves named there.
|
||||
func mailTLSConfig(certProvider port.CertProvider) *tls.Config {
|
||||
return &tls.Config{
|
||||
GetCertificate: certProvider.GetCertificate,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
CipherSuites: []uint16{
|
||||
@@ -93,99 +437,120 @@ func main() {
|
||||
},
|
||||
CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256, tls.CurveP384},
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("lambdamail-protocols SMTP listening on %s", smtpServer.Addr)
|
||||
if err := smtpServer.ListenAndServe(); err != nil {
|
||||
log.Fatalf("smtp serve: %v", err)
|
||||
}
|
||||
}()
|
||||
func runDeliveryWorker(ctx context.Context, worker *usecase.OutboundWorkerUseCase) {
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
authRepo := postgres.NewAuthRepository(pool)
|
||||
imapFolders := postgres.NewImapFolderRepository(pool)
|
||||
messageQuery := postgres.NewMessageQueryRepository(pool)
|
||||
flagRepo := postgres.NewFlagRepository(pool)
|
||||
blobReader := diskstorage.NewLocalDiskBlobReader(pool)
|
||||
expungeRepo := postgres.NewExpungeRepository(pool)
|
||||
copyRepo := postgres.NewCopyRepository(pool)
|
||||
imapUseCase := usecase.NewImapSessionUseCase(authRepo, imapFolders, messageQuery, flagRepo, blobReader, expungeRepo, copyRepo)
|
||||
useCase.SetTrackerManager(imapUseCase.GetTrackerManager(), imapFolders)
|
||||
|
||||
imapServer := imapserver.New(&imapserver.Options{
|
||||
NewSession: func(c *imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) {
|
||||
return imappresentation.NewSession(c, imapUseCase)
|
||||
},
|
||||
Caps: imap.CapSet{
|
||||
imap.CapIMAP4rev1: {},
|
||||
imap.CapMove: {},
|
||||
imap.CapUIDPlus: {},
|
||||
imap.CapIdle: {},
|
||||
imap.CapCondStore: {},
|
||||
},
|
||||
TLSConfig: &tls.Config{GetCertificate: certProvider.GetCertificate, MinVersion: tls.VersionTLS12},
|
||||
InsecureAuth: false,
|
||||
})
|
||||
|
||||
go func() {
|
||||
log.Printf("lambdamail-protocols IMAP listening on :143")
|
||||
if err := imapServer.ListenAndServe(":143"); err != nil {
|
||||
log.Fatalf("imap serve: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
pop3UseCase := usecase.NewPop3SessionUseCase(authRepo, imapFolders, messageQuery, blobReader, expungeRepo)
|
||||
pop3Server := pop3presentation.NewServer(":110", pop3UseCase, certProvider)
|
||||
|
||||
go func() {
|
||||
log.Printf("lambdamail-protocols POP3 listening on :110")
|
||||
if err := pop3Server.ListenAndServe(); err != nil {
|
||||
log.Fatalf("pop3 serve: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
sieveRepo := postgres.NewSieveRepository(pool)
|
||||
sieveUseCase := usecase.NewManageSieveSessionUseCase(authRepo, sieveRepo)
|
||||
sieveServer := managesievepresentation.NewServer(":4190", sieveUseCase, certProvider)
|
||||
|
||||
go func() {
|
||||
log.Printf("lambdamail-protocols ManageSieve listening on :4190")
|
||||
if err := sieveServer.ListenAndServe(); err != nil {
|
||||
log.Fatalf("managesieve serve: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
reportRepo := postgres.NewReportRepository(pool)
|
||||
reportUseCase := usecase.NewIngestReportsUseCase(reportRepo)
|
||||
|
||||
outboundRepo := postgres.NewOutboundRepository(pool)
|
||||
mxResolver := netdns.NewNetMXResolver()
|
||||
outboundWorker := usecase.NewOutboundWorkerUseCase(outboundRepo, mxResolver, blobReader, useCase, mailboxes, smtpServer.Domain)
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
_, _ = outboundWorker.ProcessBatch(ctx, "protocols-worker-1", 10)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if _, err := worker.ProcessBatch(ctx, "protocols-worker-1", 10); err != nil {
|
||||
log.Printf("delivery worker batch failed: %v", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
router := httppresentation.NewRouter(reportUseCase, func() error { return pool.Ping(ctx) })
|
||||
|
||||
addr := ":8080"
|
||||
log.Printf("lambdamail-protocols HTTP service listening on %s", addr)
|
||||
if err := http.ListenAndServe(addr, router); err != nil {
|
||||
log.Fatalf("http serve: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func envOrDefault(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
// runDnsReconciler provisions the DKIM keys and reconciles the zone, then
|
||||
// repeats on the interval of PLAN.md section 7.5 to detect drift.
|
||||
func runDnsReconciler(ctx context.Context, cfg config, aliasRepo port.SystemAliasRepository, dkimRepo *postgres.DkimRepository) {
|
||||
syncUC := usecase.NewSyncDnsRecordsUseCase(cloudflare.NewCloudflareAdapter(cfg.CloudflareToken), aliasRepo)
|
||||
syncUC.SetVerifier(netdns.NewPublicVerifier())
|
||||
|
||||
var provisioner *usecase.ProvisionDkimKeysUseCase
|
||||
if dkimRepo != nil {
|
||||
provisioner = usecase.NewProvisionDkimKeysUseCase(dkimRepo, generateDkimKey)
|
||||
}
|
||||
|
||||
reconcile := func() {
|
||||
domain := cfg.domain()
|
||||
|
||||
input := usecase.SyncDnsRecordsInput{
|
||||
DomainName: domain,
|
||||
MailHost: cfg.PrimaryMailHost,
|
||||
ServerIPv4: cfg.PublicIPv4,
|
||||
ServerIPv6: cfg.PublicIPv6,
|
||||
DaneEnabled: cfg.OutboundDane,
|
||||
// A smarthost sends on the domain's behalf, so the hard-fail SPF
|
||||
// has to authorise it or every relayed message fails
|
||||
// (PLAN.md section 10.4).
|
||||
RelaySpfInclude: relayConfig(cfg).SpfInclude(),
|
||||
}
|
||||
|
||||
if provisioner != nil {
|
||||
keys, err := provisioner.Execute(ctx, domain)
|
||||
if err != nil {
|
||||
log.Printf("DNS reconcile: could not provision DKIM keys for %s: %v", domain, err)
|
||||
return
|
||||
}
|
||||
input.RsaDkimPubKey = keys.RsaPublicKey
|
||||
input.EdDkimPubKey = keys.Ed25519PublicKey
|
||||
if len(keys.Created) > 0 {
|
||||
log.Printf("DNS reconcile: generated DKIM keys for %s: %v", domain, keys.Created)
|
||||
}
|
||||
}
|
||||
|
||||
out, err := syncUC.Execute(ctx, input)
|
||||
if err != nil {
|
||||
log.Printf("DNS reconcile for %s failed: %v", domain, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("DNS reconcile for %s: %s (created=%d updated=%d unchanged=%d conflicts=%d)",
|
||||
domain, out.Status, out.CreatedCount, out.UpdatedCount, out.UnchangedCount, out.ConflictCount)
|
||||
|
||||
// Conflicts and unresolvable records are reported individually
|
||||
// because each needs a different operator action.
|
||||
for _, conflict := range out.Conflicts {
|
||||
log.Printf("DNS conflict (left untouched): %s", conflict)
|
||||
}
|
||||
for _, unverified := range out.Unverified {
|
||||
log.Printf("DNS record not yet visible to public resolvers: %s", unverified)
|
||||
}
|
||||
for _, e := range out.Errors {
|
||||
log.Printf("DNS reconcile error: %s", e)
|
||||
}
|
||||
}
|
||||
|
||||
reconcile()
|
||||
|
||||
ticker := time.NewTicker(cfg.DnsSyncInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
reconcile()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// generateDkimKey adapts the key generator to the use case's signature,
|
||||
// keeping the crypto in the infrastructure layer.
|
||||
func generateDkimKey(algorithm string) ([]byte, string, error) {
|
||||
generated, err := dkim.Generate(algorithm)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return generated.PrivateKeyPEM, generated.PublicKeyBase64, nil
|
||||
}
|
||||
|
||||
func runHealthcheck() {
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.Get("http://127.0.0.1:8080/health")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "healthcheck failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
fmt.Fprintf(os.Stderr, "healthcheck failed: HTTP %d\n", resp.StatusCode)
|
||||
os.Exit(1)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"lambdamail/protocols/internal/application/usecase"
|
||||
tlsprovider "lambdamail/protocols/internal/infrastructure/tls"
|
||||
)
|
||||
|
||||
// runPreflight executes the environment checks of PLAN.md section 15 and
|
||||
// exits non-zero when a critical one fails, so the deploy pipeline can gate
|
||||
// on it.
|
||||
func runPreflight(cfg config) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
report := usecase.NewPreflightUseCase(buildPreflightDeps(cfg)).Execute(ctx, usecase.PreflightInput{
|
||||
PrimaryMailHost: cfg.PrimaryMailHost,
|
||||
MailDomain: cfg.domain(),
|
||||
PublicIPv4: cfg.PublicIPv4,
|
||||
PublicIPv6: cfg.PublicIPv6,
|
||||
DaneEnabled: cfg.OutboundDane,
|
||||
TLSMode: cfg.TLSMode,
|
||||
SmarthostSet: cfg.RelayHost != "",
|
||||
})
|
||||
|
||||
fmt.Print(report)
|
||||
|
||||
if report.Blocking() {
|
||||
fmt.Fprintln(os.Stderr, "\npreflight FAILED: at least one critical check did not pass")
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("\npreflight passed")
|
||||
}
|
||||
|
||||
func buildPreflightDeps(cfg config) usecase.PreflightDeps {
|
||||
resolver := net.DefaultResolver
|
||||
|
||||
deps := usecase.PreflightDeps{
|
||||
DialPort25: func(ctx context.Context, address string) error {
|
||||
dialer := net.Dialer{Timeout: 10 * time.Second}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return conn.Close()
|
||||
},
|
||||
LookupAddr: resolver.LookupAddr,
|
||||
LookupHost: resolver.LookupHost,
|
||||
LookupTXT: resolver.LookupTXT,
|
||||
FetchMtaSts: func(ctx context.Context, domain string) error {
|
||||
url := fmt.Sprintf("https://mta-sts.%s/.well-known/mta-sts.txt", domain)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The certificate is validated on purpose: RFC 8461 section 3.3
|
||||
// requires the policy to be served under a valid certificate for
|
||||
// the mta-sts host, and a sender that cannot validate it ignores
|
||||
// the policy entirely.
|
||||
resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
FreeDiskPercent: func() (float64, error) { return freeDiskPercent(cfg.SpoolDir) },
|
||||
Now: time.Now,
|
||||
NetworkTime: httpDateTime,
|
||||
}
|
||||
|
||||
// The certificate check only means something when a store is configured;
|
||||
// otherwise it stays skipped rather than reporting a false pass.
|
||||
if cfg.TraefikAcmeDir != "" {
|
||||
if watcher, err := tlsprovider.NewAcmeCertWatcher(cfg.TraefikAcmeDir, cfg.TraefikAcmeFile, cfg.PrimaryMailHost); err == nil {
|
||||
deps.HasCertificateFor = watcher.HasCertificateFor
|
||||
}
|
||||
}
|
||||
|
||||
return deps
|
||||
}
|
||||
|
||||
// httpDateTime reads a trusted clock from the Date header of a well-known
|
||||
// HTTPS endpoint. It is not NTP, but it is accurate to about a second, which
|
||||
// is enough to catch the drift that breaks DKIM and TOTP.
|
||||
func httpDateTime(ctx context.Context) (time.Time, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodHead, "https://www.cloudflare.com/", nil)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
|
||||
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
dateHeader := resp.Header.Get("Date")
|
||||
if dateHeader == "" {
|
||||
return time.Time{}, fmt.Errorf("no Date header in the response")
|
||||
}
|
||||
return http.ParseTime(dateHeader)
|
||||
}
|
||||
Reference in new issue
Block a user