From ec73dcff41ab6f1154673916ee084b9bd981b0c9 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Thu, 21 Mar 2024 13:45:57 -0600 Subject: [PATCH 1/4] Initial implementation of ZeroSSL API issuer Still needs CA support for CommonName-less certs --- README.md | 37 ++++-- acmeclient.go | 43 ++++--- acmeissuer.go | 14 +++ certificates.go | 4 +- dnsutil.go | 50 ++++++--- go.mod | 22 ++-- go.sum | 54 ++++----- solvers.go | 233 +++++++++++++++++++++++--------------- zerosslissuer.go | 284 +++++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 574 insertions(+), 167 deletions(-) create mode 100644 zerosslissuer.go diff --git a/README.md b/README.md index 375cabb6..7ac2011e 100644 --- a/README.md +++ b/README.md @@ -60,13 +60,16 @@ CertMagic - Automatic HTTPS using Let's Encrypt - [Advanced use](#advanced-use) - [Wildcard Certificates](#wildcard-certificates) - [Behind a load balancer (or in a cluster)](#behind-a-load-balancer-or-in-a-cluster) - - [The ACME Challenges](#the-acme-challenges) - - [HTTP Challenge](#http-challenge) - - [TLS-ALPN Challenge](#tls-alpn-challenge) - - [DNS Challenge](#dns-challenge) - - [On-Demand TLS](#on-demand-tls) - - [Storage](#storage) - - [Cache](#cache) +- [The ACME Challenges](#the-acme-challenges) + - [HTTP Challenge](#http-challenge) + - [TLS-ALPN Challenge](#tls-alpn-challenge) + - [DNS Challenge](#dns-challenge) +- [On-Demand TLS](#on-demand-tls) +- [Storage](#storage) +- [Cache](#cache) +- [Events](#events) +- [ZeroSSL](#zerossl) +- [FAQ](#faq) - [Contributing](#contributing) - [Project History](#project-history) - [Credits and License](#credits-and-license) @@ -505,6 +508,26 @@ CertMagic emits events when possible things of interest happen. Set the [`OnEven `OnEvent` can return an error. Some events may be aborted by returning an error. For example, returning an error from `cert_obtained` can cancel obtaining the certificate. Only return an error from `OnEvent` if you want to abort program flow. +## ZeroSSL + +ZeroSSL has both ACME and HTTP API services for getting certificates. CertMagic works with both of them. + +To use ZeroSSL's ACME server, configure CertMagic with an [`ACMEIssuer`](https://pkg.go.dev/github.com/caddyserver/certmagic#ACMEIssuer) like you would with any other ACME CA (just adjust the directory URL). External Account Binding (EAB) is required for ZeroSSL. You can use the [ZeroSSL API](https://pkg.go.dev/github.com/caddyserver/zerossl) to generate one, or your account dashboard. + +To use ZeroSSL's API instead, use the [`ZeroSSLIssuer`](https://pkg.go.dev/github.com/caddyserver/certmagic#ZeroSSLIssuer). Here is a simple example: + +```go +magic := certmagic.NewDefault() + +magic.Issuers = []certmagic.Issuer{ + certmagic.NewZeroSSLIssuer(magic, certmagic.ZeroSSLIssuer{ + APIKey: "", + }), +} + +err := magic.ManageSync(ctx, []string{"example.com"}) +``` + ## FAQ ### Can I use some of my own certificates while using CertMagic? diff --git a/acmeclient.go b/acmeclient.go index e9569afd..817cb467 100644 --- a/acmeclient.go +++ b/acmeclient.go @@ -20,6 +20,7 @@ import ( "fmt" weakrand "math/rand" "net" + "net/http" "net/url" "strconv" "strings" @@ -186,38 +187,24 @@ func (iss *ACMEIssuer) newACMEClient(useTestCA bool) (*acmez.Client, error) { if iss.DNS01Solver == nil { // enable HTTP-01 challenge if !iss.DisableHTTPChallenge { - useHTTPPort := HTTPChallengePort - if HTTPPort > 0 && HTTPPort != HTTPChallengePort { - useHTTPPort = HTTPPort - } - if iss.AltHTTPPort > 0 { - useHTTPPort = iss.AltHTTPPort - } client.ChallengeSolvers[acme.ChallengeTypeHTTP01] = distributedSolver{ storage: iss.config.Storage, storageKeyIssuerPrefix: iss.storageKeyCAPrefix(client.Directory), solver: &httpSolver{ - acmeIssuer: iss, - address: net.JoinHostPort(iss.ListenHost, strconv.Itoa(useHTTPPort)), + handler: iss.HTTPChallengeHandler(http.NewServeMux()), + address: net.JoinHostPort(iss.ListenHost, strconv.Itoa(iss.getHTTPPort())), }, } } // enable TLS-ALPN-01 challenge if !iss.DisableTLSALPNChallenge { - useTLSALPNPort := TLSALPNChallengePort - if HTTPSPort > 0 && HTTPSPort != TLSALPNChallengePort { - useTLSALPNPort = HTTPSPort - } - if iss.AltTLSALPNPort > 0 { - useTLSALPNPort = iss.AltTLSALPNPort - } client.ChallengeSolvers[acme.ChallengeTypeTLSALPN01] = distributedSolver{ storage: iss.config.Storage, storageKeyIssuerPrefix: iss.storageKeyCAPrefix(client.Directory), solver: &tlsALPNSolver{ config: iss.config, - address: net.JoinHostPort(iss.ListenHost, strconv.Itoa(useTLSALPNPort)), + address: net.JoinHostPort(iss.ListenHost, strconv.Itoa(iss.getTLSALPNPort())), }, } } @@ -248,6 +235,28 @@ func (iss *ACMEIssuer) newACMEClient(useTestCA bool) (*acmez.Client, error) { return client, nil } +func (iss *ACMEIssuer) getHTTPPort() int { + useHTTPPort := HTTPChallengePort + if HTTPPort > 0 && HTTPPort != HTTPChallengePort { + useHTTPPort = HTTPPort + } + if iss.AltHTTPPort > 0 { + useHTTPPort = iss.AltHTTPPort + } + return useHTTPPort +} + +func (iss *ACMEIssuer) getTLSALPNPort() int { + useTLSALPNPort := TLSALPNChallengePort + if HTTPSPort > 0 && HTTPSPort != TLSALPNChallengePort { + useTLSALPNPort = HTTPSPort + } + if iss.AltTLSALPNPort > 0 { + useTLSALPNPort = iss.AltTLSALPNPort + } + return useTLSALPNPort +} + func (c *acmeClient) throttle(ctx context.Context, names []string) error { email := c.iss.getEmail() diff --git a/acmeissuer.go b/acmeissuer.go index 580a7721..d357e5b8 100644 --- a/acmeissuer.go +++ b/acmeissuer.go @@ -1,3 +1,17 @@ +// Copyright 2015 Matthew Holt +// +// 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 certmagic import ( diff --git a/certificates.go b/certificates.go index 1ebdb9bb..eeef44cd 100644 --- a/certificates.go +++ b/certificates.go @@ -162,7 +162,7 @@ func (cfg *Config) loadManagedCertificate(ctx context.Context, domain string) (C // // This method is safe for concurrent use. func (cfg *Config) CacheUnmanagedCertificatePEMFile(ctx context.Context, certFile, keyFile string, tags []string) (string, error) { - cert, err := cfg.makeCertificateFromDiskWithOCSP(ctx, cfg.Storage, certFile, keyFile) + cert, err := cfg.makeCertificateFromDiskWithOCSP(ctx, certFile, keyFile) if err != nil { return "", err } @@ -224,7 +224,7 @@ func (cfg *Config) CacheUnmanagedCertificatePEMBytes(ctx context.Context, certBy // certificate and key files. It fills out all the fields in // the certificate except for the Managed and OnDemand flags. // (It is up to the caller to set those.) It staples OCSP. -func (cfg Config) makeCertificateFromDiskWithOCSP(ctx context.Context, storage Storage, certFile, keyFile string) (Certificate, error) { +func (cfg Config) makeCertificateFromDiskWithOCSP(ctx context.Context, certFile, keyFile string) (Certificate, error) { certPEMBlock, err := os.ReadFile(certFile) if err != nil { return Certificate{}, err diff --git a/dnsutil.go b/dnsutil.go index fc93dc2a..97cb68c3 100644 --- a/dnsutil.go +++ b/dnsutil.go @@ -211,19 +211,22 @@ func populateNameserverPorts(servers []string) { } // checkDNSPropagation checks if the expected TXT record has been propagated to all authoritative nameservers. -func checkDNSPropagation(fqdn, value string, resolvers []string) (bool, error) { +func checkDNSPropagation(fqdn string, recType uint16, expectedValue string, resolvers []string) (bool, error) { if !strings.HasSuffix(fqdn, ".") { fqdn += "." } - // Initial attempt to resolve at the recursive NS - r, err := dnsQuery(fqdn, dns.TypeTXT, resolvers, true) - if err != nil { - return false, err - } - - if r.Rcode == dns.RcodeSuccess { - fqdn = updateDomainWithCName(r, fqdn) + // Initial attempt to resolve at the recursive NS - but do not actually + // dereference (follow) a CNAME record if we are targeting a CNAME record + // itself + if recType != dns.TypeCNAME { + r, err := dnsQuery(fqdn, recType, resolvers, true) + if err != nil { + return false, err + } + if r.Rcode == dns.RcodeSuccess { + fqdn = updateDomainWithCName(r, fqdn) + } } authoritativeNss, err := lookupNameservers(fqdn, resolvers) @@ -231,13 +234,13 @@ func checkDNSPropagation(fqdn, value string, resolvers []string) (bool, error) { return false, err } - return checkAuthoritativeNss(fqdn, value, authoritativeNss) + return checkAuthoritativeNss(fqdn, recType, expectedValue, authoritativeNss) } // checkAuthoritativeNss queries each of the given nameservers for the expected TXT record. -func checkAuthoritativeNss(fqdn, value string, nameservers []string) (bool, error) { +func checkAuthoritativeNss(fqdn string, recType uint16, expectedValue string, nameservers []string) (bool, error) { for _, ns := range nameservers { - r, err := dnsQuery(fqdn, dns.TypeTXT, []string{net.JoinHostPort(ns, "53")}, true) + r, err := dnsQuery(fqdn, recType, []string{net.JoinHostPort(ns, "53")}, true) if err != nil { return false, err } @@ -254,12 +257,25 @@ func checkAuthoritativeNss(fqdn, value string, nameservers []string) (bool, erro var found bool for _, rr := range r.Answer { - if txt, ok := rr.(*dns.TXT); ok { - record := strings.Join(txt.Txt, "") - if record == value { - found = true - break + switch recType { + case dns.TypeTXT: + if txt, ok := rr.(*dns.TXT); ok { + record := strings.Join(txt.Txt, "") + if record == expectedValue { + found = true + break + } + } + case dns.TypeCNAME: + if cname, ok := rr.(*dns.CNAME); ok { + // TODO: whether a DNS provider assumes a trailing dot or not varies, and we may have to standardize this in libdns packages + if strings.TrimSuffix(cname.Target, ".") == strings.TrimSuffix(expectedValue, ".") { + found = true + break + } } + default: + return false, fmt.Errorf("unsupported record type: %d", recType) } } diff --git a/go.mod b/go.mod index d1eed982..978b204a 100644 --- a/go.mod +++ b/go.mod @@ -1,23 +1,23 @@ module github.com/caddyserver/certmagic -go 1.19 +go 1.22.0 require ( - github.com/klauspost/cpuid/v2 v2.2.5 - github.com/libdns/libdns v0.2.1 + github.com/caddyserver/zerossl v0.1.1 + github.com/klauspost/cpuid/v2 v2.2.7 + github.com/libdns/libdns v0.2.2 github.com/mholt/acmez v1.2.0 - github.com/miekg/dns v1.1.55 + github.com/miekg/dns v1.1.58 github.com/zeebo/blake3 v0.2.3 - go.uber.org/zap v1.24.0 - golang.org/x/crypto v0.17.0 - golang.org/x/net v0.17.0 + go.uber.org/zap v1.27.0 + golang.org/x/crypto v0.21.0 + golang.org/x/net v0.22.0 ) require ( - go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/mod v0.11.0 // indirect - golang.org/x/sys v0.15.0 // indirect + golang.org/x/mod v0.14.0 // indirect + golang.org/x/sys v0.18.0 // indirect golang.org/x/text v0.14.0 // indirect - golang.org/x/tools v0.10.0 // indirect + golang.org/x/tools v0.17.0 // indirect ) diff --git a/go.sum b/go.sum index 21a7d26b..1e14729d 100644 --- a/go.sum +++ b/go.sum @@ -1,42 +1,46 @@ -github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= +github.com/caddyserver/zerossl v0.1.1 h1:yQL7QXZnEb/ddH6JsNPGBANETUMHPFlAV5+a+Epxgbo= +github.com/caddyserver/zerossl v0.1.1/go.mod h1:wtiJEHbdvunr40ZzhXlnIkOB8Xj4eKtBKizCcZitJiQ= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= -github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= -github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= -github.com/libdns/libdns v0.2.1 h1:Wu59T7wSHRgtA0cfxC+n1c/e+O3upJGWytknkmFEDis= -github.com/libdns/libdns v0.2.1/go.mod h1:yQCXzk1lEZmmCPa857bnk4TsOiqYasqpyOEeSObbb40= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/libdns/libdns v0.2.2 h1:O6ws7bAfRPaBsgAYt8MDe2HcNBGC29hkZ9MX2eUSX3s= +github.com/libdns/libdns v0.2.2/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= github.com/mholt/acmez v1.2.0 h1:1hhLxSgY5FvH5HCnGUuwbKY2VQVo8IU7rxXKSnZ7F30= github.com/mholt/acmez v1.2.0/go.mod h1:VT9YwH1xgNX1kmYY89gY8xPJC84BFAisjo8Egigt4kE= -github.com/miekg/dns v1.1.55 h1:GoQ4hpsj0nFLYe+bWiCToyrBEJXkQfOOIvFGFy0lEgo= -github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY= -github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4= +github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/blake3 v0.2.3 h1:TFoLXsjeXqRNFxSbk35Dk4YtszE/MQQGK10BH4ptoTg= github.com/zeebo/blake3 v0.2.3/go.mod h1:mjJjZpnsyIVtVgTOSpJ9vmRE4wgDeyt2HU3qXvvKCaQ= github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= -go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= -go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= -go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= -golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= -golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU= -golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= -golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= +golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/tools v0.10.0 h1:tvDr/iQoUqNdohiYm0LmmKcBk+q86lb9EprIUFhHHGg= -golang.org/x/tools v0.10.0/go.mod h1:UJwyiVBsOA2uwvK/e5OY3GTpDUJriEd+/YlqAwLPmyM= +golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc= +golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/solvers.go b/solvers.go index 51ef096b..e7670b69 100644 --- a/solvers.go +++ b/solvers.go @@ -46,9 +46,9 @@ import ( // can access the keyAuth material is by loading it // from storage, which is done by distributedSolver. type httpSolver struct { - closed int32 // accessed atomically - acmeIssuer *ACMEIssuer - address string + closed int32 // accessed atomically + handler http.Handler + address string } // Present starts an HTTP server if none is already listening on s.address. @@ -88,7 +88,7 @@ func (s *httpSolver) serve(ctx context.Context, si *solverInfo) { }() defer close(si.done) httpServer := &http.Server{ - Handler: s.acmeIssuer.HTTPChallengeHandler(http.NewServeMux()), + Handler: s.handler, BaseContext: func(listener net.Listener) context.Context { return ctx }, } httpServer.SetKeepAlivesEnabled(false) @@ -250,9 +250,92 @@ func (s *tlsALPNSolver) CleanUp(_ context.Context, chal acme.Challenge) error { // DNS provider APIs and implementations of the libdns interfaces must also // support multiple same-named TXT records. type DNS01Solver struct { + DNSManager +} + +// Present creates the DNS TXT record for the given ACME challenge. +func (s *DNS01Solver) Present(ctx context.Context, challenge acme.Challenge) error { + dnsName := challenge.DNS01TXTRecordName() + if s.OverrideDomain != "" { + dnsName = s.OverrideDomain + } + keyAuth := challenge.DNS01KeyAuthorization() + + zrec, err := s.DNSManager.createRecord(ctx, dnsName, "TXT", keyAuth) + if err != nil { + return err + } + + // remember the record and zone we got so we can clean up more efficiently + s.saveDNSPresentMemory(dnsPresentMemory{ + dnsName: dnsName, + zoneRec: zrec, + }) + + return nil +} + +// Wait blocks until the TXT record created in Present() appears in +// authoritative lookups, i.e. until it has propagated, or until +// timeout, whichever is first. +func (s *DNS01Solver) Wait(ctx context.Context, challenge acme.Challenge) error { + // prepare for the checks by determining what to look for + dnsName := challenge.DNS01TXTRecordName() + if s.OverrideDomain != "" { + dnsName = s.OverrideDomain + } + keyAuth := challenge.DNS01KeyAuthorization() + + // wait for the record to propagate + memory, err := s.getDNSPresentMemory(dnsName, "TXT", keyAuth) + if err != nil { + return err + } + return s.DNSManager.wait(ctx, memory.zoneRec) +} + +// CleanUp deletes the DNS TXT record created in Present(). +// +// We ignore the context because cleanup is often/likely performed after +// a context cancellation, and properly-implemented DNS providers should +// honor cancellation, which would result in cleanup being aborted. +// Cleanup must always occur. +func (s *DNS01Solver) CleanUp(ctx context.Context, challenge acme.Challenge) error { + dnsName := challenge.DNS01TXTRecordName() + if s.OverrideDomain != "" { + dnsName = s.OverrideDomain + } + keyAuth := challenge.DNS01KeyAuthorization() + + // always forget about the record so we don't leak memory + defer s.deleteDNSPresentMemory(dnsName, keyAuth) + + // recall the record we created and zone we looked up + memory, err := s.getDNSPresentMemory(dnsName, "TXT", keyAuth) + if err != nil { + return err + } + + if err := s.DNSManager.cleanUpRecord(ctx, memory.zoneRec); err != nil { + return err + } + return nil +} + +// DNSManager is a type that makes libdns providers usable for performing +// DNS verification. See https://github.com/libdns/libdns +// +// Note that records may be manipulated concurrently by some clients (such as +// acmez, which CertMagic uses), meaning that multiple records may be created +// in a DNS zone simultaneously, and in some cases distinct records of the same +// type may have the same name. For example, solving ACME challenges for both example.com +// and *.example.com create a TXT record named _acme_challenge.example.com, +// but with different tokens as their values. This solver distinguishes between +// different records with the same type and name by looking at their values. +type DNSManager struct { // The implementation that interacts with the DNS // provider to set or delete records. (REQUIRED) - DNSProvider ACMEDNSProvider + DNSProvider DNSProvider // The TTL for the temporary challenge records. TTL time.Duration @@ -285,52 +368,37 @@ type DNS01Solver struct { // the value of their TXT records, which should contain // unique challenge tokens. // See https://github.com/caddyserver/caddy/issues/3474. - txtRecords map[string][]dnsPresentMemory - txtRecordsMu sync.Mutex + records map[string][]dnsPresentMemory + recordsMu sync.Mutex } -// Present creates the DNS TXT record for the given ACME challenge. -func (s *DNS01Solver) Present(ctx context.Context, challenge acme.Challenge) error { - dnsName := challenge.DNS01TXTRecordName() - if s.OverrideDomain != "" { - dnsName = s.OverrideDomain - } - keyAuth := challenge.DNS01KeyAuthorization() - - zone, err := findZoneByFQDN(dnsName, recursiveNameservers(s.Resolvers)) +func (m *DNSManager) createRecord(ctx context.Context, dnsName, recordType, recordValue string) (zoneRecord, error) { + zone, err := findZoneByFQDN(dnsName, recursiveNameservers(m.Resolvers)) if err != nil { - return fmt.Errorf("could not determine zone for domain %q: %v", dnsName, err) + return zoneRecord{}, fmt.Errorf("could not determine zone for domain %q: %v", dnsName, err) } - rec := libdns.Record{ - Type: "TXT", + Type: recordType, Name: libdns.RelativeName(dnsName+".", zone), - Value: keyAuth, - TTL: s.TTL, + Value: recordValue, + TTL: m.TTL, } - results, err := s.DNSProvider.AppendRecords(ctx, zone, []libdns.Record{rec}) + results, err := m.DNSProvider.AppendRecords(ctx, zone, []libdns.Record{rec}) if err != nil { - return fmt.Errorf("adding temporary record for zone %q: %w", zone, err) + return zoneRecord{}, fmt.Errorf("adding temporary record for zone %q: %w", zone, err) } if len(results) != 1 { - return fmt.Errorf("expected one record, got %d: %v", len(results), results) + return zoneRecord{}, fmt.Errorf("expected one record, got %d: %v", len(results), results) } - // remember the record and zone we got so we can clean up more efficiently - s.saveDNSPresentMemory(dnsPresentMemory{ - dnsZone: zone, - dnsName: dnsName, - rec: results[0], - }) - - return nil + return zoneRecord{zone, results[0]}, nil } -// Wait blocks until the TXT record created in Present() appears in +// wait blocks until the TXT record created in Present() appears in // authoritative lookups, i.e. until it has propagated, or until // timeout, whichever is first. -func (s *DNS01Solver) Wait(ctx context.Context, challenge acme.Challenge) error { +func (s *DNSManager) wait(ctx context.Context, zrec zoneRecord) error { // if configured to, pause before doing propagation checks // (even if they are disabled, the wait might be desirable on its own) if s.PropagationDelay > 0 { @@ -346,13 +414,6 @@ func (s *DNS01Solver) Wait(ctx context.Context, challenge acme.Challenge) error return nil } - // prepare for the checks by determining what to look for - dnsName := challenge.DNS01TXTRecordName() - if s.OverrideDomain != "" { - dnsName = s.OverrideDomain - } - keyAuth := challenge.DNS01KeyAuthorization() - // timings timeout := s.PropagationTimeout if timeout == 0 { @@ -363,6 +424,11 @@ func (s *DNS01Solver) Wait(ctx context.Context, challenge acme.Challenge) error // how we'll do the checks resolvers := recursiveNameservers(s.Resolvers) + recType := dns.TypeTXT + if zrec.record.Type == "CNAME" { + recType = dns.TypeCNAME + } + var err error start := time.Now() for time.Since(start) < timeout { @@ -372,9 +438,9 @@ func (s *DNS01Solver) Wait(ctx context.Context, challenge acme.Challenge) error return ctx.Err() } var ready bool - ready, err = checkDNSPropagation(dnsName, keyAuth, resolvers) + ready, err = checkDNSPropagation(libdns.AbsoluteName(zrec.record.Name, zrec.zone), recType, zrec.record.Value, resolvers) if err != nil { - return fmt.Errorf("checking DNS propagation of %q: %w", dnsName, err) + return fmt.Errorf("checking DNS propagation of %q: %w", zrec.record.Name, err) } if ready { return nil @@ -384,28 +450,18 @@ func (s *DNS01Solver) Wait(ctx context.Context, challenge acme.Challenge) error return fmt.Errorf("timed out waiting for record to fully propagate; verify DNS provider configuration is correct - last error: %v", err) } +type zoneRecord struct { + zone string + record libdns.Record +} + // CleanUp deletes the DNS TXT record created in Present(). // // We ignore the context because cleanup is often/likely performed after // a context cancellation, and properly-implemented DNS providers should // honor cancellation, which would result in cleanup being aborted. // Cleanup must always occur. -func (s *DNS01Solver) CleanUp(_ context.Context, challenge acme.Challenge) error { - dnsName := challenge.DNS01TXTRecordName() - if s.OverrideDomain != "" { - dnsName = s.OverrideDomain - } - keyAuth := challenge.DNS01KeyAuthorization() - - // always forget about the record so we don't leak memory - defer s.deleteDNSPresentMemory(dnsName, keyAuth) - - // recall the record we created and zone we looked up - memory, err := s.getDNSPresentMemory(dnsName, keyAuth) - if err != nil { - return err - } - +func (s *DNSManager) cleanUpRecord(_ context.Context, zrec zoneRecord) error { // clean up the record - use a different context though, since // one common reason cleanup is performed is because a context // was canceled, and if so, any HTTP requests by this provider @@ -417,68 +473,69 @@ func (s *DNS01Solver) CleanUp(_ context.Context, challenge acme.Challenge) error } ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - _, err = s.DNSProvider.DeleteRecords(ctx, memory.dnsZone, []libdns.Record{memory.rec}) + _, err := s.DNSProvider.DeleteRecords(ctx, zrec.zone, []libdns.Record{zrec.record}) if err != nil { - return fmt.Errorf("deleting temporary record for name %q in zone %q: %w", memory.dnsName, memory.dnsZone, err) + return fmt.Errorf("deleting temporary record for name %q in zone %q: %w", zrec.zone, zrec.record, err) } - return nil } const defaultDNSPropagationTimeout = 2 * time.Minute +// dnsPresentMemory associates a created DNS record with its zone +// (since libdns Records are zone-relative and do not include zone). type dnsPresentMemory struct { - dnsZone string dnsName string - rec libdns.Record + zoneRec zoneRecord } -func (s *DNS01Solver) saveDNSPresentMemory(mem dnsPresentMemory) { - s.txtRecordsMu.Lock() - if s.txtRecords == nil { - s.txtRecords = make(map[string][]dnsPresentMemory) +func (s *DNSManager) saveDNSPresentMemory(mem dnsPresentMemory) { + s.recordsMu.Lock() + if s.records == nil { + s.records = make(map[string][]dnsPresentMemory) } - s.txtRecords[mem.dnsName] = append(s.txtRecords[mem.dnsName], mem) - s.txtRecordsMu.Unlock() + s.records[mem.dnsName] = append(s.records[mem.dnsName], mem) + s.recordsMu.Unlock() } -func (s *DNS01Solver) getDNSPresentMemory(dnsName, keyAuth string) (dnsPresentMemory, error) { - s.txtRecordsMu.Lock() - defer s.txtRecordsMu.Unlock() +func (s *DNSManager) getDNSPresentMemory(dnsName, recType, value string) (dnsPresentMemory, error) { + s.recordsMu.Lock() + defer s.recordsMu.Unlock() var memory dnsPresentMemory - for _, mem := range s.txtRecords[dnsName] { - if mem.rec.Value == keyAuth { + for _, mem := range s.records[dnsName] { + if mem.zoneRec.record.Type == recType && mem.zoneRec.record.Value == value { memory = mem break } } - if memory.rec.Name == "" { + if memory.zoneRec.record.Name == "" { return dnsPresentMemory{}, fmt.Errorf("no memory of presenting a DNS record for %q (usually OK if presenting also failed)", dnsName) } return memory, nil } -func (s *DNS01Solver) deleteDNSPresentMemory(dnsName, keyAuth string) { - s.txtRecordsMu.Lock() - defer s.txtRecordsMu.Unlock() +func (s *DNSManager) deleteDNSPresentMemory(dnsName, keyAuth string) { + s.recordsMu.Lock() + defer s.recordsMu.Unlock() - for i, mem := range s.txtRecords[dnsName] { - if mem.rec.Value == keyAuth { - s.txtRecords[dnsName] = append(s.txtRecords[dnsName][:i], s.txtRecords[dnsName][i+1:]...) + for i, mem := range s.records[dnsName] { + if mem.zoneRec.record.Value == keyAuth { + s.records[dnsName] = append(s.records[dnsName][:i], s.records[dnsName][i+1:]...) return } } } -// ACMEDNSProvider defines the set of operations required for -// ACME challenges. A DNS provider must be able to append and -// delete records in order to solve ACME challenges. Find one -// you can use at https://github.com/libdns. If your provider -// isn't implemented yet, feel free to contribute! -type ACMEDNSProvider interface { +// DNSProvider defines the set of operations required for +// ACME challenges or other sorts of domain verification. +// A DNS provider must be able to append and delete records +// in order to solve ACME challenges. Find one you can use +// at https://github.com/libdns. If your provider isn't +// implemented yet, feel free to contribute! +type DNSProvider interface { libdns.RecordAppender libdns.RecordDeleter } diff --git a/zerosslissuer.go b/zerosslissuer.go new file mode 100644 index 00000000..fc54791d --- /dev/null +++ b/zerosslissuer.go @@ -0,0 +1,284 @@ +// Copyright 2015 Matthew Holt +// +// 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 certmagic + +import ( + "context" + "crypto/x509" + "fmt" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/caddyserver/zerossl" + "github.com/mholt/acmez/acme" + "go.uber.org/zap" +) + +// NewZeroSSLIssuer returns a ZeroSSL issuer with default values filled in +// for empty fields in the template. +func NewZeroSSLIssuer(cfg *Config, template ZeroSSLIssuer) *ZeroSSLIssuer { + if cfg == nil { + panic("cannot make valid ZeroSSLIssuer without an associated CertMagic config") + } + template.config = cfg + template.logger = defaultLogger.Named("zerossl") + return &template +} + +// ZeroSSLIssuer can get certificates from ZeroSSL's API. (To use ZeroSSL's ACME +// endpoint, use the ACMEIssuer instead.) +type ZeroSSLIssuer struct { + // The API key (or "access key") for using the ZeroSSL API. + APIKey string + + // How many days the certificate should be valid for. + // Note that customizing certificate lifetime may be + // a paid feature. + ValidityDays int + + // The host to bind to when opening a listener for + // verifying domain names (or IPs). + ListenHost string + + // If HTTP is forwarded from port 80, specify the + // forwarded port here. + AltHTTPPort int + + // To use CNAME validation instead of HTTP + // validation, set this field. + CNAMEValidation *DNSManager + + config *Config + logger *zap.Logger +} + +// Issue obtains a certificate for the given csr. +func (iss *ZeroSSLIssuer) Issue(ctx context.Context, csr *x509.CertificateRequest) (*IssuedCertificate, error) { + client := iss.getClient() + + identifiers := namesFromCSR(csr) + logger := iss.logger.With(zap.Strings("identifiers", identifiers)) + + logger.Info("creating certificate") + + cert, err := client.CreateCertificate(ctx, csr, iss.ValidityDays) + if err != nil { + return nil, fmt.Errorf("creating certificate: %v", err) + } + + logger = logger.With(zap.String("cert_id", cert.ID)) + logger.Info("created certificate") + + defer func(certID string) { + if err != nil { + err := client.CancelCertificate(context.WithoutCancel(ctx), certID) + if err == nil { + logger.Info("canceled certificate") + } else { + logger.Error("unable to cancel certificate", zap.Error(err)) + } + } + }(cert.ID) + + var verificationMethod zerossl.VerificationMethod + + if iss.CNAMEValidation == nil { + verificationMethod = zerossl.HTTPVerification + logger = logger.With(zap.String("verification_method", string(verificationMethod))) + + httpVerifier := &httpSolver{ + address: net.JoinHostPort(iss.ListenHost, strconv.Itoa(iss.getHTTPPort())), + handler: http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if !strings.HasPrefix(req.URL.Path, zerosslValidationPathPrefix) { + return + } + + validation, ok := cert.Validation.OtherMethods[req.Host] + if !ok { + rw.WriteHeader(http.StatusNotFound) + return + } + + // ensure URL matches + validationURL, err := url.Parse(validation.FileValidationURLHTTP) + if err != nil { + logger.Error("got invalid URL from CA", + zap.String("file_validation_url", validation.FileValidationURLHTTP), + zap.Error(err)) + rw.WriteHeader(http.StatusInternalServerError) + return + } + if req.URL.Path != validationURL.Path { + rw.WriteHeader(http.StatusNotFound) + return + } + + logger.Info("served HTTP validation file") + + fmt.Fprint(rw, strings.Join(validation.FileValidationContent, "\n")) + }), + } + + distSolver := distributedSolver{ + storage: iss.config.Storage, + storageKeyIssuerPrefix: "zerossl", + solver: httpVerifier, + } + + if err = distSolver.Present(ctx, acme.Challenge{}); err != nil { + return nil, fmt.Errorf("presenting token for verification: %v", err) + } + defer distSolver.CleanUp(ctx, acme.Challenge{}) + } else { + verificationMethod = zerossl.CNAMEVerification + logger = logger.With(zap.String("verification_method", string(verificationMethod))) + + // create the CNAME record(s) + records := make(map[string]zoneRecord, len(cert.Validation.OtherMethods)) + for name, verifyInfo := range cert.Validation.OtherMethods { + zr, err := iss.CNAMEValidation.createRecord(ctx, verifyInfo.CnameValidationP1, "CNAME", verifyInfo.CnameValidationP2) + if err != nil { + return nil, fmt.Errorf("creating CNAME record: %v", err) + } + defer func(name string, zr zoneRecord) { + if err := iss.CNAMEValidation.cleanUpRecord(ctx, zr); err != nil { + logger.Warn("cleaning up temporary validation record failed", + zap.String("dns_name", name), + zap.Error(err)) + } + }(name, zr) + records[name] = zr + } + + // wait for them to propagate + for name, zr := range records { + if err := iss.CNAMEValidation.wait(ctx, zr); err != nil { + // allow it, since the CA will ultimately decide, but definitely log it + logger.Warn("failed CNAME record propagation check", zap.String("domain", name), zap.Error(err)) + } + } + } + + logger.Info("validating identifiers") + + cert, err = client.VerifyIdentifiers(ctx, cert.ID, verificationMethod, nil) + if err != nil { + return nil, fmt.Errorf("verifying identifiers: %v", err) + } + + switch cert.Status { + case "pending_validation": + logger.Info("validations succeeded; waiting for certificate to be issued") + + cert, err = iss.waitForCertToBeIssued(ctx, client, cert) + if err != nil { + return nil, fmt.Errorf("waiting for certificate to be issued: %v", err) + } + case "issued": + logger.Info("validations succeeded; downloading certificate bundle") + default: + return nil, fmt.Errorf("unexpected certificate status: %s", cert.Status) + } + + bundle, err := client.DownloadCertificate(ctx, cert.ID, false) + if err != nil { + return nil, fmt.Errorf("downloading certificate: %v", err) + } + + logger.Info("successfully downloaded issued certificate") + + return &IssuedCertificate{ + Certificate: []byte(bundle.CertificateCrt + bundle.CABundleCrt), + Metadata: cert, + }, nil +} + +func (*ZeroSSLIssuer) waitForCertToBeIssued(ctx context.Context, client zerossl.Client, cert zerossl.CertificateObject) (zerossl.CertificateObject, error) { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return cert, ctx.Err() + case <-ticker.C: + var err error + cert, err = client.GetCertificate(ctx, cert.ID) + if err != nil { + return cert, err + } + if cert.Status == "issued" { + return cert, nil + } + if cert.Status != "pending_validation" { + return cert, fmt.Errorf("unexpected certificate status: %s", cert.Status) + } + } + } +} + +func (iss *ZeroSSLIssuer) getClient() zerossl.Client { + return zerossl.Client{AccessKey: iss.APIKey} +} + +func (iss *ZeroSSLIssuer) getHTTPPort() int { + useHTTPPort := HTTPChallengePort + if HTTPPort > 0 && HTTPPort != HTTPChallengePort { + useHTTPPort = HTTPPort + } + if iss.AltHTTPPort > 0 { + useHTTPPort = iss.AltHTTPPort + } + return useHTTPPort +} + +// IssuerKey returns the unique issuer key for ZeroSSL. +func (iss *ZeroSSLIssuer) IssuerKey() string { + return "zerossl" +} + +// Revoke revokes the given certificate. Only do this if there is a security or trust +// concern with the certificate. +func (iss *ZeroSSLIssuer) Revoke(ctx context.Context, cert CertificateResource, reason int) error { + r := zerossl.UnspecifiedReason + switch reason { + case acme.ReasonKeyCompromise: + r = zerossl.KeyCompromise + case acme.ReasonAffiliationChanged: + r = zerossl.AffiliationChanged + case acme.ReasonSuperseded: + r = zerossl.Superseded + case acme.ReasonCessationOfOperation: + r = zerossl.CessationOfOperation + default: + return fmt.Errorf("unsupported reason: %d", reason) + } + return iss.getClient().RevokeCertificate(ctx, cert.IssuerData.(zerossl.CertificateObject).ID, r) +} + +const ( + zerosslAPIBase = "https://" + zerossl.BaseURL + "/acme" + zerosslValidationPathPrefix = "/.well-known/pki-validation/" +) + +// Interface guards +var ( + _ Issuer = (*ZeroSSLIssuer)(nil) + _ Revoker = (*ZeroSSLIssuer)(nil) +) From 34997d34ac9d2e8865e52c34ca97b3b2840d4141 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Fri, 29 Mar 2024 11:10:40 -0600 Subject: [PATCH 2/4] Accommodate ZeroSSL CSR requirements; fix DNS prop check --- config.go | 40 +++++++++++++++++++++++++++++++++++----- crypto.go | 5 +++++ dnsutil.go | 12 ++++++------ solvers.go | 6 ++++-- zerosslissuer.go | 5 ++--- 5 files changed, 52 insertions(+), 16 deletions(-) diff --git a/config.go b/config.go index 1580751a..d128bc31 100644 --- a/config.go +++ b/config.go @@ -562,7 +562,7 @@ func (cfg *Config) obtainCert(ctx context.Context, name string, interactive bool } } - csr, err := cfg.generateCSR(privKey, []string{name}) + csr, err := cfg.generateCSR(privKey, []string{name}, false) if err != nil { return err } @@ -584,7 +584,19 @@ func (cfg *Config) obtainCert(ctx context.Context, name string, interactive bool } } - issuedCert, err = issuer.Issue(ctx, csr) + // TODO: ZeroSSL's API currently requires CommonName to be set, and requires it be + // distinct from SANs. If this was a cert it would violate the BRs, but their certs + // are compliant, so their CSR requirements just needlessly add friction, complexity, + // and inefficiency for clients. CommonName has been deprecated for 25+ years. + useCSR := csr + if _, ok := issuer.(*ZeroSSLIssuer); ok { + useCSR, err = cfg.generateCSR(privKey, []string{name}, true) + if err != nil { + return err + } + } + + issuedCert, err = issuer.Issue(ctx, useCSR) if err == nil { issuerUsed = issuer break @@ -808,7 +820,7 @@ func (cfg *Config) renewCert(ctx context.Context, name string, force, interactiv } } - csr, err := cfg.generateCSR(privateKey, []string{name}) + csr, err := cfg.generateCSR(privateKey, []string{name}, false) if err != nil { return err } @@ -818,6 +830,18 @@ func (cfg *Config) renewCert(ctx context.Context, name string, force, interactiv var issuerUsed Issuer var issuerKeys []string for _, issuer := range cfg.Issuers { + // TODO: ZeroSSL's API currently requires CommonName to be set, and requires it be + // distinct from SANs. If this was a cert it would violate the BRs, but their certs + // are compliant, so their CSR requirements just needlessly add friction, complexity, + // and inefficiency for clients. CommonName has been deprecated for 25+ years. + useCSR := csr + if _, ok := issuer.(*ZeroSSLIssuer); ok { + useCSR, err = cfg.generateCSR(privateKey, []string{name}, true) + if err != nil { + return err + } + } + issuerKeys = append(issuerKeys, issuer.IssuerKey()) if prechecker, ok := issuer.(PreChecker); ok { err = prechecker.PreCheck(ctx, []string{name}, interactive) @@ -826,7 +850,7 @@ func (cfg *Config) renewCert(ctx context.Context, name string, force, interactiv } } - issuedCert, err = issuer.Issue(ctx, csr) + issuedCert, err = issuer.Issue(ctx, useCSR) if err == nil { issuerUsed = issuer break @@ -898,10 +922,16 @@ func (cfg *Config) renewCert(ctx context.Context, name string, force, interactiv return err } -func (cfg *Config) generateCSR(privateKey crypto.PrivateKey, sans []string) (*x509.CertificateRequest, error) { +// generateCSR generates a CSR for the given SANs. If useCN is true, CommonName will get the first SAN (TODO: this is only a temporary hack for ZeroSSL API support). +func (cfg *Config) generateCSR(privateKey crypto.PrivateKey, sans []string, useCN bool) (*x509.CertificateRequest, error) { csrTemplate := new(x509.CertificateRequest) for _, name := range sans { + // TODO: This is a temporary hack to support ZeroSSL API... + if useCN && csrTemplate.Subject.CommonName == "" && len(name) <= 64 { + csrTemplate.Subject.CommonName = name + continue + } if ip := net.ParseIP(name); ip != nil { csrTemplate.IPAddresses = append(csrTemplate.IPAddresses, ip) } else if strings.Contains(name, "@") { diff --git a/crypto.go b/crypto.go index 5855ad75..4823fd5a 100644 --- a/crypto.go +++ b/crypto.go @@ -280,6 +280,11 @@ func hashCertificateChain(certChain [][]byte) string { func namesFromCSR(csr *x509.CertificateRequest) []string { var nameSet []string + // TODO: CommonName should not be used (it has been deprecated for 25+ years, + // but Sectigo CA still requires it to be filled out and not overlap SANs...) + if csr.Subject.CommonName != "" { + nameSet = append(nameSet, csr.Subject.CommonName) + } nameSet = append(nameSet, csr.DNSNames...) nameSet = append(nameSet, csr.EmailAddresses...) for _, v := range csr.IPAddresses { diff --git a/dnsutil.go b/dnsutil.go index e574518d..b42a24c6 100644 --- a/dnsutil.go +++ b/dnsutil.go @@ -222,7 +222,7 @@ func checkDNSPropagation(fqdn string, recType uint16, expectedValue string, chec if recType != dns.TypeCNAME { r, err := dnsQuery(fqdn, recType, resolvers, true) if err != nil { - return false, err + return false, fmt.Errorf("CNAME dns query: %v", err) } if r.Rcode == dns.RcodeSuccess { fqdn = updateDomainWithCName(r, fqdn) @@ -232,7 +232,7 @@ func checkDNSPropagation(fqdn string, recType uint16, expectedValue string, chec if checkAuthoritativeServers { authoritativeServers, err := lookupNameservers(fqdn, resolvers) if err != nil { - return false, err + return false, fmt.Errorf("looking up authoritative nameservers: %v", err) } populateNameserverPorts(authoritativeServers) resolvers = authoritativeServers @@ -244,9 +244,9 @@ func checkDNSPropagation(fqdn string, recType uint16, expectedValue string, chec // checkAuthoritativeNss queries each of the given nameservers for the expected TXT record. func checkAuthoritativeNss(fqdn string, recType uint16, expectedValue string, nameservers []string) (bool, error) { for _, ns := range nameservers { - r, err := dnsQuery(fqdn, recType, []string{net.JoinHostPort(ns, "53")}, true) + r, err := dnsQuery(fqdn, recType, []string{ns}, true) if err != nil { - return false, err + return false, fmt.Errorf("querying authoritative nameservers: %v", err) } if r.Rcode != dns.RcodeSuccess { @@ -290,12 +290,12 @@ func lookupNameservers(fqdn string, resolvers []string) ([]string, error) { zone, err := findZoneByFQDN(fqdn, resolvers) if err != nil { - return nil, fmt.Errorf("could not determine the zone: %w", err) + return nil, fmt.Errorf("could not determine the zone for '%s': %w", fqdn, err) } r, err := dnsQuery(zone, dns.TypeNS, resolvers, true) if err != nil { - return nil, err + return nil, fmt.Errorf("querying NS resolver for zone '%s' recursively: %v", zone, err) } for _, rr := range r.Answer { diff --git a/solvers.go b/solvers.go index dd1dcd0a..1d920f29 100644 --- a/solvers.go +++ b/solvers.go @@ -430,6 +430,8 @@ func (s *DNSManager) wait(ctx context.Context, zrec zoneRecord) error { recType = dns.TypeCNAME } + absName := libdns.AbsoluteName(zrec.record.Name, zrec.zone) + var err error start := time.Now() for time.Since(start) < timeout { @@ -439,9 +441,9 @@ func (s *DNSManager) wait(ctx context.Context, zrec zoneRecord) error { return ctx.Err() } var ready bool - ready, err = checkDNSPropagation(libdns.AbsoluteName(zrec.record.Name, zrec.zone), recType, zrec.record.Value, checkAuthoritativeServers, resolvers) + ready, err = checkDNSPropagation(absName, recType, zrec.record.Value, checkAuthoritativeServers, resolvers) if err != nil { - return fmt.Errorf("checking DNS propagation of %q: %w", zrec.record.Name, err) + return fmt.Errorf("checking DNS propagation of %q (relative=%s zone=%s resolvers=%v): %w", absName, zrec.record.Name, zrec.zone, resolvers, err) } if ready { return nil diff --git a/zerosslissuer.go b/zerosslissuer.go index fc54791d..fab3152f 100644 --- a/zerosslissuer.go +++ b/zerosslissuer.go @@ -42,14 +42,13 @@ func NewZeroSSLIssuer(cfg *Config, template ZeroSSLIssuer) *ZeroSSLIssuer { } // ZeroSSLIssuer can get certificates from ZeroSSL's API. (To use ZeroSSL's ACME -// endpoint, use the ACMEIssuer instead.) +// endpoint, use the ACMEIssuer instead.) Note that use of the API is restricted +// by payment tier. type ZeroSSLIssuer struct { // The API key (or "access key") for using the ZeroSSL API. APIKey string // How many days the certificate should be valid for. - // Note that customizing certificate lifetime may be - // a paid feature. ValidityDays int // The host to bind to when opening a listener for From 409b5bdd82faa26280e25749018eb0ac61a386b2 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Fri, 29 Mar 2024 11:19:49 -0600 Subject: [PATCH 3/4] Fix README example --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 957132ef..16bd0032 100644 --- a/README.md +++ b/README.md @@ -405,8 +405,10 @@ To enable it, just set the `DNS01Solver` field on a `certmagic.ACMEIssuer` struc import "github.com/libdns/cloudflare" certmagic.DefaultACME.DNS01Solver = &certmagic.DNS01Solver{ - DNSProvider: &cloudflare.Provider{ - APIToken: "topsecret", + DNSManager: certmagic.DNSManager{ + DNSProvider: &cloudflare.Provider{ + APIToken: "topsecret", + }, }, } ``` From 5bfdfb7e65d30a16a803523f878268dbdb32e264 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Fri, 29 Mar 2024 11:22:40 -0600 Subject: [PATCH 4/4] Fix comment --- dnsutil.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dnsutil.go b/dnsutil.go index b42a24c6..a87a1a90 100644 --- a/dnsutil.go +++ b/dnsutil.go @@ -210,7 +210,7 @@ func populateNameserverPorts(servers []string) { } } -// checkDNSPropagation checks if the expected TXT record has been propagated to all authoritative nameservers. +// checkDNSPropagation checks if the expected record has been propagated to all authoritative nameservers. func checkDNSPropagation(fqdn string, recType uint16, expectedValue string, checkAuthoritativeServers bool, resolvers []string) (bool, error) { if !strings.HasSuffix(fqdn, ".") { fqdn += "." @@ -241,7 +241,7 @@ func checkDNSPropagation(fqdn string, recType uint16, expectedValue string, chec return checkAuthoritativeNss(fqdn, recType, expectedValue, resolvers) } -// checkAuthoritativeNss queries each of the given nameservers for the expected TXT record. +// checkAuthoritativeNss queries each of the given nameservers for the expected record. func checkAuthoritativeNss(fqdn string, recType uint16, expectedValue string, nameservers []string) (bool, error) { for _, ns := range nameservers { r, err := dnsQuery(fqdn, recType, []string{ns}, true)