summaryrefslogtreecommitdiff
path: root/modules/caddytls/zerosslissuer.go
diff options
context:
space:
mode:
Diffstat (limited to 'modules/caddytls/zerosslissuer.go')
-rw-r--r--modules/caddytls/zerosslissuer.go236
1 files changed, 236 insertions, 0 deletions
diff --git a/modules/caddytls/zerosslissuer.go b/modules/caddytls/zerosslissuer.go
new file mode 100644
index 0000000..d0f4950
--- /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 <api_key> {
+// ...
+// }
+//
+// 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)
+)