351 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Go
		
	
	
	
			
		
		
	
	
			351 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Go
		
	
	
	
| package certmagic
 | |
| 
 | |
| import (
 | |
| 	"context"
 | |
| 	"crypto/x509"
 | |
| 	"errors"
 | |
| 	"fmt"
 | |
| 	"net/http"
 | |
| 	"net/url"
 | |
| 	"strings"
 | |
| 	"time"
 | |
| 
 | |
| 	"github.com/mholt/acmez"
 | |
| 	"github.com/mholt/acmez/acme"
 | |
| 	"go.uber.org/zap"
 | |
| )
 | |
| 
 | |
| // ACMEManager gets certificates using ACME. It implements the PreChecker,
 | |
| // Issuer, and Revoker interfaces.
 | |
| //
 | |
| // It is NOT VALID to use an ACMEManager without calling NewACMEManager().
 | |
| // It fills in default values from DefaultACME as well as setting up
 | |
| // internal state that is necessary for valid use. Always call
 | |
| // NewACMEManager() to get a valid ACMEManager value.
 | |
| type ACMEManager struct {
 | |
| 	// The endpoint of the directory for the ACME
 | |
| 	// CA we are to use
 | |
| 	CA string
 | |
| 
 | |
| 	// TestCA is the endpoint of the directory for
 | |
| 	// an ACME CA to use to test domain validation,
 | |
| 	// but any certs obtained from this CA are
 | |
| 	// discarded
 | |
| 	TestCA string
 | |
| 
 | |
| 	// The email address to use when creating or
 | |
| 	// selecting an existing ACME server account
 | |
| 	Email string
 | |
| 
 | |
| 	// Set to true if agreed to the CA's
 | |
| 	// subscriber agreement
 | |
| 	Agreed bool
 | |
| 
 | |
| 	// An optional external account to associate
 | |
| 	// with this ACME account
 | |
| 	ExternalAccount *acme.EAB
 | |
| 
 | |
| 	// Disable all HTTP challenges
 | |
| 	DisableHTTPChallenge bool
 | |
| 
 | |
| 	// Disable all TLS-ALPN challenges
 | |
| 	DisableTLSALPNChallenge bool
 | |
| 
 | |
| 	// The host (ONLY the host, not port) to listen
 | |
| 	// on if necessary to start a listener to solve
 | |
| 	// an ACME challenge
 | |
| 	ListenHost string
 | |
| 
 | |
| 	// The alternate port to use for the ACME HTTP
 | |
| 	// challenge; if non-empty, this port will be
 | |
| 	// used instead of HTTPChallengePort to spin up
 | |
| 	// a listener for the HTTP challenge
 | |
| 	AltHTTPPort int
 | |
| 
 | |
| 	// The alternate port to use for the ACME
 | |
| 	// TLS-ALPN challenge; the system must forward
 | |
| 	// TLSALPNChallengePort to this port for
 | |
| 	// challenge to succeed
 | |
| 	AltTLSALPNPort int
 | |
| 
 | |
| 	// The solver for the dns-01 challenge;
 | |
| 	// usually this is a DNS01Solver value
 | |
| 	// from this package
 | |
| 	DNS01Solver acmez.Solver
 | |
| 
 | |
| 	// TrustedRoots specifies a pool of root CA
 | |
| 	// certificates to trust when communicating
 | |
| 	// over a network to a peer.
 | |
| 	TrustedRoots *x509.CertPool
 | |
| 
 | |
| 	// The maximum amount of time to allow for
 | |
| 	// obtaining a certificate. If empty, the
 | |
| 	// default from the underlying ACME lib is
 | |
| 	// used. If set, it must not be too low so
 | |
| 	// as to cancel challenges too early.
 | |
| 	CertObtainTimeout time.Duration
 | |
| 
 | |
| 	// Address of custom DNS resolver to be used
 | |
| 	// when communicating with ACME server
 | |
| 	Resolver string
 | |
| 
 | |
| 	// Callback function that is called before a
 | |
| 	// new ACME account is registered with the CA;
 | |
| 	// it allows for last-second config changes
 | |
| 	// of the ACMEManager (TODO: this feature is
 | |
| 	// still EXPERIMENTAL and subject to change)
 | |
| 	NewAccountFunc func(context.Context, *ACMEManager, acme.Account) error
 | |
| 
 | |
| 	// Set a logger to enable logging
 | |
| 	Logger *zap.Logger
 | |
| 
 | |
| 	config     *Config
 | |
| 	httpClient *http.Client
 | |
| }
 | |
