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 // The solver for the http-01 challenge HTTP01Solver 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) )