package certmagic import ( "context" "crypto/x509" "errors" "fmt" "log" "net/http" "net/url" "strings" "time" "github.com/go-acme/lego/v3/acme" "github.com/go-acme/lego/v3/certificate" "github.com/go-acme/lego/v3/challenge" "github.com/go-acme/lego/v3/challenge/dns01" ) // 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 *ExternalAccountBinding // 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 DNS provider to use when solving the // ACME DNS challenge DNSProvider challenge.Provider // The ChallengeOption struct to provide // custom precheck or name resolution options // for DNS challenge validation and execution DNSChallengeOption dns01.ChallengeOption // 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 lego lib is // used. If set, it must not be too low so // as to cancel orders too early, running // the risk of rate limiting. CertObtainTimeout time.Duration config *Config } // 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.TestCA = DefaultACME.TestCA } if template.Email == "" { template.Email = DefaultACME.Email } if !template.Agreed { template.Agreed = DefaultACME.Agreed } 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.DNSProvider == nil { template.DNSProvider = DefaultACME.DNSProvider } if template.DNSChallengeOption == nil { template.DNSChallengeOption = DefaultACME.DNSChallengeOption } if template.TrustedRoots == nil { template.TrustedRoots = DefaultACME.TrustedRoots } if template.CertObtainTimeout == 0 { template.CertObtainTimeout = DefaultACME.CertObtainTimeout } 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(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 acmeErr acme.ProblemDetails if errors.As(err, &acmeErr) { if acmeErr.HTTPStatus == 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.newACMEClientWithRetry(useTestCA) 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 } } certRes, err := client.tryAllEnabledChallenges(ctx, csr) if err != nil { return nil, usingTestCA, fmt.Errorf("%v %w", nameSet, err) } ic := &IssuedCertificate{ Certificate: certRes.Certificate, Metadata: certRes, } return ic, usingTestCA, nil } func (c *acmeClient) tryAllEnabledChallenges(ctx context.Context, csr *x509.CertificateRequest) (*certificate.Resource, error) { // start with all enabled challenges challenges := c.initialChallenges() if len(challenges) == 0 { return nil, fmt.Errorf("no challenge types enabled") } // try while a challenge type is still available var cert *certificate.Resource var err error for len(challenges) > 0 { var chosenChallenge challenge.Type chosenChallenge, challenges = c.nextChallenge(challenges) cert, err = c.acmeClient.Certificate.ObtainForCSR(*csr, true) if err == nil { return cert, nil } log.Printf("[ERROR] %s (challenge=%s remaining=%v)", err, chosenChallenge, challenges) time.Sleep(2 * time.Second) } return cert, err } // Revoke implements the Revoker interface. It revokes the given certificate. func (am *ACMEManager) Revoke(ctx context.Context, cert CertificateResource) error { client, err := am.newACMEClient(false, false) if err != nil { return err } meta := cert.IssuerData.(map[string]interface{}) cr := certificate.Resource{ Domain: meta["domain"].(string), CertURL: meta["certUrl"].(string), CertStableURL: meta["certStableURL"].(string), } return client.revoke(ctx, cr) } // ExternalAccountBinding contains information for // binding an external account to an ACME account. type ExternalAccountBinding struct { KeyID string HMAC []byte } // 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) )