diff --git a/caddyconfig/httpcaddyfile/builtins.go b/caddyconfig/httpcaddyfile/builtins.go index 520796d9..e8fbf235 100644 --- a/caddyconfig/httpcaddyfile/builtins.go +++ b/caddyconfig/httpcaddyfile/builtins.go @@ -29,6 +29,7 @@ import ( "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" "github.com/caddyserver/caddy/v2/modules/caddyhttp" "github.com/caddyserver/caddy/v2/modules/caddytls" + "github.com/caddyserver/certmagic" "github.com/mholt/acmez/acme" "go.uber.org/zap/zapcore" ) @@ -76,6 +77,7 @@ func parseBind(h Helper) ([]ConfigValue, error) { // ca_root // dns // on_demand +// issuer ... // } // func parseTLS(h Helper) ([]ConfigValue, error) { @@ -85,6 +87,7 @@ func parseTLS(h Helper) ([]ConfigValue, error) { var certSelector caddytls.CustomCertSelectionPolicy var acmeIssuer *caddytls.ACMEIssuer var internalIssuer *caddytls.InternalIssuer + var issuer certmagic.Issuer var onDemand bool for h.Next() { @@ -276,6 +279,28 @@ func parseTLS(h Helper) ([]ConfigValue, error) { MACKey: arg[1], } + case "issuer": + if !h.NextArg() { + return nil, h.ArgErr() + } + modName := h.Val() + mod, err := caddy.GetModule("tls.issuance." + modName) + if err != nil { + return nil, h.Errf("getting issuer module '%s': %v", modName, err) + } + unm, ok := mod.New().(caddyfile.Unmarshaler) + if !ok { + return nil, h.Errf("issuer module '%s' is not a Caddyfile unmarshaler", mod.ID) + } + err = unm.UnmarshalCaddyfile(h.NewFromNextSegment()) + if err != nil { + return nil, err + } + issuer, ok = unm.(certmagic.Issuer) + if !ok { + return nil, h.Errf("module %s is not a certmagic.Issuer", mod.ID) + } + case "dns": if !h.NextArg() { return nil, h.ArgErr() @@ -350,7 +375,24 @@ func parseTLS(h Helper) ([]ConfigValue, error) { // the logic to support this would be complex return nil, h.Err("cannot use both ACME and internal issuers in same server block") } - if acmeIssuer != nil { + if issuer != nil && (acmeIssuer != nil || internalIssuer != nil) { + // similarly, the logic to support this would be complex + return nil, h.Err("when defining an issuer, all its config must be in its block, rather than from separate tls subdirectives") + } + switch { + case issuer != nil: + configVals = append(configVals, ConfigValue{ + Class: "tls.cert_issuer", + Value: issuer, + }) + + case internalIssuer != nil: + configVals = append(configVals, ConfigValue{ + Class: "tls.cert_issuer", + Value: internalIssuer, + }) + + case acmeIssuer != nil: // fill in global defaults, if configured if email := h.Option("email"); email != nil && acmeIssuer.Email == "" { acmeIssuer.Email = email.(string) @@ -361,15 +403,9 @@ func parseTLS(h Helper) ([]ConfigValue, error) { if caPemFile := h.Option("acme_ca_root"); caPemFile != nil { acmeIssuer.TrustedRootsPEMFiles = append(acmeIssuer.TrustedRootsPEMFiles, caPemFile.(string)) } - configVals = append(configVals, ConfigValue{ Class: "tls.cert_issuer", - Value: acmeIssuer, - }) - } else if internalIssuer != nil { - configVals = append(configVals, ConfigValue{ - Class: "tls.cert_issuer", - Value: internalIssuer, + Value: disambiguateACMEIssuer(acmeIssuer), }) } diff --git a/caddyconfig/httpcaddyfile/options.go b/caddyconfig/httpcaddyfile/options.go index 613bbc60..7d34805c 100644 --- a/caddyconfig/httpcaddyfile/options.go +++ b/caddyconfig/httpcaddyfile/options.go @@ -20,6 +20,7 @@ import ( "github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" "github.com/caddyserver/caddy/v2/modules/caddytls" + "github.com/caddyserver/certmagic" "github.com/mholt/acmez/acme" ) @@ -35,6 +36,7 @@ func init() { RegisterGlobalOption("acme_ca_root", parseOptSingleString) RegisterGlobalOption("acme_dns", parseOptSingleString) RegisterGlobalOption("acme_eab", parseOptACMEEAB) + RegisterGlobalOption("cert_issuer", parseOptCertIssuer) RegisterGlobalOption("email", parseOptSingleString) RegisterGlobalOption("admin", parseOptAdmin) RegisterGlobalOption("on_demand_tls", parseOptOnDemand) @@ -210,6 +212,33 @@ func parseOptACMEEAB(d *caddyfile.Dispenser) (interface{}, error) { return eab, nil } +func parseOptCertIssuer(d *caddyfile.Dispenser) (interface{}, error) { + if !d.Next() { // consume option name + return nil, d.ArgErr() + } + if !d.Next() { // get issuer module name + return nil, d.ArgErr() + } + modName := d.Val() + mod, err := caddy.GetModule("tls.issuance." + modName) + if err != nil { + return nil, d.Errf("getting issuer module '%s': %v", modName, err) + } + unm, ok := mod.New().(caddyfile.Unmarshaler) + if !ok { + return nil, d.Errf("issuer module '%s' is not a Caddyfile unmarshaler", mod.ID) + } + err = unm.UnmarshalCaddyfile(d.NewFromNextSegment()) + if err != nil { + return nil, err + } + iss, ok := unm.(certmagic.Issuer) + if !ok { + return nil, d.Errf("module %s is not a certmagic.Issuer", mod.ID) + } + return iss, nil +} + func parseOptSingleString(d *caddyfile.Dispenser) (interface{}, error) { d.Next() // consume parameter name if !d.Next() { diff --git a/caddyconfig/httpcaddyfile/tlsapp.go b/caddyconfig/httpcaddyfile/tlsapp.go index db6bf98c..a721fee3 100644 --- a/caddyconfig/httpcaddyfile/tlsapp.go +++ b/caddyconfig/httpcaddyfile/tlsapp.go @@ -21,6 +21,7 @@ import ( "reflect" "sort" "strconv" + "strings" "github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2/caddyconfig" @@ -135,8 +136,11 @@ func (st ServerType) buildTLSApp( // issuer, skip, since we intend to adjust only ACME issuers var acmeIssuer *caddytls.ACMEIssuer if ap.Issuer != nil { - var ok bool - if acmeIssuer, ok = ap.Issuer.(*caddytls.ACMEIssuer); !ok { + // ensure we include any issuer that embeds/wraps an underlying ACME issuer + type acmeCapable interface{ GetACMEIssuer() *caddytls.ACMEIssuer } + if acmeWrapper, ok := ap.Issuer.(acmeCapable); ok { + acmeIssuer = acmeWrapper.GetACMEIssuer() + } else { break } } @@ -348,6 +352,8 @@ func (st ServerType) buildTLSApp( // returned if there are no default/global options. However, if always is // true, a non-nil value will always be returned (unless there is an error). func newBaseAutomationPolicy(options map[string]interface{}, warnings []caddyconfig.Warning, always bool) (*caddytls.AutomationPolicy, error) { + issuer, hasIssuer := options["cert_issuer"] + acmeCA, hasACMECA := options["acme_ca"] acmeCARoot, hasACMECARoot := options["acme_ca_root"] acmeDNS, hasACMEDNS := options["acme_dns"] @@ -357,7 +363,7 @@ func newBaseAutomationPolicy(options map[string]interface{}, warnings []caddycon localCerts, hasLocalCerts := options["local_certs"] keyType, hasKeyType := options["key_type"] - hasGlobalAutomationOpts := hasACMECA || hasACMECARoot || hasACMEDNS || hasACMEEAB || hasEmail || hasLocalCerts || hasKeyType + hasGlobalAutomationOpts := hasIssuer || hasACMECA || hasACMECARoot || hasACMEDNS || hasACMEEAB || hasEmail || hasLocalCerts || hasKeyType // if there are no global options related to automation policies // set, then we can just return right away @@ -369,8 +375,16 @@ func newBaseAutomationPolicy(options map[string]interface{}, warnings []caddycon } ap := new(caddytls.AutomationPolicy) + if keyType != nil { + ap.KeyType = keyType.(string) + } - if localCerts != nil { + if hasIssuer { + if hasACMECA || hasACMEDNS || hasACMEEAB || hasEmail || hasLocalCerts { + return nil, fmt.Errorf("global options are ambiguous: cert_issuer is confusing when combined with acme_*, email, or local_certs options") + } + ap.Issuer = issuer.(certmagic.Issuer) + } else if localCerts != nil { // internal issuer enabled trumps any ACME configurations; useful in testing ap.Issuer = new(caddytls.InternalIssuer) // we'll encode it later } else { @@ -402,15 +416,25 @@ func newBaseAutomationPolicy(options map[string]interface{}, warnings []caddycon if acmeEAB != nil { mgr.ExternalAccount = acmeEAB.(*acme.EAB) } - if keyType != nil { - ap.KeyType = keyType.(string) - } - ap.Issuer = mgr // we'll encode it later + ap.Issuer = disambiguateACMEIssuer(mgr) // we'll encode it later } return ap, nil } +// disambiguateACMEIssuer returns an issuer based on the properties of acmeIssuer. +// If acmeIssuer implicitly configures a certain kind of ACMEIssuer (for example, +// ZeroSSL), the proper wrapper over acmeIssuer will be returned instead. +func disambiguateACMEIssuer(acmeIssuer *caddytls.ACMEIssuer) certmagic.Issuer { + // as a special case, we integrate with ZeroSSL's ACME endpoint if it looks like an + // implicit ZeroSSL configuration (this requires a wrapper type over ACMEIssuer + // because of the EAB generation; if EAB is provided, we can use plain ACMEIssuer) + if strings.Contains(acmeIssuer.CA, "acme.zerossl.com") && acmeIssuer.ExternalAccount == nil { + return &caddytls.ZeroSSLIssuer{ACMEIssuer: acmeIssuer} + } + return acmeIssuer +} + // consolidateAutomationPolicies combines automation policies that are the same, // for a cleaner overall output. func consolidateAutomationPolicies(aps []*caddytls.AutomationPolicy) []*caddytls.AutomationPolicy { diff --git a/caddytest/integration/caddyfile_adapt/global_options.txt b/caddytest/integration/caddyfile_adapt/global_options.txt index 7e850a39..65628247 100644 --- a/caddytest/integration/caddyfile_adapt/global_options.txt +++ b/caddytest/integration/caddyfile_adapt/global_options.txt @@ -56,7 +56,8 @@ { "issuer": { "module": "internal" - } + }, + "key_type": "ed25519" } ], "on_demand": { diff --git a/caddytest/integration/caddyfile_adapt/global_options_admin.txt b/caddytest/integration/caddyfile_adapt/global_options_admin.txt index 14bee216..9d0fb270 100644 --- a/caddytest/integration/caddyfile_adapt/global_options_admin.txt +++ b/caddytest/integration/caddyfile_adapt/global_options_admin.txt @@ -64,7 +64,8 @@ { "issuer": { "module": "internal" - } + }, + "key_type": "ed25519" } ], "on_demand": { diff --git a/go.mod b/go.mod index 30406919..931c5ad1 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/Masterminds/sprig/v3 v3.1.0 github.com/alecthomas/chroma v0.8.0 github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a - github.com/caddyserver/certmagic v0.11.3-0.20200808143826-2e100d6d0bcf + github.com/caddyserver/certmagic v0.11.3-0.20200810220624-10a8b5c72339 github.com/dustin/go-humanize v1.0.1-0.20200219035652-afde56e7acac github.com/go-chi/chi v4.1.2+incompatible github.com/google/cel-go v0.5.1 @@ -14,7 +14,7 @@ require ( github.com/klauspost/compress v1.10.10 github.com/klauspost/cpuid v1.2.5 github.com/lucas-clemente/quic-go v0.17.3 - github.com/mholt/acmez v0.1.0 + github.com/mholt/acmez v0.1.1-0.20200810215816-dbe88fc6cf09 github.com/naoina/go-stringutil v0.1.0 // indirect github.com/naoina/toml v0.1.1 github.com/smallstep/certificates v0.14.6 diff --git a/go.sum b/go.sum index ecb755de..3fefecf9 100644 --- a/go.sum +++ b/go.sum @@ -86,8 +86,8 @@ github.com/bombsimon/wsl/v2 v2.0.0/go.mod h1:mf25kr/SqFEPhhcxW1+7pxzGlW+hIl/hYTK github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g= github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s= -github.com/caddyserver/certmagic v0.11.3-0.20200808143826-2e100d6d0bcf h1:Srcj7CdiavYImKMLDj8sSJKS3htwwwJB3bjPRTXh+P0= -github.com/caddyserver/certmagic v0.11.3-0.20200808143826-2e100d6d0bcf/go.mod h1:z0loWzjr6Sr8rOG2pdsiwajDpAcck2wfF8E7hTV0qT4= +github.com/caddyserver/certmagic v0.11.3-0.20200810220624-10a8b5c72339 h1:wTD+Y63XoBtiTJhe/Xn7WLrwKenmjkt2WxH3FP+Y0DM= +github.com/caddyserver/certmagic v0.11.3-0.20200810220624-10a8b5c72339/go.mod h1:mqOzOvKa7UcC+TWbBLcP0ZLRut/xaaQBw0hRGWHBIkY= github.com/census-instrumentation/opencensus-proto v0.2.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= @@ -384,8 +384,8 @@ github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/mholt/acmez v0.1.0 h1:LL7WQVCjZvwbo3pjAUdsqEao5kI8nyHubAGtCzrTS5g= -github.com/mholt/acmez v0.1.0/go.mod h1:rNLSzYu5VR6ggyarXDKDCNefo06cOkJn+VObTDJCk0U= +github.com/mholt/acmez v0.1.1-0.20200810215816-dbe88fc6cf09 h1:J7NVJ46iBFeWsUc5aeVv8QNO2mLhI6rJKIbpAsH7d7g= +github.com/mholt/acmez v0.1.1-0.20200810215816-dbe88fc6cf09/go.mod h1:8qnn8QA/Ewx8E3ZSsmscqsIjhhpxuy9vqdgbX2ceceM= github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4= github.com/miekg/dns v1.1.30 h1:Qww6FseFn8PRfw07jueqIXqodm0JKiiKuK0DeXSqfyo= github.com/miekg/dns v1.1.30/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= diff --git a/modules/caddyhttp/autohttps.go b/modules/caddyhttp/autohttps.go index 97cbed3a..07809811 100644 --- a/modules/caddyhttp/autohttps.go +++ b/modules/caddyhttp/autohttps.go @@ -451,8 +451,8 @@ func (app *App) createAutomationPolicies(ctx caddy.Context, publicNames, interna if ap.Issuer == nil { ap.Issuer = new(caddytls.ACMEIssuer) } - if acmeIssuer, ok := ap.Issuer.(*caddytls.ACMEIssuer); ok { - err := app.fillInACMEIssuer(acmeIssuer) + if acmeIssuer, ok := ap.Issuer.(acmeCapable); ok { + err := app.fillInACMEIssuer(acmeIssuer.GetACMEIssuer()) if err != nil { return err } @@ -470,9 +470,13 @@ func (app *App) createAutomationPolicies(ctx caddy.Context, publicNames, interna basePolicy = new(caddytls.AutomationPolicy) } - // if the basePolicy has an existing ACMEIssuer, let's - // use it, otherwise we'll make one - baseACMEIssuer, _ := basePolicy.Issuer.(*caddytls.ACMEIssuer) + // if the basePolicy has an existing ACMEIssuer (particularly to + // include any type that embeds/wraps an ACMEIssuer), let's use it, + // otherwise we'll make one + var baseACMEIssuer *caddytls.ACMEIssuer + if acmeWrapper, ok := basePolicy.Issuer.(acmeCapable); ok { + baseACMEIssuer = acmeWrapper.GetACMEIssuer() + } if baseACMEIssuer == nil { // note that this happens if basePolicy.Issuer is nil // OR if it is not nil but is not an ACMEIssuer @@ -630,3 +634,5 @@ func (app *App) automaticHTTPSPhase2() error { app.allCertDomains = nil // no longer needed; allow GC to deallocate return nil } + +type acmeCapable interface{ GetACMEIssuer() *caddytls.ACMEIssuer } diff --git a/modules/caddytls/acmeissuer.go b/modules/caddytls/acmeissuer.go index 8fe308d6..963143bb 100644 --- a/modules/caddytls/acmeissuer.go +++ b/modules/caddytls/acmeissuer.go @@ -20,9 +20,11 @@ import ( "fmt" "io/ioutil" "net/url" + "strconv" "time" "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" "github.com/caddyserver/certmagic" "github.com/mholt/acmez" "github.com/mholt/acmez/acme" @@ -85,13 +87,13 @@ func (ACMEIssuer) CaddyModule() caddy.ModuleInfo { } } -// Provision sets up m. -func (m *ACMEIssuer) Provision(ctx caddy.Context) error { - m.logger = ctx.Logger(m) +// Provision sets up iss. +func (iss *ACMEIssuer) Provision(ctx caddy.Context) error { + iss.logger = ctx.Logger(iss) // DNS providers - if m.Challenges != nil && m.Challenges.DNS != nil && m.Challenges.DNS.ProviderRaw != nil { - val, err := ctx.LoadModule(m.Challenges.DNS, "ProviderRaw") + if iss.Challenges != nil && iss.Challenges.DNS != nil && iss.Challenges.DNS.ProviderRaw != nil { + val, err := ctx.LoadModule(iss.Challenges.DNS, "ProviderRaw") if err != nil { return fmt.Errorf("loading DNS provider module: %v", err) } @@ -104,32 +106,32 @@ func (m *ACMEIssuer) Provision(ctx caddy.Context) error { // acmez.Solver type, so we use it directly. The user must set environment // variables to configure it. Remove this shim once a sufficient number of // DNS providers are implemented for the libdns APIs instead. - m.Challenges.DNS.solver = deprecatedProvider + iss.Challenges.DNS.solver = deprecatedProvider } else { - m.Challenges.DNS.solver = &certmagic.DNS01Solver{ + iss.Challenges.DNS.solver = &certmagic.DNS01Solver{ DNSProvider: val.(certmagic.ACMEDNSProvider), - TTL: time.Duration(m.Challenges.DNS.TTL), - PropagationTimeout: time.Duration(m.Challenges.DNS.PropagationTimeout), + TTL: time.Duration(iss.Challenges.DNS.TTL), + PropagationTimeout: time.Duration(iss.Challenges.DNS.PropagationTimeout), } } } // add any custom CAs to trust store - if len(m.TrustedRootsPEMFiles) > 0 { - m.rootPool = x509.NewCertPool() - for _, pemFile := range m.TrustedRootsPEMFiles { + if len(iss.TrustedRootsPEMFiles) > 0 { + iss.rootPool = x509.NewCertPool() + for _, pemFile := range iss.TrustedRootsPEMFiles { pemData, err := ioutil.ReadFile(pemFile) if err != nil { return fmt.Errorf("loading trusted root CA's PEM file: %s: %v", pemFile, err) } - if !m.rootPool.AppendCertsFromPEM(pemData) { + if !iss.rootPool.AppendCertsFromPEM(pemData) { return fmt.Errorf("unable to add %s to trust pool: %v", pemFile, err) } } } var err error - m.template, err = m.makeIssuerTemplate() + iss.template, err = iss.makeIssuerTemplate() if err != nil { return err } @@ -137,30 +139,30 @@ func (m *ACMEIssuer) Provision(ctx caddy.Context) error { return nil } -func (m *ACMEIssuer) makeIssuerTemplate() (certmagic.ACMEManager, error) { +func (iss *ACMEIssuer) makeIssuerTemplate() (certmagic.ACMEManager, error) { template := certmagic.ACMEManager{ - CA: m.CA, - TestCA: m.TestCA, - Email: m.Email, - CertObtainTimeout: time.Duration(m.ACMETimeout), - TrustedRoots: m.rootPool, - ExternalAccount: m.ExternalAccount, - Logger: m.logger, + CA: iss.CA, + TestCA: iss.TestCA, + Email: iss.Email, + CertObtainTimeout: time.Duration(iss.ACMETimeout), + TrustedRoots: iss.rootPool, + ExternalAccount: iss.ExternalAccount, + Logger: iss.logger, } - if m.Challenges != nil { - if m.Challenges.HTTP != nil { - template.DisableHTTPChallenge = m.Challenges.HTTP.Disabled - template.AltHTTPPort = m.Challenges.HTTP.AlternatePort + if iss.Challenges != nil { + if iss.Challenges.HTTP != nil { + template.DisableHTTPChallenge = iss.Challenges.HTTP.Disabled + template.AltHTTPPort = iss.Challenges.HTTP.AlternatePort } - if m.Challenges.TLSALPN != nil { - template.DisableTLSALPNChallenge = m.Challenges.TLSALPN.Disabled - template.AltTLSALPNPort = m.Challenges.TLSALPN.AlternatePort + if iss.Challenges.TLSALPN != nil { + template.DisableTLSALPNChallenge = iss.Challenges.TLSALPN.Disabled + template.AltTLSALPNPort = iss.Challenges.TLSALPN.AlternatePort } - if m.Challenges.DNS != nil { - template.DNS01Solver = m.Challenges.DNS.solver + if iss.Challenges.DNS != nil { + template.DNS01Solver = iss.Challenges.DNS.solver } - template.ListenHost = m.Challenges.BindHost + template.ListenHost = iss.Challenges.BindHost } return template, nil @@ -170,8 +172,8 @@ func (m *ACMEIssuer) makeIssuerTemplate() (certmagic.ACMEManager, error) { // This is required because ACME needs values from the config in // order to solve the challenges during issuance. This implements // the ConfigSetter interface. -func (m *ACMEIssuer) SetConfig(cfg *certmagic.Config) { - m.magic = cfg +func (iss *ACMEIssuer) SetConfig(cfg *certmagic.Config) { + iss.magic = cfg } // TODO: I kind of hate how each call to these methods needs to @@ -179,23 +181,147 @@ func (m *ACMEIssuer) SetConfig(cfg *certmagic.Config) { // we find the right place to do that just once and then re-use? // PreCheck implements the certmagic.PreChecker interface. -func (m *ACMEIssuer) PreCheck(ctx context.Context, names []string, interactive bool) error { - return certmagic.NewACMEManager(m.magic, m.template).PreCheck(ctx, names, interactive) +func (iss *ACMEIssuer) PreCheck(ctx context.Context, names []string, interactive bool) error { + return certmagic.NewACMEManager(iss.magic, iss.template).PreCheck(ctx, names, interactive) } // Issue obtains a certificate for the given csr. -func (m *ACMEIssuer) Issue(ctx context.Context, csr *x509.CertificateRequest) (*certmagic.IssuedCertificate, error) { - return certmagic.NewACMEManager(m.magic, m.template).Issue(ctx, csr) +func (iss *ACMEIssuer) Issue(ctx context.Context, csr *x509.CertificateRequest) (*certmagic.IssuedCertificate, error) { + return certmagic.NewACMEManager(iss.magic, iss.template).Issue(ctx, csr) } // IssuerKey returns the unique issuer key for the configured CA endpoint. -func (m *ACMEIssuer) IssuerKey() string { - return certmagic.NewACMEManager(m.magic, m.template).IssuerKey() +func (iss *ACMEIssuer) IssuerKey() string { + return certmagic.NewACMEManager(iss.magic, iss.template).IssuerKey() } // Revoke revokes the given certificate. -func (m *ACMEIssuer) Revoke(ctx context.Context, cert certmagic.CertificateResource, reason int) error { - return certmagic.NewACMEManager(m.magic, m.template).Revoke(ctx, cert, reason) +func (iss *ACMEIssuer) Revoke(ctx context.Context, cert certmagic.CertificateResource, reason int) error { + return certmagic.NewACMEManager(iss.magic, iss.template).Revoke(ctx, cert, reason) +} + +// GetACMEIssuer returns iss. This is useful when other types embed ACMEIssuer, because +// type-asserting them to *ACMEIssuer will fail, but type-asserting them to an interface +// with only this method will succeed, and will still allow the embedded ACMEIssuer +// to be accessed and manipulated. +func (iss *ACMEIssuer) GetACMEIssuer() *ACMEIssuer { return iss } + +// UnmarshalCaddyfile deserializes Caddyfile tokens into iss. +// +// ... acme { +// dir +// test_dir +// email +// timeout +// disable_http_challenge +// disable_tlsalpn_challenge +// alt_http_port +// alt_tlsalpn_port +// eab +// trusted_roots +// } +// +func (iss *ACMEIssuer) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { + for d.Next() { + for nesting := d.Nesting(); d.NextBlock(nesting); { + switch d.Val() { + case "dir": + if !d.AllArgs(&iss.CA) { + return d.ArgErr() + } + + case "test_dir": + if !d.AllArgs(&iss.TestCA) { + return d.ArgErr() + } + + case "email": + if !d.AllArgs(&iss.Email) { + return d.ArgErr() + } + + case "timeout": + var timeoutStr string + if !d.AllArgs(&timeoutStr) { + return d.ArgErr() + } + timeout, err := caddy.ParseDuration(timeoutStr) + if err != nil { + return d.Errf("invalid timeout duration %s: %v", timeoutStr, err) + } + iss.ACMETimeout = caddy.Duration(timeout) + + case "disable_http_challenge": + if d.NextArg() { + return d.ArgErr() + } + if iss.Challenges == nil { + iss.Challenges = new(ChallengesConfig) + } + if iss.Challenges.HTTP == nil { + iss.Challenges.HTTP = new(HTTPChallengeConfig) + } + iss.Challenges.HTTP.Disabled = true + + case "disable_tlsalpn_challenge": + if d.NextArg() { + return d.ArgErr() + } + if iss.Challenges == nil { + iss.Challenges = new(ChallengesConfig) + } + if iss.Challenges.TLSALPN == nil { + iss.Challenges.TLSALPN = new(TLSALPNChallengeConfig) + } + iss.Challenges.TLSALPN.Disabled = true + + 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) + } + if iss.Challenges == nil { + iss.Challenges = new(ChallengesConfig) + } + if iss.Challenges.HTTP == nil { + iss.Challenges.HTTP = new(HTTPChallengeConfig) + } + iss.Challenges.HTTP.AlternatePort = port + + case "alt_tlsalpn_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) + } + if iss.Challenges == nil { + iss.Challenges = new(ChallengesConfig) + } + if iss.Challenges.TLSALPN == nil { + iss.Challenges.TLSALPN = new(TLSALPNChallengeConfig) + } + iss.Challenges.TLSALPN.AlternatePort = port + + case "eab": + iss.ExternalAccount = new(acme.EAB) + if !d.AllArgs(&iss.ExternalAccount.KeyID, &iss.ExternalAccount.MACKey) { + return d.ArgErr() + } + + case "trusted_roots": + iss.TrustedRootsPEMFiles = d.RemainingArgs() + + default: + return d.Errf("unrecognized ACME issuer property: %s", d.Val()) + } + } + } + return nil } // onDemandAskRequest makes a request to the ask URL @@ -228,9 +354,10 @@ func onDemandAskRequest(ask string, name string) error { // Interface guards var ( - _ certmagic.PreChecker = (*ACMEIssuer)(nil) - _ certmagic.Issuer = (*ACMEIssuer)(nil) - _ certmagic.Revoker = (*ACMEIssuer)(nil) - _ caddy.Provisioner = (*ACMEIssuer)(nil) - _ ConfigSetter = (*ACMEIssuer)(nil) + _ certmagic.PreChecker = (*ACMEIssuer)(nil) + _ certmagic.Issuer = (*ACMEIssuer)(nil) + _ certmagic.Revoker = (*ACMEIssuer)(nil) + _ caddy.Provisioner = (*ACMEIssuer)(nil) + _ ConfigSetter = (*ACMEIssuer)(nil) + _ caddyfile.Unmarshaler = (*ACMEIssuer)(nil) ) diff --git a/modules/caddytls/internalissuer.go b/modules/caddytls/internalissuer.go index ca43bf8f..d70b8ca2 100644 --- a/modules/caddytls/internalissuer.go +++ b/modules/caddytls/internalissuer.go @@ -23,6 +23,7 @@ import ( "time" "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" "github.com/caddyserver/caddy/v2/modules/caddypki" "github.com/caddyserver/certmagic" "github.com/smallstep/certificates/authority/provisioner" @@ -63,25 +64,25 @@ func (InternalIssuer) CaddyModule() caddy.ModuleInfo { } // Provision sets up the issuer. -func (li *InternalIssuer) Provision(ctx caddy.Context) error { +func (iss *InternalIssuer) Provision(ctx caddy.Context) error { // get a reference to the configured CA appModule, err := ctx.App("pki") if err != nil { return err } pkiApp := appModule.(*caddypki.PKI) - if li.CA == "" { - li.CA = caddypki.DefaultCAID + if iss.CA == "" { + iss.CA = caddypki.DefaultCAID } - ca, ok := pkiApp.CAs[li.CA] + ca, ok := pkiApp.CAs[iss.CA] if !ok { - return fmt.Errorf("no certificate authority configured with id: %s", li.CA) + return fmt.Errorf("no certificate authority configured with id: %s", iss.CA) } - li.ca = ca + iss.ca = ca // set any other default values - if li.Lifetime == 0 { - li.Lifetime = caddy.Duration(defaultInternalCertLifetime) + if iss.Lifetime == 0 { + iss.Lifetime = caddy.Duration(defaultInternalCertLifetime) } return nil @@ -89,38 +90,38 @@ func (li *InternalIssuer) Provision(ctx caddy.Context) error { // IssuerKey returns the unique issuer key for the // confgured CA endpoint. -func (li InternalIssuer) IssuerKey() string { - return li.ca.ID() +func (iss InternalIssuer) IssuerKey() string { + return iss.ca.ID() } // Issue issues a certificate to satisfy the CSR. -func (li InternalIssuer) Issue(ctx context.Context, csr *x509.CertificateRequest) (*certmagic.IssuedCertificate, error) { +func (iss InternalIssuer) Issue(ctx context.Context, csr *x509.CertificateRequest) (*certmagic.IssuedCertificate, error) { // prepare the signing authority authCfg := caddypki.AuthorityConfig{ - SignWithRoot: li.SignWithRoot, + SignWithRoot: iss.SignWithRoot, } - auth, err := li.ca.NewAuthority(authCfg) + auth, err := iss.ca.NewAuthority(authCfg) if err != nil { return nil, err } // get the cert (public key) that will be used for signing var issuerCert *x509.Certificate - if li.SignWithRoot { - issuerCert = li.ca.RootCertificate() + if iss.SignWithRoot { + issuerCert = iss.ca.RootCertificate() } else { - issuerCert = li.ca.IntermediateCertificate() + issuerCert = iss.ca.IntermediateCertificate() } // ensure issued certificate does not expire later than its issuer - lifetime := time.Duration(li.Lifetime) + lifetime := time.Duration(iss.Lifetime) if time.Now().Add(lifetime).After(issuerCert.NotAfter) { // TODO: log this lifetime = issuerCert.NotAfter.Sub(time.Now()) } certChain, err := auth.Sign(csr, provisioner.Options{}, - profileDefaultDuration(li.Lifetime), + profileDefaultDuration(iss.Lifetime), ) if err != nil { return nil, err @@ -139,6 +140,26 @@ func (li InternalIssuer) Issue(ctx context.Context, csr *x509.CertificateRequest }, nil } +// UnmarshalCaddyfile deserializes Caddyfile tokens into iss. +// +// ... internal { +// ca +// } +// +func (iss *InternalIssuer) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { + for d.Next() { + for d.NextBlock(0) { + switch d.Val() { + case "ca": + if !d.AllArgs(&iss.CA) { + return d.ArgErr() + } + } + } + } + return nil +} + // profileDefaultDuration is a wrapper against x509util.WithOption to conform // the SignOption interface. // diff --git a/modules/caddytls/tls.go b/modules/caddytls/tls.go index 81780266..6a635d47 100644 --- a/modules/caddytls/tls.go +++ b/modules/caddytls/tls.go @@ -313,8 +313,10 @@ func (t *TLS) HandleHTTPChallenge(w http.ResponseWriter, r *http.Request) bool { if ap.magic.Issuer == nil { return false } - if am, ok := ap.magic.Issuer.(*ACMEIssuer); ok { - return certmagic.NewACMEManager(am.magic, am.template).HandleHTTPChallenge(w, r) + 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) } return false } diff --git a/modules/caddytls/zerosslissuer.go b/modules/caddytls/zerosslissuer.go new file mode 100644 index 00000000..d0f49502 --- /dev/null +++ b/modules/caddytls/zerosslissuer.go @@ -0,0 +1,236 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package caddytls + +import ( + "context" + "crypto/x509" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" + "github.com/caddyserver/certmagic" + "github.com/mholt/acmez/acme" + "go.uber.org/zap" +) + +func init() { + caddy.RegisterModule(new(ZeroSSLIssuer)) +} + +// ZeroSSLIssuer makes an ACME manager +// for managing certificates using ACME. +type ZeroSSLIssuer struct { + *ACMEIssuer + + // The API key (or "access key") for using the ZeroSSL API. + APIKey string `json:"api_key,omitempty"` + + mu sync.Mutex + logger *zap.Logger +} + +// CaddyModule returns the Caddy module information. +func (*ZeroSSLIssuer) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + ID: "tls.issuance.zerossl", + New: func() caddy.Module { return new(ZeroSSLIssuer) }, + } +} + +// 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 + } + + return nil +} + +func (iss *ZeroSSLIssuer) newAccountCallback(ctx context.Context, am *certmagic.ACMEManager, _ acme.Account) error { + if am.ExternalAccount != nil { + return nil + } + var err error + am.ExternalAccount, err = iss.generateEABCredentials(ctx) + return err +} + +func (iss *ZeroSSLIssuer) generateEABCredentials(ctx context.Context) (*acme.EAB, 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 + switch { + case 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, "") + if email == "" { + return nil, fmt.Errorf("missing email: '%v'", iss.Email) + } + 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) + if err != nil { + return nil, 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, 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, fmt.Errorf("decoding API response: %v", err) + } + if result.Error.Code != 0 { + return nil, 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, 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, + }, nil +} + +// initialize modifies the template for the underlying ACMEManager +// 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.template.CA == "" { + iss.template.CA = zerosslACMEDirectory + } + if iss.template.NewAccountFunc == nil { + iss.template.NewAccountFunc = iss.newAccountCallback + } +} + +// 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) +} + +// 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) +} + +// IssuerKey returns the unique issuer key for the configured CA endpoint. +func (iss *ZeroSSLIssuer) IssuerKey() string { + iss.initialize() + return iss.ACMEIssuer.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) +} + +// UnmarshalCaddyfile deserializes Caddyfile tokens into iss. +// +// ... 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 iss.ACMEIssuer == nil { + iss.ACMEIssuer = new(ACMEIssuer) + } + err := iss.ACMEIssuer.UnmarshalCaddyfile(d.NewFromNextSegment()) + if err != nil { + return err + } + } + return nil +} + +const ( + zerosslACMEDirectory = "https://acme.zerossl.com/v2/DV90" + 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) +)