mirror of
https://github.com/pomerium/pomerium.git
synced 2025-05-02 03:46:29 +02:00
The `autocert_ca` and `autocert_email` options have been added to be able to configure CAs that support the ACME protocol as an alternative to Let's Encrypt. Fix ProtoBuf definition for additional autocert options Fix PR comments and add ACME EAB configuration Add configuration option for trusted CAs when talking ACME Fix linter issues copy edits render updated reference to docs Add test for autocert manager configuration Add tests for autocert configuration options Fix CI build issues Don't set empty acme.EAB struct if configuration not set Remove required email when setting custom CA When using a non-default CA it's no longer required to specify an email address. I required this before, because it seemed to cause an issue in which no certificate was issued. The root cause was something different, rendering the hard email requirement pointless. It's still beneficial to specify an email, though. I changed the text in the docs to explain that. Update generated docs Fix failing tests by recreation of a new ACMEManager The default ACMEManager object was reused in multiple tests, resulting in unexpected states when tests run in parallel. By using a new instance for every test, this is no longer an issue.
428 lines
11 KiB
Go
428 lines
11 KiB
Go
// Package autocert implements automatic management of TLS certificates.
|
|
package autocert
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"sort"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/caddyserver/certmagic"
|
|
"github.com/mholt/acmez/acme"
|
|
"github.com/rs/zerolog"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/pomerium/pomerium/config"
|
|
"github.com/pomerium/pomerium/internal/httputil"
|
|
"github.com/pomerium/pomerium/internal/log"
|
|
"github.com/pomerium/pomerium/internal/telemetry/metrics"
|
|
"github.com/pomerium/pomerium/pkg/cryptutil"
|
|
)
|
|
|
|
var (
|
|
errObtainCertFailed = errors.New("obtain cert failed")
|
|
errRenewCertFailed = errors.New("renew cert failed")
|
|
|
|
// RenewCert is not thread-safe
|
|
renewCertLock sync.Mutex
|
|
)
|
|
|
|
const (
|
|
ocspRespCacheSize = 50000
|
|
renewalInterval = time.Minute * 10
|
|
renewalTimeout = time.Hour
|
|
)
|
|
|
|
// Manager manages TLS certificates.
|
|
type Manager struct {
|
|
src config.Source
|
|
acmeTemplate certmagic.ACMEManager
|
|
|
|
mu sync.RWMutex
|
|
config *config.Config
|
|
certmagic *certmagic.Config
|
|
acmeMgr atomic.Value
|
|
srv *http.Server
|
|
|
|
*ocspCache
|
|
|
|
config.ChangeDispatcher
|
|
}
|
|
|
|
// New creates a new autocert manager.
|
|
func New(src config.Source) (*Manager, error) {
|
|
return newManager(context.Background(), src, certmagic.DefaultACME, renewalInterval)
|
|
}
|
|
|
|
func newManager(ctx context.Context,
|
|
src config.Source,
|
|
acmeTemplate certmagic.ACMEManager,
|
|
checkInterval time.Duration,
|
|
) (*Manager, error) {
|
|
ctx = log.WithContext(ctx, func(c zerolog.Context) zerolog.Context {
|
|
return c.Str("service", "autocert-manager")
|
|
})
|
|
|
|
ocspRespCache, err := newOCSPCache(ocspRespCacheSize)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
certmagicConfig := certmagic.NewDefault()
|
|
// set certmagic default storage cache, otherwise cert renewal loop will be based off
|
|
// certmagic's own default location
|
|
certmagicConfig.Storage = &certmagic.FileStorage{
|
|
Path: src.GetConfig().Options.AutocertOptions.Folder,
|
|
}
|
|
|
|
logger := log.ZapLogger().With(zap.String("service", "autocert"))
|
|
certmagicConfig.Logger = logger
|
|
acmeTemplate.Logger = logger
|
|
|
|
mgr := &Manager{
|
|
src: src,
|
|
acmeTemplate: acmeTemplate,
|
|
certmagic: certmagicConfig,
|
|
ocspCache: ocspRespCache,
|
|
}
|
|
err = mgr.update(ctx, src.GetConfig())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
mgr.src.OnConfigChange(ctx, func(ctx context.Context, cfg *config.Config) {
|
|
err := mgr.update(ctx, cfg)
|
|
if err != nil {
|
|
log.Error(ctx).Err(err).Msg("autocert: error updating config")
|
|
return
|
|
}
|
|
|
|
cfg = mgr.GetConfig()
|
|
mgr.Trigger(ctx, cfg)
|
|
})
|
|
go func() {
|
|
ticker := time.NewTicker(checkInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
err := mgr.renewConfigCerts(ctx)
|
|
if err != nil {
|
|
log.Error(ctx).Err(err).Msg("autocert: error updating config")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
return mgr, nil
|
|
}
|
|
|
|
func (mgr *Manager) getCertMagicConfig(cfg *config.Config) (*certmagic.Config, error) {
|
|
mgr.certmagic.MustStaple = cfg.Options.AutocertOptions.MustStaple
|
|
mgr.certmagic.OnDemand = nil // disable on-demand
|
|
mgr.certmagic.Storage = &certmagic.FileStorage{Path: cfg.Options.AutocertOptions.Folder}
|
|
certs, err := cfg.AllCertificates()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// add existing certs to the cache, and staple OCSP
|
|
for _, cert := range certs {
|
|
if err := mgr.certmagic.CacheUnmanagedTLSCertificate(cert, nil); err != nil {
|
|
return nil, fmt.Errorf("config: failed caching cert: %w", err)
|
|
}
|
|
}
|
|
acmeMgr := certmagic.NewACMEManager(mgr.certmagic, mgr.acmeTemplate)
|
|
err = configureCertificateAuthority(acmeMgr, cfg.Options.AutocertOptions)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
err = configureExternalAccountBinding(acmeMgr, cfg.Options.AutocertOptions)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
err = configureTrustedRoots(acmeMgr, cfg.Options.AutocertOptions)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
acmeMgr.DisableTLSALPNChallenge = true
|
|
mgr.certmagic.Issuers = []certmagic.Issuer{acmeMgr}
|
|
mgr.acmeMgr.Store(acmeMgr)
|
|
|
|
return mgr.certmagic, nil
|
|
}
|
|
|
|
func (mgr *Manager) renewConfigCerts(ctx context.Context) error {
|
|
ctx, cancel := context.WithTimeout(ctx, renewalTimeout)
|
|
defer cancel()
|
|
|
|
mgr.mu.Lock()
|
|
defer mgr.mu.Unlock()
|
|
|
|
cfg := mgr.config
|
|
cm, err := mgr.getCertMagicConfig(cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
needsReload := false
|
|
var renew, ocsp []string
|
|
log.Debug(ctx).Strs("domains", sourceHostnames(cfg)).Msg("checking domains")
|
|
for _, domain := range sourceHostnames(cfg) {
|
|
cert, err := cm.CacheManagedCertificate(domain)
|
|
if err != nil {
|
|
// this happens for unmanaged certificates
|
|
continue
|
|
}
|
|
if cert.NeedsRenewal(cm) {
|
|
renew = append(renew, domain)
|
|
needsReload = true
|
|
}
|
|
if mgr.ocspCache.updated(domain, cert.OCSPStaple) {
|
|
ocsp = append(ocsp, domain)
|
|
needsReload = true
|
|
}
|
|
}
|
|
if !needsReload {
|
|
return nil
|
|
}
|
|
|
|
ctx = log.WithContext(ctx, func(c zerolog.Context) zerolog.Context {
|
|
if len(renew) > 0 {
|
|
c = c.Strs("renew_domains", renew)
|
|
}
|
|
if len(ocsp) > 0 {
|
|
c = c.Strs("ocsp_refresh", ocsp)
|
|
}
|
|
return c
|
|
})
|
|
log.Info(ctx).Msg("updating certificates")
|
|
|
|
cfg = mgr.src.GetConfig().Clone()
|
|
mgr.updateServer(ctx, cfg)
|
|
if err := mgr.updateAutocert(ctx, cfg); err != nil {
|
|
return err
|
|
}
|
|
|
|
mgr.config = cfg
|
|
mgr.Trigger(ctx, cfg)
|
|
return nil
|
|
}
|
|
|
|
func (mgr *Manager) update(ctx context.Context, cfg *config.Config) error {
|
|
cfg = cfg.Clone()
|
|
|
|
mgr.mu.Lock()
|
|
defer mgr.mu.Unlock()
|
|
defer func() { mgr.config = cfg }()
|
|
|
|
mgr.updateServer(ctx, cfg)
|
|
return mgr.updateAutocert(ctx, cfg)
|
|
}
|
|
|
|
// obtainCert obtains a certificate for given domain, use cached manager if cert exists there.
|
|
func (mgr *Manager) obtainCert(ctx context.Context, domain string, cm *certmagic.Config) (certmagic.Certificate, error) {
|
|
cert, err := cm.CacheManagedCertificate(domain)
|
|
if err != nil {
|
|
log.Info(ctx).Str("domain", domain).Msg("obtaining certificate")
|
|
err = cm.ObtainCertSync(ctx, domain)
|
|
if err != nil {
|
|
log.Error(ctx).Err(err).Msg("autocert failed to obtain client certificate")
|
|
return certmagic.Certificate{}, errObtainCertFailed
|
|
}
|
|
metrics.RecordAutocertRenewal()
|
|
cert, err = cm.CacheManagedCertificate(domain)
|
|
}
|
|
return cert, err
|
|
}
|
|
|
|
// renewCert attempts to renew given certificate.
|
|
func (mgr *Manager) renewCert(ctx context.Context, domain string, cert certmagic.Certificate, cm *certmagic.Config) (certmagic.Certificate, error) {
|
|
expired := time.Now().After(cert.Leaf.NotAfter)
|
|
log.Info(ctx).Str("domain", domain).Msg("renewing certificate")
|
|
renewCertLock.Lock()
|
|
err := cm.RenewCertSync(ctx, domain, false)
|
|
renewCertLock.Unlock()
|
|
if err != nil {
|
|
if expired {
|
|
return certmagic.Certificate{}, errRenewCertFailed
|
|
}
|
|
log.Warn(ctx).Err(err).Msg("renew client certificated failed, use existing cert")
|
|
}
|
|
return cm.CacheManagedCertificate(domain)
|
|
}
|
|
|
|
func (mgr *Manager) updateAutocert(ctx context.Context, cfg *config.Config) error {
|
|
if !cfg.Options.AutocertOptions.Enable {
|
|
return nil
|
|
}
|
|
|
|
cm, err := mgr.getCertMagicConfig(cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, domain := range sourceHostnames(cfg) {
|
|
cert, err := mgr.obtainCert(ctx, domain, cm)
|
|
if err != nil && errors.Is(err, errObtainCertFailed) {
|
|
return fmt.Errorf("autocert: failed to obtain client certificate: %w", err)
|
|
}
|
|
if err == nil && cert.NeedsRenewal(cm) {
|
|
cert, err = mgr.renewCert(ctx, domain, cert, cm)
|
|
}
|
|
if err != nil && errors.Is(err, errRenewCertFailed) {
|
|
return fmt.Errorf("autocert: failed to renew client certificate: %w", err)
|
|
}
|
|
if err != nil {
|
|
log.Error(ctx).Err(err).Msg("autocert: failed to obtain client certificate")
|
|
continue
|
|
}
|
|
|
|
log.Info(ctx).Strs("names", cert.Names).Msg("autocert: added certificate")
|
|
cfg.AutoCertificates = append(cfg.AutoCertificates, cert.Certificate)
|
|
}
|
|
|
|
metrics.RecordAutocertCertificates(cfg.AutoCertificates)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (mgr *Manager) updateServer(ctx context.Context, cfg *config.Config) {
|
|
if mgr.srv != nil {
|
|
// nothing to do if the address hasn't changed
|
|
if mgr.srv.Addr == cfg.Options.HTTPRedirectAddr {
|
|
return
|
|
}
|
|
// close immediately, don't care about the error
|
|
_ = mgr.srv.Close()
|
|
mgr.srv = nil
|
|
}
|
|
|
|
if cfg.Options.HTTPRedirectAddr == "" {
|
|
return
|
|
}
|
|
|
|
redirect := httputil.RedirectHandler()
|
|
|
|
hsrv := &http.Server{
|
|
Addr: cfg.Options.HTTPRedirectAddr,
|
|
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if mgr.handleHTTPChallenge(w, r) {
|
|
return
|
|
}
|
|
redirect.ServeHTTP(w, r)
|
|
}),
|
|
}
|
|
go func() {
|
|
log.Info(ctx).Str("addr", hsrv.Addr).Msg("starting http redirect server")
|
|
err := hsrv.ListenAndServe()
|
|
if err != nil {
|
|
log.Error(ctx).Err(err).Msg("failed to run http redirect server")
|
|
}
|
|
}()
|
|
mgr.srv = hsrv
|
|
}
|
|
|
|
func (mgr *Manager) handleHTTPChallenge(w http.ResponseWriter, r *http.Request) bool {
|
|
obj := mgr.acmeMgr.Load()
|
|
if obj == nil {
|
|
return false
|
|
}
|
|
acmeMgr := obj.(*certmagic.ACMEManager)
|
|
return acmeMgr.HandleHTTPChallenge(w, r)
|
|
}
|
|
|
|
// GetConfig gets the config.
|
|
func (mgr *Manager) GetConfig() *config.Config {
|
|
mgr.mu.RLock()
|
|
defer mgr.mu.RUnlock()
|
|
|
|
return mgr.config
|
|
}
|
|
|
|
// configureCertificateAuthority configures the acmeMgr ACME Certificate Authority settings.
|
|
func configureCertificateAuthority(acmeMgr *certmagic.ACMEManager, opts config.AutocertOptions) error {
|
|
acmeMgr.Agreed = true
|
|
if opts.UseStaging {
|
|
acmeMgr.CA = acmeMgr.TestCA
|
|
}
|
|
if opts.CA != "" {
|
|
acmeMgr.CA = opts.CA // when a CA is specified, it overrides the staging setting
|
|
}
|
|
if opts.Email != "" {
|
|
acmeMgr.Email = opts.Email
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// configureExternalAccountBinding configures the acmeMgr ACME External Account Binding settings.
|
|
func configureExternalAccountBinding(acmeMgr *certmagic.ACMEManager, opts config.AutocertOptions) error {
|
|
if opts.EABKeyID != "" || opts.EABMACKey != "" {
|
|
acmeMgr.ExternalAccount = &acme.EAB{}
|
|
}
|
|
if opts.EABKeyID != "" {
|
|
acmeMgr.ExternalAccount.KeyID = opts.EABKeyID
|
|
}
|
|
if opts.EABMACKey != "" {
|
|
_, err := base64.RawURLEncoding.DecodeString(opts.EABMACKey)
|
|
if err != nil {
|
|
return fmt.Errorf("config: decoding base64-urlencoded MAC Key: %w", err)
|
|
}
|
|
acmeMgr.ExternalAccount.MACKey = opts.EABMACKey
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// configureTrustedRoots configures the acmeMgr x509 roots to trust when communicating with an ACME CA.
|
|
func configureTrustedRoots(acmeMgr *certmagic.ACMEManager, opts config.AutocertOptions) error {
|
|
if opts.TrustedCA != "" {
|
|
// pool effectively contains the certificate(s) in the TrustedCA base64 PEM appended to the system roots
|
|
pool, err := cryptutil.GetCertPool(opts.TrustedCA, "")
|
|
if err != nil {
|
|
return fmt.Errorf("config: creating trusted certificate pool: %w", err)
|
|
}
|
|
acmeMgr.TrustedRoots = pool
|
|
}
|
|
if opts.TrustedCAFile != "" {
|
|
// pool effectively contains the certificate(s) in TrustedCAFile appended to the system roots
|
|
pool, err := cryptutil.GetCertPool("", opts.TrustedCAFile)
|
|
if err != nil {
|
|
return fmt.Errorf("config: creating trusted certificate pool: %w", err)
|
|
}
|
|
acmeMgr.TrustedRoots = pool
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func sourceHostnames(cfg *config.Config) []string {
|
|
policies := cfg.Options.GetAllPolicies()
|
|
|
|
if len(policies) == 0 {
|
|
return nil
|
|
}
|
|
|
|
dedupe := map[string]struct{}{}
|
|
for _, p := range policies {
|
|
dedupe[p.Source.Hostname()] = struct{}{}
|
|
}
|
|
if cfg.Options.AuthenticateURLString != "" {
|
|
u, _ := cfg.Options.GetAuthenticateURL()
|
|
if u != nil {
|
|
dedupe[u.Hostname()] = struct{}{}
|
|
}
|
|
}
|
|
|
|
var h []string
|
|
for k := range dedupe {
|
|
h = append(h, k)
|
|
}
|
|
sort.Strings(h)
|
|
|
|
return h
|
|
}
|