Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial implementation of ZeroSSL API issuer #279

Merged
merged 5 commits into from
Apr 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 34 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -402,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",
},
},
}
```
Expand Down Expand Up @@ -505,6 +510,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: "<your ZeroSSL API key>",
}),
}

err := magic.ManageSync(ctx, []string{"example.com"})
```

## FAQ

### Can I use some of my own certificates while using CertMagic?
Expand Down
43 changes: 26 additions & 17 deletions acmeclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"fmt"
weakrand "math/rand"
"net"
"net/http"
"net/url"
"strconv"
"strings"
Expand Down Expand Up @@ -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())),
},
}
}
Expand Down Expand Up @@ -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()

Expand Down
14 changes: 14 additions & 0 deletions acmeissuer.go
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down
4 changes: 2 additions & 2 deletions certificates.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
40 changes: 35 additions & 5 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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, "@") {
Expand Down
5 changes: 5 additions & 0 deletions crypto.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
61 changes: 37 additions & 24 deletions dnsutil.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,42 +210,43 @@ func populateNameserverPorts(servers []string) {
}
}

// checkDNSPropagation checks if the expected TXT record has been propagated.
// If checkAuthoritativeServers is true, the authoritative nameservers are checked directly,
// otherwise only the given resolvers are checked.
func checkDNSPropagation(fqdn, value string, resolvers []string, checkAuthoritativeServers bool) (bool, error) {
// 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 += "."
}

// 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, fmt.Errorf("CNAME dns query: %v", err)
}
if r.Rcode == dns.RcodeSuccess {
fqdn = updateDomainWithCName(r, fqdn)
}
}

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
}

return checkNameservers(fqdn, value, resolvers)
return checkAuthoritativeNss(fqdn, recType, expectedValue, resolvers)
}

// checkNameservers checks if any of the given nameservers has the expected TXT record.
func checkNameservers(fqdn, value string, nameservers []string) (bool, error) {
// 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, dns.TypeTXT, []string{ns}, 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 {
Expand All @@ -259,11 +260,23 @@ func checkNameservers(fqdn, value string, nameservers []string) (bool, error) {
}

for _, rr := range r.Answer {
if txt, ok := rr.(*dns.TXT); ok {
record := strings.Join(txt.Txt, "")
if record == value {
return true, nil
switch recType {
case dns.TypeTXT:
if txt, ok := rr.(*dns.TXT); ok {
record := strings.Join(txt.Txt, "")
if record == expectedValue {
return true, nil
}
}
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, ".") {
return true, nil
}
}
default:
return false, fmt.Errorf("unsupported record type: %d", recType)
}
}
}
Expand All @@ -277,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 {
Expand Down
Loading
Loading