Files

136 lines
4.1 KiB
Go

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, "."))
}