259 lines
7.4 KiB
Go
259 lines
7.4 KiB
Go
package acme
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"log"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"lambdamail/protocols/internal/application/port"
|
|
"lambdamail/protocols/internal/application/usecase"
|
|
)
|
|
|
|
// TlsaPublisher publishes the DANE associations that must be live for the
|
|
// mail host. The rollover hands it the complete desired set, so applying it is
|
|
// idempotent.
|
|
type TlsaPublisher interface {
|
|
PublishTlsa(ctx context.Context, mailHost string, digests []string) error
|
|
}
|
|
|
|
// PendingKeyStore is the part of the ACME repository the rollover needs
|
|
// beyond the plain port: it tracks when an association was published.
|
|
type PendingKeyStore interface {
|
|
SavePendingKeyWithDigest(ctx context.Context, domain string, privateKeyPEM []byte, tlsaDigest string) error
|
|
PendingKeyDigest(ctx context.Context, domain string) (string, *time.Time, error)
|
|
MarkPendingKeyPublished(ctx context.Context, domain string) error
|
|
}
|
|
|
|
// Manager keeps the mail host's certificate current and, when DANE is on,
|
|
// drives the rollover so the published associations always cover whatever is
|
|
// being served.
|
|
type Manager struct {
|
|
issuer *Issuer
|
|
store port.CertificateStore
|
|
pending PendingKeyStore
|
|
rollover *usecase.RolloverTlsaUseCase
|
|
tlsa TlsaPublisher
|
|
|
|
mailHost string
|
|
extraHosts []string
|
|
daneEnabled bool
|
|
|
|
current atomic.Pointer[tls.Certificate]
|
|
}
|
|
|
|
func NewManager(
|
|
issuer *Issuer,
|
|
store port.CertificateStore,
|
|
pending PendingKeyStore,
|
|
tlsa TlsaPublisher,
|
|
mailHost string,
|
|
extraHosts []string,
|
|
daneEnabled bool,
|
|
) *Manager {
|
|
return &Manager{
|
|
issuer: issuer,
|
|
store: store,
|
|
pending: pending,
|
|
rollover: usecase.NewRolloverTlsaUseCase(usecase.DefaultRolloverConfig()),
|
|
tlsa: tlsa,
|
|
mailHost: mailHost,
|
|
extraHosts: extraHosts,
|
|
daneEnabled: daneEnabled,
|
|
}
|
|
}
|
|
|
|
// GetCertificate satisfies tls.Config.GetCertificate.
|
|
func (m *Manager) GetCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) {
|
|
cert := m.current.Load()
|
|
if cert == nil {
|
|
return nil, fmt.Errorf("acme: no certificate available for %s yet", m.mailHost)
|
|
}
|
|
return cert, nil
|
|
}
|
|
|
|
// Start loads whatever is stored, issues a certificate if there is none, and
|
|
// then keeps reconciling on an interval.
|
|
func (m *Manager) Start(ctx context.Context, interval time.Duration) error {
|
|
if interval <= 0 {
|
|
interval = 6 * time.Hour
|
|
}
|
|
|
|
if err := m.reconcile(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
go func() {
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
if err := m.reconcile(ctx); err != nil {
|
|
log.Printf("acme reconcile failed: %v", err)
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
// reconcile is the whole certificate lifecycle in one pass. It is written to
|
|
// be safe to run at any moment: each step only acts when its precondition
|
|
// holds, so an interrupted rollover simply resumes on the next tick.
|
|
func (m *Manager) reconcile(ctx context.Context) error {
|
|
stored, err := m.store.LoadCertificate(ctx, m.mailHost)
|
|
if err != nil {
|
|
return fmt.Errorf("load stored certificate: %w", err)
|
|
}
|
|
|
|
if stored != nil {
|
|
if err := m.serve(stored); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if !m.daneEnabled {
|
|
// Without DANE there is no association to coordinate with, so the
|
|
// only question is whether the certificate needs renewing.
|
|
if stored == nil || m.rollover.ShouldRenewNow(stored.NotAfter) {
|
|
return m.issue(ctx, nil, 1)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
return m.reconcileWithDane(ctx, stored)
|
|
}
|
|
|
|
func (m *Manager) reconcileWithDane(ctx context.Context, stored *port.StoredCertificate) error {
|
|
// A deployment with DANE and no certificate yet: issue against a
|
|
// pre-generated key and publish its association immediately. There is no
|
|
// old association to preserve, so no window to protect.
|
|
if stored == nil {
|
|
keyPEM, err := GeneratePrivateKey()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
digest, err := usecase.TlsaDigestForKey(keyPEM)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := m.tlsa.PublishTlsa(ctx, m.mailHost, []string{digest}); err != nil {
|
|
return fmt.Errorf("publish initial TLSA: %w", err)
|
|
}
|
|
return m.issue(ctx, keyPEM, 1)
|
|
}
|
|
|
|
currentDigest, err := usecase.TlsaDigestForCertificate(stored.CertificatePEM)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
pendingDigest, publishedAt, err := m.pending.PendingKeyDigest(ctx, m.mailHost)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
decision := m.rollover.Decide(ctx, usecase.RolloverState{
|
|
CurrentDigest: currentDigest,
|
|
NotAfter: stored.NotAfter,
|
|
PendingDigest: pendingDigest,
|
|
PendingPublishedAt: publishedAt,
|
|
CertificateMatchesPending: pendingDigest != "" && pendingDigest == currentDigest,
|
|
})
|
|
|
|
log.Printf("acme rollover for %s: %s (%s)", m.mailHost, decision.Stage, decision.Reason)
|
|
|
|
// The published set is always applied first. Publishing is additive and
|
|
// idempotent, so doing it before anything else means the associations are
|
|
// never behind the certificate.
|
|
if err := m.tlsa.PublishTlsa(ctx, m.mailHost, decision.Publish); err != nil {
|
|
return fmt.Errorf("publish TLSA set: %w", err)
|
|
}
|
|
|
|
switch decision.Stage {
|
|
case usecase.StagePublishNext:
|
|
if pendingDigest == "" {
|
|
// Generate the next key and publish its association. The
|
|
// certificate is not touched on this pass: the association has to
|
|
// propagate first.
|
|
keyPEM, err := GeneratePrivateKey()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
digest, err := usecase.TlsaDigestForKey(keyPEM)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := m.pending.SavePendingKeyWithDigest(ctx, m.mailHost, keyPEM, digest); err != nil {
|
|
return err
|
|
}
|
|
if err := m.tlsa.PublishTlsa(ctx, m.mailHost, []string{currentDigest, digest}); err != nil {
|
|
return fmt.Errorf("publish next TLSA: %w", err)
|
|
}
|
|
}
|
|
return m.pending.MarkPendingKeyPublished(ctx, m.mailHost)
|
|
|
|
case usecase.StageRenew:
|
|
keyPEM, err := m.store.LoadPendingKey(ctx, m.mailHost)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if keyPEM == nil {
|
|
return fmt.Errorf("acme: rollover reached RENEW with no pending key for %s", m.mailHost)
|
|
}
|
|
return m.issue(ctx, keyPEM, stored.KeyGeneration+1)
|
|
|
|
case usecase.StageStable:
|
|
// The rollover finished and the old association has been dropped, so
|
|
// the reserved key is no longer pending.
|
|
if pendingDigest != "" && pendingDigest == currentDigest {
|
|
return m.store.ClearPendingKey(ctx, m.mailHost)
|
|
}
|
|
return nil
|
|
|
|
default:
|
|
// AWAIT_PROPAGATION and CLEANUP are waits: the published set above is
|
|
// the entire action.
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// issue obtains a certificate and starts serving it.
|
|
func (m *Manager) issue(ctx context.Context, privateKeyPEM []byte, generation int) error {
|
|
domains := append([]string{m.mailHost}, m.extraHosts...)
|
|
|
|
issued, err := m.issuer.Obtain(ctx, domains, privateKeyPEM)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
issued.KeyGeneration = generation
|
|
|
|
if err := m.store.SaveCertificate(ctx, *issued); err != nil {
|
|
return fmt.Errorf("store issued certificate: %w", err)
|
|
}
|
|
|
|
log.Printf("acme issued a certificate for %s, valid until %s", strings.Join(domains, ", "), issued.NotAfter.Format(time.RFC3339))
|
|
return m.serve(issued)
|
|
}
|
|
|
|
// serve parses a stored certificate and swaps it in atomically, so in-flight
|
|
// handshakes are unaffected.
|
|
func (m *Manager) serve(stored *port.StoredCertificate) error {
|
|
pair, err := tls.X509KeyPair(stored.CertificatePEM, stored.PrivateKeyPEM)
|
|
if err != nil {
|
|
return fmt.Errorf("acme: stored certificate and key do not match: %w", err)
|
|
}
|
|
m.current.Store(&pair)
|
|
return nil
|
|
}
|
|
|
|
// Ensure port interface compliance
|
|
var _ port.CertProvider = (*Manager)(nil)
|