From 13781e67ab1b2553598d0dd1a7153ce3cdbd4879 Mon Sep 17 00:00:00 2001 From: Matt Holt Date: Mon, 16 Nov 2020 11:05:55 -0700 Subject: caddytls: Support multiple issuers (#3862) * caddytls: Support multiple issuers Defaults are Let's Encrypt and ZeroSSL. There are probably bugs. * Commit updated integration tests, d'oh * Update go.mod --- modules/caddytls/acmeissuer.go | 9 +++++ modules/caddytls/automation.go | 84 +++++++++++++++++++++++---------------- modules/caddytls/tls.go | 20 +++++----- modules/caddytls/zerosslissuer.go | 40 +++++++------------ 4 files changed, 85 insertions(+), 68 deletions(-) (limited to 'modules/caddytls') diff --git a/modules/caddytls/acmeissuer.go b/modules/caddytls/acmeissuer.go index 6466229..7c79c7e 100644 --- a/modules/caddytls/acmeissuer.go +++ b/modules/caddytls/acmeissuer.go @@ -97,6 +97,15 @@ func (ACMEIssuer) CaddyModule() caddy.ModuleInfo { func (iss *ACMEIssuer) Provision(ctx caddy.Context) error { iss.logger = ctx.Logger(iss) + // expand email address, if non-empty + if iss.Email != "" { + email, err := caddy.NewReplacer().ReplaceOrErr(iss.Email, true, true) + if err != nil { + return fmt.Errorf("expanding email address '%s': %v", iss.Email, err) + } + iss.Email = email + } + // DNS providers if iss.Challenges != nil && iss.Challenges.DNS != nil && iss.Challenges.DNS.ProviderRaw != nil { val, err := ctx.LoadModule(iss.Challenges.DNS, "ProviderRaw") diff --git a/modules/caddytls/automation.go b/modules/caddytls/automation.go index 1612391..509ad6e 100644 --- a/modules/caddytls/automation.go +++ b/modules/caddytls/automation.go @@ -23,7 +23,6 @@ import ( "github.com/caddyserver/caddy/v2" "github.com/caddyserver/certmagic" "github.com/mholt/acmez" - "go.uber.org/zap" ) // AutomationConfig governs the automated management of TLS certificates. @@ -72,8 +71,13 @@ type AutomationPolicy struct { // Which subjects (hostnames or IP addresses) this policy applies to. Subjects []string `json:"subjects,omitempty"` - // The module that will issue certificates. Default: internal if all - // subjects do not qualify for public certificates; othewise acme. + // The modules that may issue certificates. Default: internal if all + // subjects do not qualify for public certificates; othewise acme and + // zerossl. + IssuersRaw []json.RawMessage `json:"issuers,omitempty" caddy:"namespace=tls.issuance inline_key=module"` + + // DEPRECATED: Use `issuers` instead (November 2020). This field will + // be removed in the future. IssuerRaw json.RawMessage `json:"issuer,omitempty" caddy:"namespace=tls.issuance inline_key=module"` // If true, certificates will be requested with MustStaple. Not all @@ -103,10 +107,10 @@ type AutomationPolicy struct { // load. OnDemand bool `json:"on_demand,omitempty"` - // Issuer stores the decoded issuer parameters. This is only - // used to populate an underlying certmagic.Config's Issuer + // Issuers stores the decoded issuer parameters. This is only + // used to populate an underlying certmagic.Config's Issuers // field; it is not referenced thereafter. - Issuer certmagic.Issuer `json:"-"` + Issuers []certmagic.Issuer `json:"-"` magic *certmagic.Config storage certmagic.Storage @@ -150,34 +154,30 @@ func (ap *AutomationPolicy) Provision(tlsApp *TLS) error { } } - // if this automation policy has no Issuer defined, and - // none of the subjects qualify for a public certificate, - // set the issuer to internal so that these names can all - // get certificates; critically, we can only do this if an - // issuer is not explicitly configured (IssuerRaw, vs. just - // Issuer) AND if the list of subjects is non-empty - if ap.IssuerRaw == nil && len(ap.Subjects) > 0 { - var anyPublic bool - for _, s := range ap.Subjects { - if certmagic.SubjectQualifiesForPublicCert(s) { - anyPublic = true - break - } + // TODO: IssuerRaw field deprecated as of November 2020 - remove this shim after deprecation is complete + if ap.IssuerRaw != nil { + tlsApp.logger.Warn("the 'issuer' field is deprecated and will be removed in the future; use 'issuers' instead; your issuer has been appended automatically for now") + ap.IssuersRaw = append(ap.IssuersRaw, ap.IssuerRaw) + } + + // load and provision any explicitly-configured issuer modules + if ap.IssuersRaw != nil { + val, err := tlsApp.ctx.LoadModule(ap, "IssuersRaw") + if err != nil { + return fmt.Errorf("loading TLS automation management module: %s", err) } - if !anyPublic { - tlsApp.logger.Info("setting internal issuer for automation policy that has only internal subjects but no issuer configured", - zap.Strings("subjects", ap.Subjects)) - ap.IssuerRaw = json.RawMessage(`{"module":"internal"}`) + for _, issVal := range val.([]interface{}) { + ap.Issuers = append(ap.Issuers, issVal.(certmagic.Issuer)) } } - // load and provision any explicitly-configured issuer module - if ap.IssuerRaw != nil { - val, err := tlsApp.ctx.LoadModule(ap, "IssuerRaw") + issuers := ap.Issuers + if len(issuers) == 0 { + var err error + issuers, err = DefaultIssuers(tlsApp.ctx) if err != nil { - return fmt.Errorf("loading TLS automation management module: %s", err) + return err } - ap.Issuer = val.(certmagic.Issuer) } keyType := ap.KeyType @@ -206,12 +206,9 @@ func (ap *AutomationPolicy) Provision(tlsApp *TLS) error { KeySource: keySource, OnDemand: ond, Storage: storage, - Issuer: ap.Issuer, // if nil, certmagic.New() will create one + Issuers: issuers, Logger: tlsApp.logger, } - if rev, ok := ap.Issuer.(certmagic.Revoker); ok { - template.Revoker = rev - } ap.magic = certmagic.New(tlsApp.certCache, template) // sometimes issuers may need the parent certmagic.Config in @@ -219,13 +216,32 @@ func (ap *AutomationPolicy) Provision(tlsApp *TLS) error { // access to the correct storage and cache so it can solve // ACME challenges -- it's an annoying, inelegant circular // dependency that I don't know how to resolve nicely!) - if annoying, ok := ap.Issuer.(ConfigSetter); ok { - annoying.SetConfig(ap.magic) + for _, issuer := range ap.magic.Issuers { + if annoying, ok := issuer.(ConfigSetter); ok { + annoying.SetConfig(ap.magic) + } } return nil } +// DefaultIssuers returns empty but provisioned default Issuers. +// This function is experimental and has no compatibility promises. +func DefaultIssuers(ctx caddy.Context) ([]certmagic.Issuer, error) { + acme := new(ACMEIssuer) + err := acme.Provision(ctx) + if err != nil { + return nil, err + } + zerossl := new(ZeroSSLIssuer) + err = zerossl.Provision(ctx) + if err != nil { + return nil, err + } + // TODO: eventually, insert ZeroSSL into first position in the slice -- see also httpcaddyfile/tlsapp.go for where similar defaults are configured + return []certmagic.Issuer{acme, zerossl}, nil +} + // ChallengesConfig configures the ACME challenges. type ChallengesConfig struct { // HTTP configures the ACME HTTP challenge. This diff --git a/modules/caddytls/tls.go b/modules/caddytls/tls.go index 12d25ad..146eed4 100644 --- a/modules/caddytls/tls.go +++ b/modules/caddytls/tls.go @@ -137,7 +137,7 @@ func (t *TLS) Provision(ctx caddy.Context) error { continue } t.Automation.defaultInternalAutomationPolicy = &AutomationPolicy{ - IssuerRaw: json.RawMessage(`{"module":"internal"}`), + IssuersRaw: []json.RawMessage{json.RawMessage(`{"module":"internal"}`)}, } err = t.Automation.defaultInternalAutomationPolicy.Provision(t) if err != nil { @@ -303,20 +303,22 @@ func (t *TLS) Manage(names []string) error { // 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 issue of type -// *certmagic.ACMEManager. +// requires that the automation policy for r.Host has an issuer of type +// *certmagic.ACMEManager, or one that is ACME-enabled (GetACMEIssuer()). func (t *TLS) HandleHTTPChallenge(w http.ResponseWriter, r *http.Request) bool { if !certmagic.LooksLikeHTTPChallenge(r) { return false } + // try all the issuers until we find the one that initiated the challenge ap := t.getAutomationPolicyForName(r.Host) - if ap.magic.Issuer == nil { - return false - } type acmeCapable interface{ GetACMEIssuer() *ACMEIssuer } - if am, ok := ap.magic.Issuer.(acmeCapable); ok { - iss := am.GetACMEIssuer() - return certmagic.NewACMEManager(iss.magic, iss.template).HandleHTTPChallenge(w, r) + for _, iss := range ap.magic.Issuers { + if am, ok := iss.(acmeCapable); ok { + iss := am.GetACMEIssuer() + if certmagic.NewACMEManager(iss.magic, iss.template).HandleHTTPChallenge(w, r) { + return true + } + } } return false } diff --git a/modules/caddytls/zerosslissuer.go b/modules/caddytls/zerosslissuer.go index d0f4950..4680d1b 100644 --- a/modules/caddytls/zerosslissuer.go +++ b/modules/caddytls/zerosslissuer.go @@ -59,16 +59,13 @@ func (*ZeroSSLIssuer) CaddyModule() caddy.ModuleInfo { // Provision sets up iss. func (iss *ZeroSSLIssuer) Provision(ctx caddy.Context) error { iss.logger = ctx.Logger(iss) - if iss.ACMEIssuer == nil { iss.ACMEIssuer = new(ACMEIssuer) } - err := iss.ACMEIssuer.Provision(ctx) - if err != nil { - return err + if iss.ACMEIssuer.CA == "" { + iss.ACMEIssuer.CA = certmagic.ZeroSSLProductionCA } - - return nil + return iss.ACMEIssuer.Provision(ctx) } func (iss *ZeroSSLIssuer) newAccountCallback(ctx context.Context, am *certmagic.ACMEManager, _ acme.Account) error { @@ -86,26 +83,22 @@ func (iss *ZeroSSLIssuer) generateEABCredentials(ctx context.Context) (*acme.EAB // there are two ways to generate EAB credentials: authenticated with // their API key, or unauthenticated with their email address - switch { - case iss.APIKey != "": + if iss.APIKey != "" { apiKey := caddy.NewReplacer().ReplaceAll(iss.APIKey, "") if apiKey == "" { return nil, 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()) - - case iss.Email != "": - email := caddy.NewReplacer().ReplaceAll(iss.Email, "") + } else { + email := iss.Email if email == "" { - return nil, fmt.Errorf("missing email: '%v'", iss.Email) + iss.logger.Warn("missing email address for ZeroSSL; it is strongly recommended to set one for next time") + email = "caddy@zerossl.com" // special email address that preserves backwards-compat, but which black-holes dashboard features, oh well } endpoint = zerosslAPIBase + "/eab-credentials-email" form := url.Values{"email": []string{email}} body = strings.NewReader(form.Encode()) - - default: - return nil, fmt.Errorf("must configure either an API key or email address to use ZeroSSL without explicit EAB") } req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, body) @@ -161,9 +154,6 @@ func (iss *ZeroSSLIssuer) generateEABCredentials(ctx context.Context) (*acme.EAB func (iss *ZeroSSLIssuer) initialize() { iss.mu.Lock() defer iss.mu.Unlock() - if iss.template.CA == "" { - iss.template.CA = zerosslACMEDirectory - } if iss.template.NewAccountFunc == nil { iss.template.NewAccountFunc = iss.newAccountCallback } @@ -195,15 +185,18 @@ func (iss *ZeroSSLIssuer) Revoke(ctx context.Context, cert certmagic.Certificate // UnmarshalCaddyfile deserializes Caddyfile tokens into iss. // -// ... zerossl { +// ... zerossl [] { // ... // } // // Any of the subdirectives for the ACME issuer can be used in the block. func (iss *ZeroSSLIssuer) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { for d.Next() { - if !d.AllArgs(&iss.APIKey) { - return d.ArgErr() + if d.NextArg() { + iss.APIKey = d.Val() + if d.NextArg() { + return d.ArgErr() + } } if iss.ACMEIssuer == nil { @@ -217,10 +210,7 @@ func (iss *ZeroSSLIssuer) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { return nil } -const ( - zerosslACMEDirectory = "https://acme.zerossl.com/v2/DV90" - zerosslAPIBase = "https://api.zerossl.com/acme" -) +const zerosslAPIBase = "https://api.zerossl.com/acme" // Interface guards var ( -- cgit v1.2.3