diff options
author | Matt Holt <[email protected]> | 2024-04-13 21:31:43 -0400 |
---|---|---|
committer | GitHub <[email protected]> | 2024-04-13 21:31:43 -0400 |
commit | 81413caea251a3ef9e3641d7b1b6e867572a2b1b (patch) | |
tree | 1a379dcf516e8d7daeae1fb00e54c1fbf1e74696 /modules | |
parent | dc9dd2e4b3e266cb1267f672e3bcfd50c67cc3d4 (diff) | |
download | caddy-81413caea251a3ef9e3641d7b1b6e867572a2b1b.tar.gz caddy-81413caea251a3ef9e3641d7b1b6e867572a2b1b.zip |
caddytls: Upgrade ACMEz to v2; support ZeroSSL API; various fixes (#6229)
* WIP: acmez v2, CertMagic, and ZeroSSL issuer upgrades
* caddytls: ZeroSSLIssuer now uses ZeroSSL API instead of ACME
* Fix go.mod
* caddytls: Fix automation related to managers (fix #6060)
* Fix typo (appease linter)
* Fix HTTP validation with ZeroSSL API
Diffstat (limited to 'modules')
-rw-r--r-- | modules/caddyhttp/autohttps.go | 10 | ||||
-rw-r--r-- | modules/caddyhttp/reverseproxy/fastcgi/client_test.go | 2 | ||||
-rw-r--r-- | modules/caddytls/acmeissuer.go | 94 | ||||
-rw-r--r-- | modules/caddytls/automation.go | 47 | ||||
-rw-r--r-- | modules/caddytls/capools.go | 3 | ||||
-rw-r--r-- | modules/caddytls/certmanagers.go | 9 | ||||
-rw-r--r-- | modules/caddytls/connpolicy.go | 2 | ||||
-rw-r--r-- | modules/caddytls/tls.go | 55 | ||||
-rw-r--r-- | modules/caddytls/zerosslissuer.go | 330 |
9 files changed, 352 insertions, 200 deletions
diff --git a/modules/caddyhttp/autohttps.go b/modules/caddyhttp/autohttps.go index e28062f05..54a2d9ccd 100644 --- a/modules/caddyhttp/autohttps.go +++ b/modules/caddyhttp/autohttps.go @@ -287,6 +287,16 @@ uniqueDomainsLoop: for _, ap := range app.tlsApp.Automation.Policies { for _, apHost := range ap.Subjects() { if apHost == d { + // if the automation policy has all internal subjects but no issuers, + // it will default to CertMagic's issuers which are public CAs; use + // our internal issuer instead + if len(ap.Issuers) == 0 && ap.AllInternalSubjects() { + iss := new(caddytls.InternalIssuer) + if err := iss.Provision(ctx); err != nil { + return err + } + ap.Issuers = append(ap.Issuers, iss) + } continue uniqueDomainsLoop } } diff --git a/modules/caddyhttp/reverseproxy/fastcgi/client_test.go b/modules/caddyhttp/reverseproxy/fastcgi/client_test.go index a2227a653..14a1cf684 100644 --- a/modules/caddyhttp/reverseproxy/fastcgi/client_test.go +++ b/modules/caddyhttp/reverseproxy/fastcgi/client_test.go @@ -213,8 +213,6 @@ func DisabledTest(t *testing.T) { // TODO: test chunked reader globalt = t - rand.Seed(time.Now().UTC().UnixNano()) - // server go func() { listener, err := net.Listen("tcp", ipPort) diff --git a/modules/caddytls/acmeissuer.go b/modules/caddytls/acmeissuer.go index 8a7f8b499..a14dc61a8 100644 --- a/modules/caddytls/acmeissuer.go +++ b/modules/caddytls/acmeissuer.go @@ -17,14 +17,19 @@ package caddytls import ( "context" "crypto/x509" + "encoding/json" "fmt" + "net/http" + "net/url" "os" "strconv" + "strings" "time" "github.com/caddyserver/certmagic" - "github.com/mholt/acmez" - "github.com/mholt/acmez/acme" + "github.com/caddyserver/zerossl" + "github.com/mholt/acmez/v2" + "github.com/mholt/acmez/v2/acme" "go.uber.org/zap" "github.com/caddyserver/caddy/v2" @@ -142,12 +147,14 @@ func (iss *ACMEIssuer) Provision(ctx caddy.Context) error { iss.Challenges.DNS.solver = deprecatedProvider } else { iss.Challenges.DNS.solver = &certmagic.DNS01Solver{ - DNSProvider: val.(certmagic.ACMEDNSProvider), - TTL: time.Duration(iss.Challenges.DNS.TTL), - PropagationDelay: time.Duration(iss.Challenges.DNS.PropagationDelay), - PropagationTimeout: time.Duration(iss.Challenges.DNS.PropagationTimeout), - Resolvers: iss.Challenges.DNS.Resolvers, - OverrideDomain: iss.Challenges.DNS.OverrideDomain, + DNSManager: certmagic.DNSManager{ + DNSProvider: val.(certmagic.DNSProvider), + TTL: time.Duration(iss.Challenges.DNS.TTL), + PropagationDelay: time.Duration(iss.Challenges.DNS.PropagationDelay), + PropagationTimeout: time.Duration(iss.Challenges.DNS.PropagationTimeout), + Resolvers: iss.Challenges.DNS.Resolvers, + OverrideDomain: iss.Challenges.DNS.OverrideDomain, + }, } } } @@ -210,6 +217,18 @@ func (iss *ACMEIssuer) makeIssuerTemplate() (certmagic.ACMEIssuer, error) { } } + // ZeroSSL requires EAB, but we can generate that automatically (requires an email address be configured) + if strings.HasPrefix(iss.CA, "https://acme.zerossl.com/") { + template.NewAccountFunc = func(ctx context.Context, acmeIss *certmagic.ACMEIssuer, acct acme.Account) (acme.Account, error) { + if acmeIss.ExternalAccount != nil { + return acct, nil + } + var err error + acmeIss.ExternalAccount, acct, err = iss.generateZeroSSLEABCredentials(ctx, acct) + return acct, err + } + } + return template, nil } @@ -248,6 +267,65 @@ func (iss *ACMEIssuer) Revoke(ctx context.Context, cert certmagic.CertificateRes // to be accessed and manipulated. func (iss *ACMEIssuer) GetACMEIssuer() *ACMEIssuer { return iss } +// generateZeroSSLEABCredentials generates ZeroSSL EAB credentials for the primary contact email +// on the issuer. It should only be usedif the CA endpoint is ZeroSSL. An email address is required. +func (iss *ACMEIssuer) generateZeroSSLEABCredentials(ctx context.Context, acct acme.Account) (*acme.EAB, acme.Account, error) { + if strings.TrimSpace(iss.Email) == "" { + return nil, acme.Account{}, fmt.Errorf("your email address is required to use ZeroSSL's ACME endpoint") + } + + if len(acct.Contact) == 0 { + // we borrow the email from config or the default email, so ensure it's saved with the account + acct.Contact = []string{"mailto:" + iss.Email} + } + + endpoint := zerossl.BaseURL + "/acme/eab-credentials-email" + form := url.Values{"email": []string{iss.Email}} + body := strings.NewReader(form.Encode()) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, body) + if err != nil { + return nil, acct, fmt.Errorf("forming request: %v", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", certmagic.UserAgent) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, acct, fmt.Errorf("performing EAB credentials request: %v", err) + } + defer resp.Body.Close() + + var result struct { + Success bool `json:"success"` + Error struct { + Code int `json:"code"` + Type string `json:"type"` + } `json:"error"` + EABKID string `json:"eab_kid"` + EABHMACKey string `json:"eab_hmac_key"` + } + err = json.NewDecoder(resp.Body).Decode(&result) + if err != nil { + return nil, acct, fmt.Errorf("decoding API response: %v", err) + } + if result.Error.Code != 0 { + // do this check first because ZeroSSL's API returns 200 on errors + return nil, acct, fmt.Errorf("failed getting EAB credentials: HTTP %d: %s (code %d)", + resp.StatusCode, result.Error.Type, result.Error.Code) + } + if resp.StatusCode != http.StatusOK { + return nil, acct, fmt.Errorf("failed getting EAB credentials: HTTP %d", resp.StatusCode) + } + + iss.logger.Info("generated EAB credentials", zap.String("key_id", result.EABKID)) + + return &acme.EAB{ + KeyID: result.EABKID, + MACKey: result.EABHMACKey, + }, acct, nil +} + // UnmarshalCaddyfile deserializes Caddyfile tokens into iss. // // ... acme [<directory_url>] { diff --git a/modules/caddytls/automation.go b/modules/caddytls/automation.go index a90e5ded8..803cb174e 100644 --- a/modules/caddytls/automation.go +++ b/modules/caddytls/automation.go @@ -24,7 +24,7 @@ import ( "strings" "github.com/caddyserver/certmagic" - "github.com/mholt/acmez" + "github.com/mholt/acmez/v2" "go.uber.org/zap" "github.com/caddyserver/caddy/v2" @@ -201,6 +201,7 @@ func (ap *AutomationPolicy) Provision(tlsApp *TLS) error { // store them on the policy before putting it on the config // load and provision any cert manager modules + hadExplicitManagers := len(ap.ManagersRaw) > 0 if ap.ManagersRaw != nil { vals, err := tlsApp.ctx.LoadModule(ap, "ManagersRaw") if err != nil { @@ -256,12 +257,25 @@ func (ap *AutomationPolicy) Provision(tlsApp *TLS) error { if ap.OnDemand || len(ap.Managers) > 0 { // permission module is now required after a number of negligence cases that allowed abuse; // but it may still be optional for explicit subjects (bounded, non-wildcard), for the - // internal issuer since it doesn't cause public PKI pressure on ACME servers - if ap.isWildcardOrDefault() && !ap.onlyInternalIssuer() && (tlsApp.Automation == nil || tlsApp.Automation.OnDemand == nil || tlsApp.Automation.OnDemand.permission == nil) { - return fmt.Errorf("on-demand TLS cannot be enabled without a permission module to prevent abuse; please refer to documentation for details") + // internal issuer since it doesn't cause public PKI pressure on ACME servers; subtly, it + // is useful to allow on-demand TLS to be enabled so Managers can be used, but to still + // prevent issuance from Issuers (when Managers don't provide a certificate) if there's no + // permission module configured + noProtections := ap.isWildcardOrDefault() && !ap.onlyInternalIssuer() && (tlsApp.Automation == nil || tlsApp.Automation.OnDemand == nil || tlsApp.Automation.OnDemand.permission == nil) + failClosed := noProtections && hadExplicitManagers // don't allow on-demand issuance (other than implicit managers) if no managers have been explicitly configured + if noProtections { + if !hadExplicitManagers { + // no managers, no explicitly-configured permission module, this is a config error + return fmt.Errorf("on-demand TLS cannot be enabled without a permission module to prevent abuse; please refer to documentation for details") + } + // allow on-demand to be enabled but only for the purpose of the Managers; issuance won't be allowed from Issuers + tlsApp.logger.Warn("on-demand TLS can only get certificates from the configured external manager(s) because no ask endpoint / permission module is specified") } ond = &certmagic.OnDemandConfig{ DecisionFunc: func(ctx context.Context, name string) error { + if failClosed { + return fmt.Errorf("no permission module configured; certificates not allowed except from external Managers") + } if tlsApp.Automation == nil || tlsApp.Automation.OnDemand == nil { return nil } @@ -344,6 +358,16 @@ func (ap *AutomationPolicy) Subjects() []string { return ap.subjects } +// AllInternalSubjects returns true if all the subjects on this policy are internal. +func (ap *AutomationPolicy) AllInternalSubjects() bool { + for _, subj := range ap.subjects { + if !certmagic.SubjectIsInternal(subj) { + return false + } + } + return true +} + func (ap *AutomationPolicy) onlyInternalIssuer() bool { if len(ap.Issuers) != 1 { return false @@ -370,17 +394,21 @@ func (ap *AutomationPolicy) isWildcardOrDefault() bool { // DefaultIssuers returns empty Issuers (not provisioned) to be used as defaults. // This function is experimental and has no compatibility promises. -func DefaultIssuers() []certmagic.Issuer { - return []certmagic.Issuer{ - new(ACMEIssuer), - &ZeroSSLIssuer{ACMEIssuer: new(ACMEIssuer)}, +func DefaultIssuers(userEmail string) []certmagic.Issuer { + issuers := []certmagic.Issuer{new(ACMEIssuer)} + if strings.TrimSpace(userEmail) != "" { + issuers = append(issuers, &ACMEIssuer{ + CA: certmagic.ZeroSSLProductionCA, + Email: userEmail, + }) } + return issuers } // DefaultIssuersProvisioned returns empty but provisioned default Issuers from // DefaultIssuers(). This function is experimental and has no compatibility promises. func DefaultIssuersProvisioned(ctx caddy.Context) ([]certmagic.Issuer, error) { - issuers := DefaultIssuers() + issuers := DefaultIssuers("") for i, iss := range issuers { if prov, ok := iss.(caddy.Provisioner); ok { err := prov.Provision(ctx) @@ -453,6 +481,7 @@ type TLSALPNChallengeConfig struct { type DNSChallengeConfig struct { // The DNS provider module to use which will manage // the DNS records relevant to the ACME challenge. + // Required. ProviderRaw json.RawMessage `json:"provider,omitempty" caddy:"namespace=dns.providers inline_key=name"` // The TTL of the TXT record used for the DNS challenge. diff --git a/modules/caddytls/capools.go b/modules/caddytls/capools.go index 44a2fa2c2..7e378aac4 100644 --- a/modules/caddytls/capools.go +++ b/modules/caddytls/capools.go @@ -579,8 +579,7 @@ func (hcp *HTTPCertPool) Provision(ctx caddy.Context) error { customTransport.TLSClientConfig = tlsConfig } - var httpClient *http.Client - *httpClient = *http.DefaultClient + httpClient := *http.DefaultClient httpClient.Transport = customTransport for _, uri := range hcp.Endpoints { diff --git a/modules/caddytls/certmanagers.go b/modules/caddytls/certmanagers.go index 9bb436a37..b2e2eb073 100644 --- a/modules/caddytls/certmanagers.go +++ b/modules/caddytls/certmanagers.go @@ -96,6 +96,11 @@ type HTTPCertGetter struct { // To be valid, the response must be HTTP 200 with a PEM body // consisting of blocks for the certificate chain and the private // key. + // + // To indicate that this manager is not managing a certificate for + // the described handshake, the endpoint should return HTTP 204 + // (No Content). Error statuses will indicate that the manager is + // capable of providing a certificate but was unable to. URL string `json:"url,omitempty"` ctx context.Context @@ -147,6 +152,10 @@ func (hcg HTTPCertGetter) GetCertificate(ctx context.Context, hello *tls.ClientH return nil, err } defer resp.Body.Close() + if resp.StatusCode == http.StatusNoContent { + // endpoint is not managing certs for this handshake + return nil, nil + } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("got HTTP %d", resp.StatusCode) } diff --git a/modules/caddytls/connpolicy.go b/modules/caddytls/connpolicy.go index 49c7add49..1e1455446 100644 --- a/modules/caddytls/connpolicy.go +++ b/modules/caddytls/connpolicy.go @@ -26,7 +26,7 @@ import ( "path/filepath" "strings" - "github.com/mholt/acmez" + "github.com/mholt/acmez/v2" "go.uber.org/zap" "github.com/caddyserver/caddy/v2" diff --git a/modules/caddytls/tls.go b/modules/caddytls/tls.go index bb4cd9715..2a05d5235 100644 --- a/modules/caddytls/tls.go +++ b/modules/caddytls/tls.go @@ -176,8 +176,9 @@ func (t *TLS) Provision(ctx caddy.Context) error { t.Automation.OnDemand.permission = val.(OnDemandPermission) } - // on-demand rate limiting + // on-demand rate limiting (TODO: deprecated, and should be removed later; rate limiting is ineffective now that permission modules are required) if t.Automation != nil && t.Automation.OnDemand != nil && t.Automation.OnDemand.RateLimit != nil { + t.logger.Warn("DEPRECATED: on_demand.rate_limit will be removed in a future release; use permission modules or external certificate managers instead") onDemandRateLimiter.SetMaxEvents(t.Automation.OnDemand.RateLimit.Burst) onDemandRateLimiter.SetWindow(time.Duration(t.Automation.OnDemand.RateLimit.Interval)) } else { @@ -413,35 +414,49 @@ func (t *TLS) Manage(names []string) error { return nil } -// HandleHTTPChallenge ensures that the HTTP challenge is handled for the -// certificate named by r.Host, if it is an HTTP challenge request. It -// requires that the automation policy for r.Host has an issuer of type -// *certmagic.ACMEManager, or one that is ACME-enabled (GetACMEIssuer()). +// HandleHTTPChallenge ensures that the ACME HTTP challenge or ZeroSSL HTTP +// validation request is handled for the certificate named by r.Host, if it +// is an HTTP challenge request. It requires that the automation policy for +// r.Host has an issuer that implements GetACMEIssuer() or is a *ZeroSSLIssuer. func (t *TLS) HandleHTTPChallenge(w http.ResponseWriter, r *http.Request) bool { + acmeChallenge := certmagic.LooksLikeHTTPChallenge(r) + zerosslValidation := certmagic.LooksLikeZeroSSLHTTPValidation(r) + // no-op if it's not an ACME challenge request - if !certmagic.LooksLikeHTTPChallenge(r) { + if !acmeChallenge && !zerosslValidation { return false } // try all the issuers until we find the one that initiated the challenge ap := t.getAutomationPolicyForName(r.Host) - type acmeCapable interface{ GetACMEIssuer() *ACMEIssuer } - for _, iss := range ap.magic.Issuers { - if am, ok := iss.(acmeCapable); ok { - iss := am.GetACMEIssuer() - if iss.issuer.HandleHTTPChallenge(w, r) { - return true + + if acmeChallenge { + type acmeCapable interface{ GetACMEIssuer() *ACMEIssuer } + + for _, iss := range ap.magic.Issuers { + if acmeIssuer, ok := iss.(acmeCapable); ok { + if acmeIssuer.GetACMEIssuer().issuer.HandleHTTPChallenge(w, r) { + return true + } } } - } - // it's possible another server in this process initiated the challenge; - // users have requested that Caddy only handle HTTP challenges it initiated, - // so that users can proxy the others through to their backends; but we - // might not have an automation policy for all identifiers that are trying - // to get certificates (e.g. the admin endpoint), so we do this manual check - if challenge, ok := certmagic.GetACMEChallenge(r.Host); ok { - return certmagic.SolveHTTPChallenge(t.logger, w, r, challenge.Challenge) + // it's possible another server in this process initiated the challenge; + // users have requested that Caddy only handle HTTP challenges it initiated, + // so that users can proxy the others through to their backends; but we + // might not have an automation policy for all identifiers that are trying + // to get certificates (e.g. the admin endpoint), so we do this manual check + if challenge, ok := certmagic.GetACMEChallenge(r.Host); ok { + return certmagic.SolveHTTPChallenge(t.logger, w, r, challenge.Challenge) + } + } else if zerosslValidation { + for _, iss := range ap.magic.Issuers { + if ziss, ok := iss.(*ZeroSSLIssuer); ok { + if ziss.issuer.HandleZeroSSLHTTPValidation(w, r) { + return true + } + } + } } return false diff --git a/modules/caddytls/zerosslissuer.go b/modules/caddytls/zerosslissuer.go index 1c091a076..b8727ab66 100644 --- a/modules/caddytls/zerosslissuer.go +++ b/modules/caddytls/zerosslissuer.go @@ -17,19 +17,15 @@ package caddytls import ( "context" "crypto/x509" - "encoding/json" "fmt" - "io" - "net/http" - "net/url" - "strings" - "sync" + "strconv" + "time" "github.com/caddyserver/certmagic" - "github.com/mholt/acmez/acme" "go.uber.org/zap" "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/caddyconfig" "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" ) @@ -37,24 +33,36 @@ func init() { caddy.RegisterModule(new(ZeroSSLIssuer)) } -// ZeroSSLIssuer makes an ACME issuer for getting certificates -// from ZeroSSL by automatically generating EAB credentials. -// Please be sure to set a valid email address in your config -// so you can access/manage your domains in your ZeroSSL account. -// -// This issuer is only needed for automatic generation of EAB -// credentials. If manually configuring/reusing EAB credentials, -// the standard ACMEIssuer may be used if desired. +// ZeroSSLIssuer uses the ZeroSSL API to get certificates. +// Note that this is distinct from ZeroSSL's ACME endpoint. +// To use ZeroSSL's ACME endpoint, use the ACMEIssuer +// configured with ZeroSSL's ACME directory endpoint. type ZeroSSLIssuer struct { - *ACMEIssuer - // The API key (or "access key") for using the ZeroSSL API. - // This is optional, but can be used if you have an API key - // already and don't want to supply your email address. + // REQUIRED. APIKey string `json:"api_key,omitempty"` - mu sync.Mutex - logger *zap.Logger + // How many days the certificate should be valid for. + // Only certain values are accepted; see ZeroSSL docs. + ValidityDays int `json:"validity_days,omitempty"` + + // The host to bind to when opening a listener for + // verifying domain names (or IPs). + ListenHost string `json:"listen_host,omitempty"` + + // If HTTP is forwarded from port 80, specify the + // forwarded port here. + AlternateHTTPPort int `json:"alternate_http_port,omitempty"` + + // Use CNAME validation instead of HTTP. ZeroSSL's + // API uses CNAME records for DNS validation, similar + // to how Let's Encrypt uses TXT records for the + // DNS challenge. + CNAMEValidation *DNSChallengeConfig `json:"cname_validation,omitempty"` + + logger *zap.Logger + storage certmagic.Storage + issuer *certmagic.ZeroSSLIssuer } // CaddyModule returns the Caddy module information. @@ -65,178 +73,184 @@ func (*ZeroSSLIssuer) CaddyModule() caddy.ModuleInfo { } } -// Provision sets up iss. +// Provision sets up the issuer. func (iss *ZeroSSLIssuer) Provision(ctx caddy.Context) error { iss.logger = ctx.Logger() - if iss.ACMEIssuer == nil { - iss.ACMEIssuer = new(ACMEIssuer) - } - if iss.ACMEIssuer.CA == "" { - iss.ACMEIssuer.CA = certmagic.ZeroSSLProductionCA - } - return iss.ACMEIssuer.Provision(ctx) -} + iss.storage = ctx.Storage() + repl := caddy.NewReplacer() -// newAccountCallback generates EAB if not already provided. It also sets a valid default contact on the account if not set. -func (iss *ZeroSSLIssuer) newAccountCallback(ctx context.Context, acmeIss *certmagic.ACMEIssuer, acct acme.Account) (acme.Account, error) { - if acmeIss.ExternalAccount != nil { - return acct, nil - } - var err error - acmeIss.ExternalAccount, acct, err = iss.generateEABCredentials(ctx, acct) - return acct, err -} - -// generateEABCredentials generates EAB credentials using the API key if provided, -// otherwise using the primary contact email on the issuer. If an email is not set -// on the issuer, a default generic email is used. -func (iss *ZeroSSLIssuer) generateEABCredentials(ctx context.Context, acct acme.Account) (*acme.EAB, acme.Account, error) { - var endpoint string - var body io.Reader - - // there are two ways to generate EAB credentials: authenticated with - // their API key, or unauthenticated with their email address - if iss.APIKey != "" { - apiKey := caddy.NewReplacer().ReplaceAll(iss.APIKey, "") - if apiKey == "" { - return nil, acct, fmt.Errorf("missing API key: '%v'", iss.APIKey) - } - qs := url.Values{"access_key": []string{apiKey}} - endpoint = fmt.Sprintf("%s/eab-credentials?%s", zerosslAPIBase, qs.Encode()) - } else { - email := iss.Email - if email == "" { - iss.logger.Warn("missing email address for ZeroSSL; it is strongly recommended to set one for next time") - email = "[email protected]" // special email address that preserves backwards-compat, but which black-holes dashboard features, oh well + var dnsManager *certmagic.DNSManager + if iss.CNAMEValidation != nil && len(iss.CNAMEValidation.ProviderRaw) > 0 { + val, err := ctx.LoadModule(iss.CNAMEValidation, "ProviderRaw") + if err != nil { + return fmt.Errorf("loading DNS provider module: %v", err) } - if len(acct.Contact) == 0 { - // we borrow the email from config or the default email, so ensure it's saved with the account - acct.Contact = []string{"mailto:" + email} + dnsManager = &certmagic.DNSManager{ + DNSProvider: val.(certmagic.DNSProvider), + TTL: time.Duration(iss.CNAMEValidation.TTL), + PropagationDelay: time.Duration(iss.CNAMEValidation.PropagationDelay), + PropagationTimeout: time.Duration(iss.CNAMEValidation.PropagationTimeout), + Resolvers: iss.CNAMEValidation.Resolvers, + OverrideDomain: iss.CNAMEValidation.OverrideDomain, + Logger: iss.logger.Named("cname"), } - endpoint = zerosslAPIBase + "/eab-credentials-email" - form := url.Values{"email": []string{email}} - body = strings.NewReader(form.Encode()) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, body) - if err != nil { - return nil, acct, fmt.Errorf("forming request: %v", err) - } - if body != nil { - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - } - req.Header.Set("User-Agent", certmagic.UserAgent) - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, acct, fmt.Errorf("performing EAB credentials request: %v", err) - } - defer resp.Body.Close() - - var result struct { - Success bool `json:"success"` - Error struct { - Code int `json:"code"` - Type string `json:"type"` - } `json:"error"` - EABKID string `json:"eab_kid"` - EABHMACKey string `json:"eab_hmac_key"` - } - err = json.NewDecoder(resp.Body).Decode(&result) - if err != nil { - return nil, acct, fmt.Errorf("decoding API response: %v", err) - } - if result.Error.Code != 0 { - return nil, acct, fmt.Errorf("failed getting EAB credentials: HTTP %d: %s (code %d)", - resp.StatusCode, result.Error.Type, result.Error.Code) } - if resp.StatusCode != http.StatusOK { - return nil, acct, fmt.Errorf("failed getting EAB credentials: HTTP %d", resp.StatusCode) - } - - iss.logger.Info("generated EAB credentials", zap.String("key_id", result.EABKID)) - - return &acme.EAB{ - KeyID: result.EABKID, - MACKey: result.EABHMACKey, - }, acct, nil -} -// initialize modifies the template for the underlying ACMEIssuer -// values by setting the CA endpoint to the ZeroSSL directory and -// setting the NewAccountFunc callback to one which allows us to -// generate EAB credentials only if a new account is being made. -// Since it modifies the stored template, its effect should only -// be needed once, but it is fine to call it repeatedly. -func (iss *ZeroSSLIssuer) initialize() { - iss.mu.Lock() - defer iss.mu.Unlock() - if iss.ACMEIssuer.issuer.NewAccountFunc == nil { - iss.ACMEIssuer.issuer.NewAccountFunc = iss.newAccountCallback + iss.issuer = &certmagic.ZeroSSLIssuer{ + APIKey: repl.ReplaceAll(iss.APIKey, ""), + ValidityDays: iss.ValidityDays, + ListenHost: iss.ListenHost, + AltHTTPPort: iss.AlternateHTTPPort, + Storage: iss.storage, + CNAMEValidation: dnsManager, + Logger: iss.logger, } -} -// PreCheck implements the certmagic.PreChecker interface. -func (iss *ZeroSSLIssuer) PreCheck(ctx context.Context, names []string, interactive bool) error { - iss.initialize() - return iss.ACMEIssuer.PreCheck(ctx, names, interactive) + return nil } // Issue obtains a certificate for the given csr. func (iss *ZeroSSLIssuer) Issue(ctx context.Context, csr *x509.CertificateRequest) (*certmagic.IssuedCertificate, error) { - iss.initialize() - return iss.ACMEIssuer.Issue(ctx, csr) + return iss.issuer.Issue(ctx, csr) } // IssuerKey returns the unique issuer key for the configured CA endpoint. func (iss *ZeroSSLIssuer) IssuerKey() string { - iss.initialize() - return iss.ACMEIssuer.IssuerKey() + return iss.issuer.IssuerKey() } // Revoke revokes the given certificate. func (iss *ZeroSSLIssuer) Revoke(ctx context.Context, cert certmagic.CertificateResource, reason int) error { - iss.initialize() - return iss.ACMEIssuer.Revoke(ctx, cert, reason) + return iss.issuer.Revoke(ctx, cert, reason) } // UnmarshalCaddyfile deserializes Caddyfile tokens into iss. // -// ... zerossl [<api_key>] { -// ... +// ... zerossl <api_key> { +// validity_days <days> +// alt_http_port <port> +// dns <provider_name> ... +// propagation_delay <duration> +// propagation_timeout <duration> +// resolvers <list...> +// dns_ttl <duration> // } -// -// Any of the subdirectives for the ACME issuer can be used in the block. func (iss *ZeroSSLIssuer) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { d.Next() // consume issuer name + + // API key is required + if !d.NextArg() { + return d.ArgErr() + } + iss.APIKey = d.Val() if d.NextArg() { - iss.APIKey = d.Val() - if d.NextArg() { - return d.ArgErr() - } + return d.ArgErr() } - if iss.ACMEIssuer == nil { - iss.ACMEIssuer = new(ACMEIssuer) - } - err := iss.ACMEIssuer.UnmarshalCaddyfile(d.NewFromNextSegment()) - if err != nil { - return err + for nesting := d.Nesting(); d.NextBlock(nesting); { + switch d.Val() { + case "validity_days": + if iss.ValidityDays != 0 { + return d.Errf("validity days is already specified: %d", iss.ValidityDays) + } + days, err := strconv.Atoi(d.Val()) + if err != nil { + return d.Errf("invalid number of days %s: %v", d.Val(), err) + } + iss.ValidityDays = days + + case "alt_http_port": + if !d.NextArg() { + return d.ArgErr() + } + port, err := strconv.Atoi(d.Val()) + if err != nil { + return d.Errf("invalid port %s: %v", d.Val(), err) + } + iss.AlternateHTTPPort = port + + case "dns": + if !d.NextArg() { + return d.ArgErr() + } + provName := d.Val() + if iss.CNAMEValidation == nil { + iss.CNAMEValidation = new(DNSChallengeConfig) + } + unm, err := caddyfile.UnmarshalModule(d, "dns.providers."+provName) + if err != nil { + return err + } + iss.CNAMEValidation.ProviderRaw = caddyconfig.JSONModuleObject(unm, "name", provName, nil) + + case "propagation_delay": + if !d.NextArg() { + return d.ArgErr() + } + delayStr := d.Val() + delay, err := caddy.ParseDuration(delayStr) + if err != nil { + return d.Errf("invalid propagation_delay duration %s: %v", delayStr, err) + } + if iss.CNAMEValidation == nil { + iss.CNAMEValidation = new(DNSChallengeConfig) + } + iss.CNAMEValidation.PropagationDelay = caddy.Duration(delay) + + case "propagation_timeout": + if !d.NextArg() { + return d.ArgErr() + } + timeoutStr := d.Val() + var timeout time.Duration + if timeoutStr == "-1" { + timeout = time.Duration(-1) + } else { + var err error + timeout, err = caddy.ParseDuration(timeoutStr) + if err != nil { + return d.Errf("invalid propagation_timeout duration %s: %v", timeoutStr, err) + } + } + if iss.CNAMEValidation == nil { + iss.CNAMEValidation = new(DNSChallengeConfig) + } + iss.CNAMEValidation.PropagationTimeout = caddy.Duration(timeout) + + case "resolvers": + if iss.CNAMEValidation == nil { + iss.CNAMEValidation = new(DNSChallengeConfig) + } + iss.CNAMEValidation.Resolvers = d.RemainingArgs() + if len(iss.CNAMEValidation.Resolvers) == 0 { + return d.ArgErr() + } + + case "dns_ttl": + if !d.NextArg() { + return d.ArgErr() + } + ttlStr := d.Val() + ttl, err := caddy.ParseDuration(ttlStr) + if err != nil { + return d.Errf("invalid dns_ttl duration %s: %v", ttlStr, err) + } + if iss.CNAMEValidation == nil { + iss.CNAMEValidation = new(DNSChallengeConfig) + } + iss.CNAMEValidation.TTL = caddy.Duration(ttl) + + default: + return d.Errf("unrecognized zerossl issuer property: %s", d.Val()) + } } + return nil } -const zerosslAPIBase = "https://api.zerossl.com/acme" - // Interface guards var ( - _ certmagic.PreChecker = (*ZeroSSLIssuer)(nil) - _ certmagic.Issuer = (*ZeroSSLIssuer)(nil) - _ certmagic.Revoker = (*ZeroSSLIssuer)(nil) - _ caddy.Provisioner = (*ZeroSSLIssuer)(nil) - _ ConfigSetter = (*ZeroSSLIssuer)(nil) - - // a type which properly embeds an ACMEIssuer should implement - // this interface so it can be treated as an ACMEIssuer - _ interface{ GetACMEIssuer() *ACMEIssuer } = (*ZeroSSLIssuer)(nil) + _ certmagic.Issuer = (*ZeroSSLIssuer)(nil) + _ certmagic.Revoker = (*ZeroSSLIssuer)(nil) + _ caddy.Provisioner = (*ZeroSSLIssuer)(nil) ) |