Платформа ЦРНП "Мирокод" для разработки проектов
https://git.mirocod.ru
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
466 lines
15 KiB
466 lines
15 KiB
package certmagic |
|
|
|
import ( |
|
"context" |
|
"crypto/x509" |
|
"errors" |
|
"fmt" |
|
"net/http" |
|
"net/url" |
|
"sort" |
|
"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 any 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 |
|
|
|
// The PEM-encoded private key of the ACME |
|
// account to use; only needed if the account |
|
// is already created on the server and |
|
// can be looked up with the ACME protocol |
|
AccountKeyPEM 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 and the Account. |
|
// (TODO: this feature is still EXPERIMENTAL and subject to change) |
|
NewAccountFunc func(context.Context, *ACMEManager, acme.Account) (acme.Account, error) |
|
|
|
// Preferences for selecting alternate |
|
// certificate chains |
|
PreferredChains ChainPreference |
|
|
|
// 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, and if any required values are still empty, sensible |
|
// defaults will be used. |
|
// |
|
// Typically, you'll create the Config first with New() or NewDefault(), |
|
// then call NewACMEManager(), then assign the return value to the Issuers |
|
// field 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.AccountKeyPEM == "" { |
|
template.AccountKeyPEM = DefaultACME.AccountKeyPEM |
|
} |
|
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 (*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 { |
|
publicCA := strings.Contains(am.CA, "api.letsencrypt.org") || strings.Contains(am.CA, "acme.zerossl.com") |
|
if publicCA { |
|
for _, name := range names { |
|
if !SubjectQualifiesForPublicCert(name) { |
|
return fmt.Errorf("subject does not qualify for a public 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.newACMEClientWithAccount(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) |
|
} |
|
if len(certChains) == 0 { |
|
return nil, usingTestCA, fmt.Errorf("no certificate chains") |
|
} |
|
|
|
preferredChain := am.selectPreferredChain(certChains) |
|
|
|
ic := &IssuedCertificate{ |
|
Certificate: preferredChain.ChainPEM, |
|
Metadata: preferredChain, |
|
} |
|
|
|
return ic, usingTestCA, nil |
|
} |
|
|
|
// selectPreferredChain sorts and then filters the certificate chains to find the optimal |
|
// chain preferred by the client. If there's only one chain, that is returned without any |
|
// processing. If there are no matches, the first chain is returned. |
|
func (am *ACMEManager) selectPreferredChain(certChains []acme.Certificate) acme.Certificate { |
|
if len(certChains) == 1 { |
|
if am.Logger != nil && (len(am.PreferredChains.AnyCommonName) > 0 || len(am.PreferredChains.RootCommonName) > 0) { |
|
am.Logger.Debug("there is only one chain offered; selecting it regardless of preferences", |
|
zap.String("chain_url", certChains[0].URL)) |
|
} |
|
return certChains[0] |
|
} |
|
|
|
if am.PreferredChains.Smallest != nil { |
|
if *am.PreferredChains.Smallest { |
|
sort.Slice(certChains, func(i, j int) bool { |
|
return len(certChains[i].ChainPEM) < len(certChains[j].ChainPEM) |
|
}) |
|
} else { |
|
sort.Slice(certChains, func(i, j int) bool { |
|
return len(certChains[i].ChainPEM) > len(certChains[j].ChainPEM) |
|
}) |
|
} |
|
} |
|
|
|
if len(am.PreferredChains.AnyCommonName) > 0 || len(am.PreferredChains.RootCommonName) > 0 { |
|
// in order to inspect, we need to decode their PEM contents |
|
decodedChains := make([][]*x509.Certificate, len(certChains)) |
|
for i, chain := range certChains { |
|
certs, err := parseCertsFromPEMBundle(chain.ChainPEM) |
|
if err != nil { |
|
if am.Logger != nil { |
|
am.Logger.Error("unable to parse PEM certificate chain", |
|
zap.Int("chain", i), |
|
zap.Error(err)) |
|
} |
|
continue |
|
} |
|
decodedChains[i] = certs |
|
} |
|
|
|
if len(am.PreferredChains.AnyCommonName) > 0 { |
|
for _, prefAnyCN := range am.PreferredChains.AnyCommonName { |
|
for i, chain := range decodedChains { |
|
for _, cert := range chain { |
|
if cert.Issuer.CommonName == prefAnyCN { |
|
if am.Logger != nil { |
|
am.Logger.Debug("found preferred certificate chain by issuer common name", |
|
zap.String("preference", prefAnyCN), |
|
zap.Int("chain", i)) |
|
} |
|
return certChains[i] |
|
} |
|
} |
|
} |
|
} |
|
} |
|
|
|
if len(am.PreferredChains.RootCommonName) > 0 { |
|
for _, prefRootCN := range am.PreferredChains.RootCommonName { |
|
for i, chain := range decodedChains { |
|
if chain[len(chain)-1].Issuer.CommonName == prefRootCN { |
|
if am.Logger != nil { |
|
am.Logger.Debug("found preferred certificate chain by root common name", |
|
zap.String("preference", prefRootCN), |
|
zap.Int("chain", i)) |
|
} |
|
return certChains[i] |
|
} |
|
} |
|
} |
|
} |
|
|
|
if am.Logger != nil { |
|
am.Logger.Warn("did not find chain matching preferences; using first") |
|
} |
|
} |
|
|
|
return certChains[0] |
|
} |
|
|
|
// 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.newACMEClientWithAccount(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) |
|
} |
|
|
|
// ChainPreference describes the client's preferred certificate chain, |
|
// useful if the CA offers alternate chains. The first matching chain |
|
// will be selected. |
|
type ChainPreference struct { |
|
// Prefer chains with the fewest number of bytes. |
|
Smallest *bool |
|
|
|
// Select first chain having a root with one of |
|
// these common names. |
|
RootCommonName []string |
|
|
|
// Select first chain that has any issuer with one |
|
// of these common names. |
|
AnyCommonName []string |
|
} |
|
|
|
// DefaultACME specifies default settings to use for ACMEManagers. |
|
// Using this value is optional but can be convenient. |
|
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" |
|
ZeroSSLProductionCA = "https://acme.zerossl.com/v2/DV90" |
|
) |
|
|
|
// 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) |
|
)
|
|
|