| 
 | |
| // NewACMEManager constructs a valid ACMEManager based on a template
 | |
| // configuration; any empty values will be filled in by defaults in
 | |
| // DefaultACME. The associated config is also required.
 | |
| //
 | |
| // Typically, you'll create the Config first, then call NewACMEManager(),
 | |
| // then assign the return value to the Issuer/Revoker fields of the Config.
 | |
| func NewACMEManager(cfg *Config, template ACMEManager) *ACMEManager {
 | |
| 	if cfg == nil {
 | |
| 		panic("cannot make valid ACMEManager without an associated CertMagic config")
 | |
| 	}
 | |
| 	if template.CA == "" {
 | |
| 		template.CA = DefaultACME.CA
 | |
| 	}
 | |
| 	if template.TestCA == "" && template.CA == DefaultACME.CA {
 | |
| 		// only use the default test CA if the CA is also
 | |
| 		// the default CA; no point in testing against
 | |
| 		// Let's Encrypt's staging server if we are not
 | |
| 		// using their production server too
 | |
| 		template.TestCA = DefaultACME.TestCA
 | |
| 	}
 | |
| 	if template.Email == "" {
 | |
| 		template.Email = DefaultACME.Email
 | |
| 	}
 | |
| 	if !template.Agreed {
 | |
| 		template.Agreed = DefaultACME.Agreed
 | |
| 	}
 | |
| 	if template.ExternalAccount == nil {
 | |
| 		template.ExternalAccount = DefaultACME.ExternalAccount
 | |
| 	}
 | |
| 	if !template.DisableHTTPChallenge {
 | |
| 		template.DisableHTTPChallenge = DefaultACME.DisableHTTPChallenge
 | |
| 	}
 | |
| 	if !template.DisableTLSALPNChallenge {
 | |
| 		template.DisableTLSALPNChallenge = DefaultACME.DisableTLSALPNChallenge
 | |
| 	}
 | |
| 	if template.ListenHost == "" {
 | |
| 		template.ListenHost = DefaultACME.ListenHost
 | |
| 	}
 | |
| 	if template.AltHTTPPort == 0 {
 | |
| 		template.AltHTTPPort = DefaultACME.AltHTTPPort
 | |
| 	}
 | |
| 	if template.AltTLSALPNPort == 0 {
 | |
| 		template.AltTLSALPNPort = DefaultACME.AltTLSALPNPort
 | |
| 	}
 | |
| 	if template.DNS01Solver == nil {
 | |
| 		template.DNS01Solver = DefaultACME.DNS01Solver
 | |
| 	}
 | |
| 	if template.TrustedRoots == nil {
 | |
| 		template.TrustedRoots = DefaultACME.TrustedRoots
 | |
| 	}
 | |
| 	if template.CertObtainTimeout == 0 {
 | |
| 		template.CertObtainTimeout = DefaultACME.CertObtainTimeout
 | |
| 	}
 | |
| 	if template.Resolver == "" {
 | |
| 		template.Resolver = DefaultACME.Resolver
 | |
| 	}
 | |
| 	if template.NewAccountFunc == nil {
 | |
| 		template.NewAccountFunc = DefaultACME.NewAccountFunc
 | |
| 	}
 | |
| 	if template.Logger == nil {
 | |
| 		template.Logger = DefaultACME.Logger
 | |
| 	}
 | |
| 	template.config = cfg
 | |
| 	return &template
 | |
| }
 | |
| 
 | |
| // IssuerKey returns the unique issuer key for the
 | |
| // confgured CA endpoint.
 | |
| func (am *ACMEManager) IssuerKey() string {
 | |
| 	return am.issuerKey(am.CA)
 | |
| }
 | |
| 
 | |
