Skip to content

Commit f7ea6fb

Browse files
committed
Enhancements to make ZeroSSL issuer more usable in Caddy
1 parent 74862ff commit f7ea6fb

12 files changed

+110
-93
lines changed

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -522,7 +522,7 @@ To use ZeroSSL's API instead, use the [`ZeroSSLIssuer`](https://pkg.go.dev/githu
522522
magic := certmagic.NewDefault()
523523

524524
magic.Issuers = []certmagic.Issuer{
525-
certmagic.NewZeroSSLIssuer(magic, certmagic.ZeroSSLIssuer{
525+
certmagic.ZeroSSLIssuer{
526526
APIKey: "<your ZeroSSL API key>",
527527
}),
528528
}

acmeclient.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -161,8 +161,8 @@ func (iss *ACMEIssuer) newACMEClient(useTestCA bool) (*acmez.Client, error) {
161161
if err != nil {
162162
return nil, err
163163
}
164-
if u.Scheme != "https" && !isLoopback(u.Host) && !isInternal(u.Host) {
165-
return nil, fmt.Errorf("%s: insecure CA URL (HTTPS required)", caURL)
164+
if u.Scheme != "https" && !SubjectIsInternal(u.Host) {
165+
return nil, fmt.Errorf("%s: insecure CA URL (HTTPS required for non-internal CA)", caURL)
166166
}
167167

168168
client := &acmez.Client{

acmeissuer.go

+22-4
Original file line numberDiff line numberDiff line change
@@ -323,14 +323,32 @@ func (iss *ACMEIssuer) isAgreed() bool {
323323

324324
// PreCheck performs a few simple checks before obtaining or
325325
// renewing a certificate with ACME, and returns whether this
326-
// batch is eligible for certificates if using Let's Encrypt.
327-
// It also ensures that an email address is available.
326+
// batch is eligible for certificates. It also ensures that an
327+
// email address is available if possible.
328+
//
329+
// IP certificates via ACME are defined in RFC 8738.
328330
func (am *ACMEIssuer) PreCheck(ctx context.Context, names []string, interactive bool) error {
329-
publicCA := strings.Contains(am.CA, "api.letsencrypt.org") || strings.Contains(am.CA, "acme.zerossl.com") || strings.Contains(am.CA, "api.pki.goog")
331+
publicCAsAndIPCerts := map[string]bool{ // map of public CAs to whether they support IP certificates (last updated: Q1 2024)
332+
"api.letsencrypt.org": false, // https://community.letsencrypt.org/t/certificate-for-static-ip/84/2?u=mholt
333+
"acme.zerossl.com": false, // only supported via their API, not ACME endpoint
334+
"api.pki.goog": true, // https://pki.goog/faq/#faq-IPCerts
335+
"api.buypass.com": false, // https://community.buypass.com/t/h7hm76w/buypass-support-for-rfc-8738
336+
"acme.ssl.com": false,
337+
}
338+
var publicCA, ipCertAllowed bool
339+
for caSubstr, ipCert := range publicCAsAndIPCerts {
340+
if strings.Contains(am.CA, caSubstr) {
341+
publicCA, ipCertAllowed = true, ipCert
342+
break
343+
}
344+
}
330345
if publicCA {
331346
for _, name := range names {
332347
if !SubjectQualifiesForPublicCert(name) {
333-
return fmt.Errorf("subject does not qualify for a public certificate: %s", name)
348+
return fmt.Errorf("subject '%s' does not qualify for a public certificate", name)
349+
}
350+
if !ipCertAllowed && SubjectIsIP(name) {
351+
return fmt.Errorf("subject '%s' cannot have public IP certificate from %s (if CA's policy has changed, please notify the developers in an issue)", name, am.CA)
334352
}
335353
}
336354
}

cache.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ package certmagic
1616

1717
import (
1818
"fmt"
19-
weakrand "math/rand" // seeded elsewhere
19+
weakrand "math/rand"
2020
"strings"
2121
"sync"
2222
"time"

certificates.go

+48-9
Original file line numberDiff line numberDiff line change
@@ -386,22 +386,18 @@ func SubjectQualifiesForCert(subj string) bool {
386386

387387
// SubjectQualifiesForPublicCert returns true if the subject
388388
// name appears eligible for automagic TLS with a public
389-
// CA such as Let's Encrypt. For example: localhost and IP
390-
// addresses are not eligible because we cannot obtain certs
389+
// CA such as Let's Encrypt. For example: internal IP addresses
390+
// and localhost are not eligible because we cannot obtain certs
391391
// for those names with a public CA. Wildcard names are
392392
// allowed, as long as they conform to CABF requirements (only
393393
// one wildcard label, and it must be the left-most label).
394394
func SubjectQualifiesForPublicCert(subj string) bool {
395395
// must at least qualify for a certificate
396396
return SubjectQualifiesForCert(subj) &&
397397

398-
// localhost, .localhost TLD, and .local TLD are ineligible
398+
// loopback hosts and internal IPs are ineligible
399399
!SubjectIsInternal(subj) &&
400400

401-
// cannot be an IP address (as of yet), see
402-
// https://community.letsencrypt.org/t/certificate-for-static-ip/84/2?u=mholt
403-
!SubjectIsIP(subj) &&
404-
405401
// only one wildcard label allowed, and it must be left-most, with 3+ labels
406402
(!strings.Contains(subj, "*") ||
407403
(strings.Count(subj, "*") == 1 &&
@@ -416,12 +412,55 @@ func SubjectIsIP(subj string) bool {
416412
}
417413

418414
// SubjectIsInternal returns true if subj is an internal-facing
419-
// hostname or address.
415+
// hostname or address, including localhost/loopback hosts.
416+
// Ports are ignored, if present.
420417
func SubjectIsInternal(subj string) bool {
418+
subj = strings.ToLower(strings.TrimSuffix(hostOnly(subj), "."))
421419
return subj == "localhost" ||
422420
strings.HasSuffix(subj, ".localhost") ||
423421
strings.HasSuffix(subj, ".local") ||
424-
strings.HasSuffix(subj, ".home.arpa")
422+
strings.HasSuffix(subj, ".home.arpa") ||
423+
isInternalIP(subj)
424+
}
425+
426+
// isInternalIP returns true if the IP of addr
427+
// belongs to a private network IP range. addr
428+
// must only be an IP or an IP:port combination.
429+
func isInternalIP(addr string) bool {
430+
privateNetworks := []string{
431+
"127.0.0.0/8", // IPv4 loopback
432+
"0.0.0.0/16",
433+
"10.0.0.0/8", // RFC1918
434+
"172.16.0.0/12", // RFC1918
435+
"192.168.0.0/16", // RFC1918
436+
"169.254.0.0/16", // RFC3927 link-local
437+
"::1/7", // IPv6 loopback
438+
"fe80::/10", // IPv6 link-local
439+
"fc00::/7", // IPv6 unique local addr
440+
}
441+
host := hostOnly(addr)
442+
ip := net.ParseIP(host)
443+
if ip == nil {
444+
return false
445+
}
446+
for _, privateNetwork := range privateNetworks {
447+
_, ipnet, _ := net.ParseCIDR(privateNetwork)
448+
if ipnet.Contains(ip) {
449+
return true
450+
}
451+
}
452+
return false
453+
}
454+
455+
// hostOnly returns only the host portion of hostport.
456+
// If there is no port or if there is an error splitting
457+
// the port off, the whole input string is returned.
458+
func hostOnly(hostport string) string {
459+
host, _, err := net.SplitHostPort(hostport)
460+
if err != nil {
461+
return hostport // OK; probably had no port to begin with
462+
}
463+
return host
425464
}
426465

427466
// MatchWildcard returns true if subject (a candidate DNS name)

certificates_test.go

+3-2
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,8 @@ func TestSubjectQualifiesForPublicCert(t *testing.T) {
143143
{"Sub.Example.COM", true},
144144
{"127.0.0.1", false},
145145
{"127.0.1.5", false},
146-
{"69.123.43.94", false},
146+
{"1.2.3.4", true},
147+
{"69.123.43.94", true},
147148
{"::1", false},
148149
{"::", false},
149150
{"0.0.0.0", false},
@@ -166,7 +167,7 @@ func TestSubjectQualifiesForPublicCert(t *testing.T) {
166167
{"foo.bar.home.arpa", false},
167168
{"192.168.1.3", false},
168169
{"10.0.2.1", false},
169-
{"169.112.53.4", false},
170+
{"169.112.53.4", true},
170171
{"$hostname", false},
171172
{"%HOSTNAME%", false},
172173
{"{hostname}", false},

certmagic.go

-46
Original file line numberDiff line numberDiff line change
@@ -302,52 +302,6 @@ type OnDemandConfig struct {
302302
hostAllowlist map[string]struct{}
303303
}
304304

305-
// isLoopback returns true if the hostname of addr looks
306-
// explicitly like a common local hostname. addr must only
307-
// be a host or a host:port combination.
308-
func isLoopback(addr string) bool {
309-
host := hostOnly(addr)
310-
return host == "localhost" ||
311-
strings.Trim(host, "[]") == "::1" ||
312-
strings.HasPrefix(host, "127.")
313-
}
314-
315-
// isInternal returns true if the IP of addr
316-
// belongs to a private network IP range. addr
317-
// must only be an IP or an IP:port combination.
318-
// Loopback addresses are considered false.
319-
func isInternal(addr string) bool {
320-
privateNetworks := []string{
321-
"10.0.0.0/8",
322-
"172.16.0.0/12",
323-
"192.168.0.0/16",
324-
"fc00::/7",
325-
}
326-
host := hostOnly(addr)
327-
ip := net.ParseIP(host)
328-
if ip == nil {
329-
return false
330-
}
331-
for _, privateNetwork := range privateNetworks {
332-
_, ipnet, _ := net.ParseCIDR(privateNetwork)
333-
if ipnet.Contains(ip) {
334-
return true
335-
}
336-
}
337-
return false
338-
}
339-
340-
// hostOnly returns only the host portion of hostport.
341-
// If there is no port or if there is an error splitting
342-
// the port off, the whole input string is returned.
343-
func hostOnly(hostport string) string {
344-
host, _, err := net.SplitHostPort(hostport)
345-
if err != nil {
346-
return hostport // OK; probably had no port to begin with
347-
}
348-
return host
349-
}
350-
351305
// PreChecker is an interface that can be optionally implemented by
352306
// Issuers. Pre-checks are performed before each call (or batch of
353307
// identical calls) to Issue(), giving the issuer the option to ensure

config.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -599,7 +599,7 @@ func (cfg *Config) obtainCert(ctx context.Context, name string, interactive bool
599599
// are compliant, so their CSR requirements just needlessly add friction, complexity,
600600
// and inefficiency for clients. CommonName has been deprecated for 25+ years.
601601
useCSR := csr
602-
if _, ok := issuer.(*ZeroSSLIssuer); ok {
602+
if issuer.IssuerKey() == zerosslIssuerKey {
603603
useCSR, err = cfg.generateCSR(privKey, []string{name}, true)
604604
if err != nil {
605605
return err

crypto.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,7 @@ func hashCertificateChain(certChain [][]byte) string {
281281
func namesFromCSR(csr *x509.CertificateRequest) []string {
282282
var nameSet []string
283283
// 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...)
284+
// but ZeroSSL CA still requires it to be filled out and not overlap SANs...)
285285
if csr.Subject.CommonName != "" {
286286
nameSet = append(nameSet, csr.Subject.CommonName)
287287
}

go.mod

+3-1
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ module github.com/caddyserver/certmagic
22

33
go 1.22.0
44

5+
toolchain go1.22.2
6+
57
require (
6-
github.com/caddyserver/zerossl v0.1.1
8+
github.com/caddyserver/zerossl v0.1.2
79
github.com/klauspost/cpuid/v2 v2.2.7
810
github.com/libdns/libdns v0.2.2
911
github.com/mholt/acmez/v2 v2.0.0-beta.2

go.sum

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
github.com/caddyserver/zerossl v0.1.1 h1:yQL7QXZnEb/ddH6JsNPGBANETUMHPFlAV5+a+Epxgbo=
2-
github.com/caddyserver/zerossl v0.1.1/go.mod h1:wtiJEHbdvunr40ZzhXlnIkOB8Xj4eKtBKizCcZitJiQ=
1+
github.com/caddyserver/zerossl v0.1.2 h1:tlEu1VzWGoqcCpivs9liKAKhfpJWYJkHEMmlxRbVAxE=
2+
github.com/caddyserver/zerossl v0.1.2/go.mod h1:wtiJEHbdvunr40ZzhXlnIkOB8Xj4eKtBKizCcZitJiQ=
33
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
44
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
55
github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=

zerosslissuer.go

+26-23
Original file line numberDiff line numberDiff line change
@@ -26,26 +26,17 @@ import (
2626
"time"
2727

2828
"github.com/caddyserver/zerossl"
29+
"github.com/mholt/acmez/v2"
2930
"github.com/mholt/acmez/v2/acme"
3031
"go.uber.org/zap"
3132
)
3233

33-
// NewZeroSSLIssuer returns a ZeroSSL issuer with default values filled in
34-
// for empty fields in the template.
35-
func NewZeroSSLIssuer(cfg *Config, template ZeroSSLIssuer) *ZeroSSLIssuer {
36-
if cfg == nil {
37-
panic("cannot make valid ZeroSSLIssuer without an associated CertMagic config")
38-
}
39-
template.config = cfg
40-
template.logger = defaultLogger.Named("zerossl")
41-
return &template
42-
}
43-
4434
// ZeroSSLIssuer can get certificates from ZeroSSL's API. (To use ZeroSSL's ACME
4535
// endpoint, use the ACMEIssuer instead.) Note that use of the API is restricted
4636
// by payment tier.
4737
type ZeroSSLIssuer struct {
4838
// The API key (or "access key") for using the ZeroSSL API.
39+
// REQUIRED.
4940
APIKey string
5041

5142
// How many days the certificate should be valid for.
@@ -63,16 +54,26 @@ type ZeroSSLIssuer struct {
6354
// validation, set this field.
6455
CNAMEValidation *DNSManager
6556

66-
config *Config
67-
logger *zap.Logger
57+
// Where to store verification material temporarily.
58+
// Set this on all instances in a cluster to the same
59+
// value to enable distributed verification.
60+
Storage Storage
61+
62+
// An optional (but highly recommended) logger.
63+
Logger *zap.Logger
6864
}
6965

7066
// Issue obtains a certificate for the given csr.
7167
func (iss *ZeroSSLIssuer) Issue(ctx context.Context, csr *x509.CertificateRequest) (*IssuedCertificate, error) {
7268
client := iss.getClient()
7369

7470
identifiers := namesFromCSR(csr)
75-
logger := iss.logger.With(zap.Strings("identifiers", identifiers))
71+
72+
logger := iss.Logger
73+
if logger == nil {
74+
logger = zap.NewNop()
75+
}
76+
logger = logger.With(zap.Strings("identifiers", identifiers))
7677

7778
logger.Info("creating certificate")
7879

@@ -134,16 +135,19 @@ func (iss *ZeroSSLIssuer) Issue(ctx context.Context, csr *x509.CertificateReques
134135
}),
135136
}
136137

137-
distSolver := distributedSolver{
138-
storage: iss.config.Storage,
139-
storageKeyIssuerPrefix: "zerossl",
140-
solver: httpVerifier,
138+
var solver acmez.Solver = httpVerifier
139+
if iss.Storage != nil {
140+
solver = distributedSolver{
141+
storage: iss.Storage,
142+
storageKeyIssuerPrefix: iss.IssuerKey(),
143+
solver: httpVerifier,
144+
}
141145
}
142146

143-
if err = distSolver.Present(ctx, acme.Challenge{}); err != nil {
147+
if err = solver.Present(ctx, acme.Challenge{}); err != nil {
144148
return nil, fmt.Errorf("presenting token for verification: %v", err)
145149
}
146-
defer distSolver.CleanUp(ctx, acme.Challenge{})
150+
defer solver.CleanUp(ctx, acme.Challenge{})
147151
} else {
148152
verificationMethod = zerossl.CNAMEVerification
149153
logger = logger.With(zap.String("verification_method", string(verificationMethod)))
@@ -248,9 +252,7 @@ func (iss *ZeroSSLIssuer) getHTTPPort() int {
248252
}
249253

250254
// IssuerKey returns the unique issuer key for ZeroSSL.
251-
func (iss *ZeroSSLIssuer) IssuerKey() string {
252-
return "zerossl"
253-
}
255+
func (iss *ZeroSSLIssuer) IssuerKey() string { return zerosslIssuerKey }
254256

255257
// Revoke revokes the given certificate. Only do this if there is a security or trust
256258
// concern with the certificate.
@@ -274,6 +276,7 @@ func (iss *ZeroSSLIssuer) Revoke(ctx context.Context, cert CertificateResource,
274276
const (
275277
zerosslAPIBase = "https://" + zerossl.BaseURL + "/acme"
276278
zerosslValidationPathPrefix = "/.well-known/pki-validation/"
279+
zerosslIssuerKey = "zerossl"
277280
)
278281

279282
// Interface guards

0 commit comments

Comments
 (0)