Skip to content

Commit 6095ab8

Browse files
authored
Initial implementation of ZeroSSL API issuer (#279)
* Initial implementation of ZeroSSL API issuer Still needs CA support for CommonName-less certs * Accommodate ZeroSSL CSR requirements; fix DNS prop check * Fix README example * Fix comment
1 parent c61a4fe commit 6095ab8

11 files changed

+623
-181
lines changed

README.md

+34-9
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,16 @@ CertMagic - Automatic HTTPS using Let's Encrypt
6060
- [Advanced use](#advanced-use)
6161
- [Wildcard Certificates](#wildcard-certificates)
6262
- [Behind a load balancer (or in a cluster)](#behind-a-load-balancer-or-in-a-cluster)
63-
- [The ACME Challenges](#the-acme-challenges)
64-
- [HTTP Challenge](#http-challenge)
65-
- [TLS-ALPN Challenge](#tls-alpn-challenge)
66-
- [DNS Challenge](#dns-challenge)
67-
- [On-Demand TLS](#on-demand-tls)
68-
- [Storage](#storage)
69-
- [Cache](#cache)
63+
- [The ACME Challenges](#the-acme-challenges)
64+
- [HTTP Challenge](#http-challenge)
65+
- [TLS-ALPN Challenge](#tls-alpn-challenge)
66+
- [DNS Challenge](#dns-challenge)
67+
- [On-Demand TLS](#on-demand-tls)
68+
- [Storage](#storage)
69+
- [Cache](#cache)
70+
- [Events](#events)
71+
- [ZeroSSL](#zerossl)
72+
- [FAQ](#faq)
7073
- [Contributing](#contributing)
7174
- [Project History](#project-history)
7275
- [Credits and License](#credits-and-license)
@@ -402,8 +405,10 @@ To enable it, just set the `DNS01Solver` field on a `certmagic.ACMEIssuer` struc
402405
import "github.com/libdns/cloudflare"
403406

404407
certmagic.DefaultACME.DNS01Solver = &certmagic.DNS01Solver{
405-
DNSProvider: &cloudflare.Provider{
406-
APIToken: "topsecret",
408+
DNSManager: certmagic.DNSManager{
409+
DNSProvider: &cloudflare.Provider{
410+
APIToken: "topsecret",
411+
},
407412
},
408413
}
409414
```
@@ -505,6 +510,26 @@ CertMagic emits events when possible things of interest happen. Set the [`OnEven
505510

506511
`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.
507512

513+
## ZeroSSL
514+
515+
ZeroSSL has both ACME and HTTP API services for getting certificates. CertMagic works with both of them.
516+
517+
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.
518+
519+
To use ZeroSSL's API instead, use the [`ZeroSSLIssuer`](https://pkg.go.dev/github.com/caddyserver/certmagic#ZeroSSLIssuer). Here is a simple example:
520+
521+
```go
522+
magic := certmagic.NewDefault()
523+
524+
magic.Issuers = []certmagic.Issuer{
525+
certmagic.NewZeroSSLIssuer(magic, certmagic.ZeroSSLIssuer{
526+
APIKey: "<your ZeroSSL API key>",
527+
}),
528+
}
529+
530+
err := magic.ManageSync(ctx, []string{"example.com"})
531+
```
532+
508533
## FAQ
509534

510535
### Can I use some of my own certificates while using CertMagic?

acmeclient.go

+26-17
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"fmt"
2121
weakrand "math/rand"
2222
"net"
23+
"net/http"
2324
"net/url"
2425
"strconv"
2526
"strings"
@@ -186,38 +187,24 @@ func (iss *ACMEIssuer) newACMEClient(useTestCA bool) (*acmez.Client, error) {
186187
if iss.DNS01Solver == nil {
187188
// enable HTTP-01 challenge
188189
if !iss.DisableHTTPChallenge {
189-
useHTTPPort := HTTPChallengePort
190-
if HTTPPort > 0 && HTTPPort != HTTPChallengePort {
191-
useHTTPPort = HTTPPort
192-
}
193-
if iss.AltHTTPPort > 0 {
194-
useHTTPPort = iss.AltHTTPPort
195-
}
196190
client.ChallengeSolvers[acme.ChallengeTypeHTTP01] = distributedSolver{
197191
storage: iss.config.Storage,
198192
storageKeyIssuerPrefix: iss.storageKeyCAPrefix(client.Directory),
199193
solver: &httpSolver{
200-
acmeIssuer: iss,
201-
address: net.JoinHostPort(iss.ListenHost, strconv.Itoa(useHTTPPort)),
194+
handler: iss.HTTPChallengeHandler(http.NewServeMux()),
195+
address: net.JoinHostPort(iss.ListenHost, strconv.Itoa(iss.getHTTPPort())),
202196
},
203197
}
204198
}
205199

206200
// enable TLS-ALPN-01 challenge
207201
if !iss.DisableTLSALPNChallenge {
208-
useTLSALPNPort := TLSALPNChallengePort
209-
if HTTPSPort > 0 && HTTPSPort != TLSALPNChallengePort {
210-
useTLSALPNPort = HTTPSPort
211-
}
212-
if iss.AltTLSALPNPort > 0 {
213-
useTLSALPNPort = iss.AltTLSALPNPort
214-
}
215202
client.ChallengeSolvers[acme.ChallengeTypeTLSALPN01] = distributedSolver{
216203
storage: iss.config.Storage,
217204
storageKeyIssuerPrefix: iss.storageKeyCAPrefix(client.Directory),
218205
solver: &tlsALPNSolver{
219206
config: iss.config,
220-
address: net.JoinHostPort(iss.ListenHost, strconv.Itoa(useTLSALPNPort)),
207+
address: net.JoinHostPort(iss.ListenHost, strconv.Itoa(iss.getTLSALPNPort())),
221208
},
222209
}
223210
}
@@ -248,6 +235,28 @@ func (iss *ACMEIssuer) newACMEClient(useTestCA bool) (*acmez.Client, error) {
248235
return client, nil
249236
}
250237

238+
func (iss *ACMEIssuer) getHTTPPort() int {
239+
useHTTPPort := HTTPChallengePort
240+
if HTTPPort > 0 && HTTPPort != HTTPChallengePort {
241+
useHTTPPort = HTTPPort
242+
}
243+
if iss.AltHTTPPort > 0 {
244+
useHTTPPort = iss.AltHTTPPort
245+
}
246+
return useHTTPPort
247+
}
248+
249+
func (iss *ACMEIssuer) getTLSALPNPort() int {
250+
useTLSALPNPort := TLSALPNChallengePort
251+
if HTTPSPort > 0 && HTTPSPort != TLSALPNChallengePort {
252+
useTLSALPNPort = HTTPSPort
253+
}
254+
if iss.AltTLSALPNPort > 0 {
255+
useTLSALPNPort = iss.AltTLSALPNPort
256+
}
257+
return useTLSALPNPort
258+
}
259+
251260
func (c *acmeClient) throttle(ctx context.Context, names []string) error {
252261
email := c.iss.getEmail()
253262

acmeissuer.go

+14
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,17 @@
1+
// Copyright 2015 Matthew Holt
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
115
package certmagic
216

317
import (

certificates.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ func (cfg *Config) loadManagedCertificate(ctx context.Context, domain string) (C
162162
//
163163
// This method is safe for concurrent use.
164164
func (cfg *Config) CacheUnmanagedCertificatePEMFile(ctx context.Context, certFile, keyFile string, tags []string) (string, error) {
165-
cert, err := cfg.makeCertificateFromDiskWithOCSP(ctx, cfg.Storage, certFile, keyFile)
165+
cert, err := cfg.makeCertificateFromDiskWithOCSP(ctx, certFile, keyFile)
166166
if err != nil {
167167
return "", err
168168
}
@@ -224,7 +224,7 @@ func (cfg *Config) CacheUnmanagedCertificatePEMBytes(ctx context.Context, certBy
224224
// certificate and key files. It fills out all the fields in
225225
// the certificate except for the Managed and OnDemand flags.
226226
// (It is up to the caller to set those.) It staples OCSP.
227-
func (cfg Config) makeCertificateFromDiskWithOCSP(ctx context.Context, storage Storage, certFile, keyFile string) (Certificate, error) {
227+
func (cfg Config) makeCertificateFromDiskWithOCSP(ctx context.Context, certFile, keyFile string) (Certificate, error) {
228228
certPEMBlock, err := os.ReadFile(certFile)
229229
if err != nil {
230230
return Certificate{}, err

config.go

+35-5
Original file line numberDiff line numberDiff line change
@@ -562,7 +562,7 @@ func (cfg *Config) obtainCert(ctx context.Context, name string, interactive bool
562562
}
563563
}
564564

565-
csr, err := cfg.generateCSR(privKey, []string{name})
565+
csr, err := cfg.generateCSR(privKey, []string{name}, false)
566566
if err != nil {
567567
return err
568568
}
@@ -584,7 +584,19 @@ func (cfg *Config) obtainCert(ctx context.Context, name string, interactive bool
584584
}
585585
}
586586

587-
issuedCert, err = issuer.Issue(ctx, csr)
587+
// TODO: ZeroSSL's API currently requires CommonName to be set, and requires it be
588+
// distinct from SANs. If this was a cert it would violate the BRs, but their certs
589+
// are compliant, so their CSR requirements just needlessly add friction, complexity,
590+
// and inefficiency for clients. CommonName has been deprecated for 25+ years.
591+
useCSR := csr
592+
if _, ok := issuer.(*ZeroSSLIssuer); ok {
593+
useCSR, err = cfg.generateCSR(privKey, []string{name}, true)
594+
if err != nil {
595+
return err
596+
}
597+
}
598+
599+
issuedCert, err = issuer.Issue(ctx, useCSR)
588600
if err == nil {
589601
issuerUsed = issuer
590602
break
@@ -808,7 +820,7 @@ func (cfg *Config) renewCert(ctx context.Context, name string, force, interactiv
808820
}
809821
}
810822

811-
csr, err := cfg.generateCSR(privateKey, []string{name})
823+
csr, err := cfg.generateCSR(privateKey, []string{name}, false)
812824
if err != nil {
813825
return err
814826
}
@@ -818,6 +830,18 @@ func (cfg *Config) renewCert(ctx context.Context, name string, force, interactiv
818830
var issuerUsed Issuer
819831
var issuerKeys []string
820832
for _, issuer := range cfg.Issuers {
833+
// TODO: ZeroSSL's API currently requires CommonName to be set, and requires it be
834+
// distinct from SANs. If this was a cert it would violate the BRs, but their certs
835+
// are compliant, so their CSR requirements just needlessly add friction, complexity,
836+
// and inefficiency for clients. CommonName has been deprecated for 25+ years.
837+
useCSR := csr
838+
if _, ok := issuer.(*ZeroSSLIssuer); ok {
839+
useCSR, err = cfg.generateCSR(privateKey, []string{name}, true)
840+
if err != nil {
841+
return err
842+
}
843+
}
844+
821845
issuerKeys = append(issuerKeys, issuer.IssuerKey())
822846
if prechecker, ok := issuer.(PreChecker); ok {
823847
err = prechecker.PreCheck(ctx, []string{name}, interactive)
@@ -826,7 +850,7 @@ func (cfg *Config) renewCert(ctx context.Context, name string, force, interactiv
826850
}
827851
}
828852

829-
issuedCert, err = issuer.Issue(ctx, csr)
853+
issuedCert, err = issuer.Issue(ctx, useCSR)
830854
if err == nil {
831855
issuerUsed = issuer
832856
break
@@ -898,10 +922,16 @@ func (cfg *Config) renewCert(ctx context.Context, name string, force, interactiv
898922
return err
899923
}
900924

901-
func (cfg *Config) generateCSR(privateKey crypto.PrivateKey, sans []string) (*x509.CertificateRequest, error) {
925+
// 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).
926+
func (cfg *Config) generateCSR(privateKey crypto.PrivateKey, sans []string, useCN bool) (*x509.CertificateRequest, error) {
902927
csrTemplate := new(x509.CertificateRequest)
903928

904929
for _, name := range sans {
930+
// TODO: This is a temporary hack to support ZeroSSL API...
931+
if useCN && csrTemplate.Subject.CommonName == "" && len(name) <= 64 {
932+
csrTemplate.Subject.CommonName = name
933+
continue
934+
}
905935
if ip := net.ParseIP(name); ip != nil {
906936
csrTemplate.IPAddresses = append(csrTemplate.IPAddresses, ip)
907937
} else if strings.Contains(name, "@") {

crypto.go

+5
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,11 @@ func hashCertificateChain(certChain [][]byte) string {
280280

281281
func namesFromCSR(csr *x509.CertificateRequest) []string {
282282
var nameSet []string
283+
// TODO: CommonName should not be used (it has been deprecated for 25+ years,
284+
// but Sectigo CA still requires it to be filled out and not overlap SANs...)
285+
if csr.Subject.CommonName != "" {
286+
nameSet = append(nameSet, csr.Subject.CommonName)
287+
}
283288
nameSet = append(nameSet, csr.DNSNames...)
284289
nameSet = append(nameSet, csr.EmailAddresses...)
285290
for _, v := range csr.IPAddresses {

dnsutil.go

+37-24
Original file line numberDiff line numberDiff line change
@@ -210,42 +210,43 @@ func populateNameserverPorts(servers []string) {
210210
}
211211
}
212212

213-
// checkDNSPropagation checks if the expected TXT record has been propagated.
214-
// If checkAuthoritativeServers is true, the authoritative nameservers are checked directly,
215-
// otherwise only the given resolvers are checked.
216-
func checkDNSPropagation(fqdn, value string, resolvers []string, checkAuthoritativeServers bool) (bool, error) {
213+
// checkDNSPropagation checks if the expected record has been propagated to all authoritative nameservers.
214+
func checkDNSPropagation(fqdn string, recType uint16, expectedValue string, checkAuthoritativeServers bool, resolvers []string) (bool, error) {
217215
if !strings.HasSuffix(fqdn, ".") {
218216
fqdn += "."
219217
}
220218

221-
// Initial attempt to resolve at the recursive NS
222-
r, err := dnsQuery(fqdn, dns.TypeTXT, resolvers, true)
223-
if err != nil {
224-
return false, err
225-
}
226-
227-
if r.Rcode == dns.RcodeSuccess {
228-
fqdn = updateDomainWithCName(r, fqdn)
219+
// Initial attempt to resolve at the recursive NS - but do not actually
220+
// dereference (follow) a CNAME record if we are targeting a CNAME record
221+
// itself
222+
if recType != dns.TypeCNAME {
223+
r, err := dnsQuery(fqdn, recType, resolvers, true)
224+
if err != nil {
225+
return false, fmt.Errorf("CNAME dns query: %v", err)
226+
}
227+
if r.Rcode == dns.RcodeSuccess {
228+
fqdn = updateDomainWithCName(r, fqdn)
229+
}
229230
}
230231

231232
if checkAuthoritativeServers {
232233
authoritativeServers, err := lookupNameservers(fqdn, resolvers)
233234
if err != nil {
234-
return false, err
235+
return false, fmt.Errorf("looking up authoritative nameservers: %v", err)
235236
}
236237
populateNameserverPorts(authoritativeServers)
237238
resolvers = authoritativeServers
238239
}
239240

240-
return checkNameservers(fqdn, value, resolvers)
241+
return checkAuthoritativeNss(fqdn, recType, expectedValue, resolvers)
241242
}
242243

243-
// checkNameservers checks if any of the given nameservers has the expected TXT record.
244-
func checkNameservers(fqdn, value string, nameservers []string) (bool, error) {
244+
// checkAuthoritativeNss queries each of the given nameservers for the expected record.
245+
func checkAuthoritativeNss(fqdn string, recType uint16, expectedValue string, nameservers []string) (bool, error) {
245246
for _, ns := range nameservers {
246-
r, err := dnsQuery(fqdn, dns.TypeTXT, []string{ns}, true)
247+
r, err := dnsQuery(fqdn, recType, []string{ns}, true)
247248
if err != nil {
248-
return false, err
249+
return false, fmt.Errorf("querying authoritative nameservers: %v", err)
249250
}
250251

251252
if r.Rcode != dns.RcodeSuccess {
@@ -259,11 +260,23 @@ func checkNameservers(fqdn, value string, nameservers []string) (bool, error) {
259260
}
260261

261262
for _, rr := range r.Answer {
262-
if txt, ok := rr.(*dns.TXT); ok {
263-
record := strings.Join(txt.Txt, "")
264-
if record == value {
265-
return true, nil
263+
switch recType {
264+
case dns.TypeTXT:
265+
if txt, ok := rr.(*dns.TXT); ok {
266+
record := strings.Join(txt.Txt, "")
267+
if record == expectedValue {
268+
return true, nil
269+
}
266270
}
271+
case dns.TypeCNAME:
272+
if cname, ok := rr.(*dns.CNAME); ok {
273+
// TODO: whether a DNS provider assumes a trailing dot or not varies, and we may have to standardize this in libdns packages
274+
if strings.TrimSuffix(cname.Target, ".") == strings.TrimSuffix(expectedValue, ".") {
275+
return true, nil
276+
}
277+
}
278+
default:
279+
return false, fmt.Errorf("unsupported record type: %d", recType)
267280
}
268281
}
269282
}
@@ -277,12 +290,12 @@ func lookupNameservers(fqdn string, resolvers []string) ([]string, error) {
277290

278291
zone, err := findZoneByFQDN(fqdn, resolvers)
279292
if err != nil {
280-
return nil, fmt.Errorf("could not determine the zone: %w", err)
293+
return nil, fmt.Errorf("could not determine the zone for '%s': %w", fqdn, err)
281294
}
282295

283296
r, err := dnsQuery(zone, dns.TypeNS, resolvers, true)
284297
if err != nil {
285-
return nil, err
298+
return nil, fmt.Errorf("querying NS resolver for zone '%s' recursively: %v", zone, err)
286299
}
287300

288301
for _, rr := range r.Answer {

0 commit comments

Comments
 (0)