| func (am *ACMEManager) issuerKey(ca string) string {
 | |
| 	key := ca
 | |
| 	if caURL, err := url.Parse(key); err == nil {
 | |
| 		key = caURL.Host
 | |
| 		if caURL.Path != "" {
 | |
| 			// keep the path, but make sure it's a single
 | |
| 			// component (i.e. no forward slashes, and for
 | |
| 			// good measure, no backward slashes either)
 | |
| 			const hyphen = "-"
 | |
| 			repl := strings.NewReplacer(
 | |
| 				"/", hyphen,
 | |
| 				"\\", hyphen,
 | |
| 			)
 | |
| 			path := strings.Trim(repl.Replace(caURL.Path), hyphen)
 | |
| 			if path != "" {
 | |
| 				key += hyphen + path
 | |
| 			}
 | |
| 		}
 | |
| 	}
 | |
| 	return key
 | |
| }
 | |
| 
 | |
| // PreCheck performs a few simple checks before obtaining or
 | |
| // renewing a certificate with ACME, and returns whether this
 | |
| // batch is eligible for certificates if using Let's Encrypt.
 | |
| // It also ensures that an email address is available.
 | |
| func (am *ACMEManager) PreCheck(_ context.Context, names []string, interactive bool) error {
 | |
| 	letsEncrypt := strings.Contains(am.CA, "api.letsencrypt.org")
 | |
| 	if letsEncrypt {
 | |
| 		for _, name := range names {
 | |
| 			if !SubjectQualifiesForPublicCert(name) {
 | |
| 				return fmt.Errorf("subject does not qualify for a Let's Encrypt certificate: %s", name)
 | |
| 			}
 | |
| 		}
 | |
| 	}
 | |
| 	return am.getEmail(interactive)
 | |
| }
 | |
| 
 | |
| // Issue implements the Issuer interface. It obtains a certificate for the given csr using
 | |
| // the ACME configuration am.
 | |
| func (am *ACMEManager) Issue(ctx context.Context, csr *x509.CertificateRequest) (*IssuedCertificate, error) {
 | |
| 	if am.config == nil {
 | |
| 		panic("missing config pointer (must use NewACMEManager)")
 | |
| 	}
 | |
| 
 | |
| 	var isRetry bool
 | |
| 	if attempts, ok := ctx.Value(AttemptsCtxKey).(*int); ok {
 | |
| 		isRetry = *attempts > 0
 | |
| 	}
 | |
| 
 | |
| 	cert, usedTestCA, err := am.doIssue(ctx, csr, isRetry)
 | |
| 	if err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 
 | |
| 	// important to note that usedTestCA is not necessarily the same as isRetry
 | |
| 	// (usedTestCA can be true if the main CA and the test CA happen to be the same)
 | |
| 	if isRetry && usedTestCA && am.CA != am.TestCA {
 | |
| 		// succeeded with testing endpoint, so try again with production endpoint
 | |
| 		// (only if the production endpoint is different from the testing endpoint)
 | |
| 		// TODO: This logic is imperfect and could benefit from some refinement.
 | |
| 		// The two CA endpoints likely have different states, which could cause one
 | |
| 		// to succeed and the other to fail, even if it's not a validation error.
 | |
| 		// Two common cases would be:
 | |
| 		// 1) Rate limiter state. This is more likely to cause prod to fail while
 | |
| 		// staging succeeds, since prod usually has tighter rate limits. Thus, if
 | |
| 		// initial attempt failed in prod due to rate limit, first retry (on staging)
 | |
| 		// might succeed, and then trying prod again right way would probably still
 | |
| 		// fail; normally this would terminate retries but the right thing to do in
 | |
| 		// this case is to back off and retry again later. We could refine this logic
 | |
| 		// to stick with the production endpoint on retries unless the error changes.
 | |
| 		// 2) Cached authorizations state. If a domain validates successfully with
 | |
| 		// one endpoint, but then the other endpoint is used, it might fail, e.g. if
 | |
| 		// DNS was just changed or is still propagating. In this case, the second CA
 | |
| 		// should continue to be retried with backoff, without switching back to the
 | |
| 		// other endpoint. This is more likely to happen if a user is testing with
 | |
| 		// the staging CA as the main CA, then changes their configuration once they
 | |
| 		// think they are ready for the production endpoint.
 | |
| 		cert, _, err = am.doIssue(ctx, csr, false)
 | |
| 		if err != nil {
 | |
| 			// succeeded with test CA but failed just now with the production CA;
 | |
| 			// either we are observing differing internal states of each CA that will
 | |
| 			// work out with time, or there is a bug/misconfiguration somewhere
 | |
| 			// externally; it is hard to tell which! one easy cue is whether the
 | |
| 			// error is specifically a 429 (Too Many Requests); if so, we should
 | |
| 			// probably keep retrying
 | |
| 			var problem acme.Problem
 | |
| 			if errors.As(err, &problem) {
 | |
| 				if problem.Status == http.StatusTooManyRequests {
 | |
| 					// DON'T abort retries; the test CA succeeded (even
 | |
| 					// if it's cached, it recently succeeded!) so we just
 | |
| 					// need to keep trying (with backoff) until this CA's
 | |
| 					// rate limits expire...
 | |
| 					// TODO: as mentioned in comment above, we would benefit
 | |
| 					// by pinning the main CA at this point instead of
 | |
| 					// needlessly retrying with the test CA first each time
 | |
| 					return nil, err
 | |
| 				}
 | |
| 			}
 | |
| 			return nil, ErrNoRetry{err}
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	return cert, err
 | |
| }
 | |
| 
 | |
| func (am *ACMEManager) doIssue(ctx context.Context, csr *x509.CertificateRequest, useTestCA bool) (*IssuedCertificate, bool, error) {
 | |
| 	client, err := am.newACMEClient(ctx, useTestCA, false)
 | |
| 	if err != nil {
 | |
| 		return nil, false, err
 | |
| 	}
 | |
| 	usingTestCA := client.usingTestCA()
 | |
| 
 | |
| 	nameSet := namesFromCSR(csr)
 | |
| 
 | |
| 	if !useTestCA {
 | |
| 		if err := client.throttle(ctx, nameSet); err != nil {
 | |
| 			return nil, usingTestCA, err
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	certChains, err := client.acmeClient.ObtainCertificateUsingCSR(ctx, client.account, csr)
 | |
| 	if err != nil {
 | |
| 		return nil, usingTestCA, fmt.Errorf("%v %w (ca=%s)", nameSet, err, client.acmeClient.Directory)
 | |
| 	}
 | |
| 
 | |
| 	// TODO: ACME server could in theory issue a cert with multiple chains,
 | |
| 	// but we don't (yet) have a way to choose one, so just use first one
 | |
| 	ic := &IssuedCertificate{
 | |
| 		Certificate: certChains[0].ChainPEM,
 | |
| 		Metadata:    certChains[0],
 | |
| 	}
 | |
| 
 | |
| 	return ic, usingTestCA, nil
 | |
| }
 | |
| 
 | |
| // Revoke implements the Revoker interface. It revokes the given certificate.
 | |
| func (am *ACMEManager) Revoke(ctx context.Context, cert CertificateResource, reason int) error {
 | |
| 	client, err := am.newACMEClient(ctx, false, false)
 | |
| 	if err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	certs, err := parseCertsFromPEMBundle(cert.CertificatePEM)
 | |
| 	if err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	return client.revoke(ctx, certs[0], reason)
 | |
| }
 | |
| 
 | |
| // DefaultACME specifies the default settings
 | |
| // to use for ACMEManagers.
 | |
| var DefaultACME = ACMEManager{
 | |
| 	CA:     LetsEncryptProductionCA,
 | |
| 	TestCA: LetsEncryptStagingCA,
 | |
| }
 | |
| 
 | |
| // Some well-known CA endpoints available to use.
 | |
| const (
 | |
| 	LetsEncryptStagingCA    = "https://acme-staging-v02.api.letsencrypt.org/directory"
 | |
| 	LetsEncryptProductionCA = "https://acme-v02.api.letsencrypt.org/directory"
 | |
| )
 | |
| 
 | |
| // prefixACME is the storage key prefix used for ACME-specific assets.
 | |
| const prefixACME = "acme"
 | |
| 
 | |
| // Interface guards
 | |
| var (
 | |
| 	_ PreChecker = (*ACMEManager)(nil)
 | |
| 	_ Issuer     = (*ACMEManager)(nil)
 | |
| 	_ Revoker    = (*ACMEManager)(nil)
 | |
| )
 |