acmez icon indicating copy to clipboard operation
acmez copied to clipboard

When I try to request *.example.com and example.com, it will pending

Open r6c opened this issue 2 years ago • 12 comments

        client := acmez.Client{
		Client: &acme.Client{
			Directory: constants.DefaultCA,
		},
		ChallengeSolvers: map[string]acmez.Solver{
			acme.ChallengeTypeDNS01: &acme_issuer.DNS01Solver{
				PropagationTimeout: time.Minute * 15,
				PollingInterval:    time.Second * 10,
				TTL:                constants.DefaultDnsRecordTTL,
				DNSProvider:        dnsProvider,
			},
		},
	}
        domains:=[]string{"*.example.com","example.com"}
	log.Println("-----")
	log.Printf("%+v\n", domains)
        certs, err := client.ObtainCertificate(context.Background(), account.AcmeAccount, certPrivateKey, domains)
	if err != nil {
		log.Println(err)
		return nil, fmt.Errorf("obtaining certificate: %v", err)
	}
	log.Println("====")

it use one dns provider, cloudflare.

when i try one domain, it's works fine domains:=[]string{"*.example.com"} or domains:=[]string{"example.com"}

r6c avatar Jul 23 '22 06:07 r6c

log

2022/07/23 14:44:12 -----
2022/07/23 14:44:12 [*.example.com example.com]

timeout is 15m, but still no any response in 30m.

r6c avatar Jul 23 '22 06:07 r6c

{"level":"info","ts":1658569561.968958,"caller":"[email protected]/client.go:394","msg":"trying to solve challenge","identifier":"*.example.com","challenge_type":"dns-01","ca":"https://acme.zerossl.com/v2/DV90"}
{"level":"info","ts":1658569564.0227418,"caller":"[email protected]/client.go:394","msg":"trying to solve challenge","identifier":"example.com","challenge_type":"dns-01","ca":"https://acme.zerossl.com/v2/DV90"}

after this log, it's pending a few hours.

r6c avatar Jul 23 '22 09:07 r6c

I'm not sure I understand. Can you provide the full log output? (not just from your own program)

Be sure to set the Logger field of the acme.Client struct.

mholt avatar Jul 25 '22 16:07 mholt

package main

import (
	"awesomeProject/acme_issuer"
	"context"
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"fmt"
	"github.com/libdns/cloudflare"
	"github.com/mholt/acmez"
	"github.com/mholt/acmez/acme"
	"go.uber.org/zap"
	"log"
	"time"
)

func main() {
	domains := []string{"example.com", "*.example.com"}

	dnsProvider := &cloudflare.Provider{
		APIToken: "Key",
	}

	logger, err := zap.NewProduction()
	if err != nil {
		log.Fatalln(err)
	}
	defer logger.Sync() // flushes buffer, if any

	client := acmez.Client{
		Logger: logger,
		Client: &acme.Client{
			Logger:    logger,
			Directory: "https://acme.zerossl.com/v2/DV90",
		},
		ChallengeSolvers: map[string]acmez.Solver{
			acme.ChallengeTypeDNS01: &acme_issuer.DNS01Solver{
				PropagationTimeout: time.Minute * 15,
				PollingInterval:    time.Second * 10,
				TTL:                600,
				DNSProvider:        dnsProvider,
			},
		},
	}

	// Before you can get a cert, you'll need an account registered with
	// the ACME CA; it needs a private key which should obviously be
	// different from any key used for certificates!
	accountPrivateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		log.Fatalln(err)
	}

	ctx := context.Background()

	account := acme.Account{
		Contact:              []string{"mailto:[email protected]"},
		TermsOfServiceAgreed: true,
		PrivateKey:           accountPrivateKey,
	}
	err = account.SetExternalAccountBinding(ctx, client.Client, acme.EAB{
		KeyID:  `ID`,
		MACKey: `Key`,
	})
	if err != nil {
		log.Fatalln(err)
	}

	// If the account is new, we need to create it; only do this once!
	// then be sure to securely store the account key and metadata so
	// you can reuse it later!
	account, err = client.NewAccount(ctx, account)
	if err != nil {
		log.Fatalln(fmt.Sprintf("new account: %v", err))
	}

	// Every certificate needs a key.
	certPrivateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		log.Fatalln(fmt.Sprintf("generating certificate key: %v", err))
	}

	fmt.Println("ObtainCertificate...")

	certs, err := client.ObtainCertificate(context.Background(), account, certPrivateKey, domains)
	if err != nil {
		log.Fatalln(fmt.Sprintf("obtaining certificate: %v", err))
	}

	for _, cert := range certs {
		log.Println(cert)
	}
}

Log:

ObtainCertificate...
{"level":"info","ts":1658913829.171802,"caller":"[email protected]/client.go:394","msg":"trying to solve challenge","identifier":"example.com","challenge_type":"dns-01","ca":"https://acme.zerossl.com/v2/DV90"}
{"level":"info","ts":1658913831.3343675,"caller":"[email protected]/client.go:394","msg":"trying to solve challenge","identifier":"*.example.com","challenge_type":"dns-01","ca":"https://acme.zerossl.com/v2/DV90"}

r6c avatar Jul 27 '22 09:07 r6c

Snipaste_2022-07-27_17-27-49

r6c avatar Jul 27 '22 09:07 r6c

no more any log.

r6c avatar Jul 27 '22 09:07 r6c

If I use domains := []string{"*.example.com"}

Log:

ObtainCertificate...
{"level":"info","ts":1658915211.612598,"caller":"[email protected]/client.go:394","msg":"trying to solve challenge","identifier":"*.example.com","challenge_type":"dns-01","ca":"https://acme.zerossl.com/v2/DV90"}
{"level":"info","ts":1658915264.9595623,"caller":"[email protected]/client.go:164","msg":"validations succeeded; finalizing order","order":"https://acme.zerossl.com/v2/DV90/order/AYzAMUuxKdymhNRtrvTYw"}
{"level":"info","ts":1658915317.5602314,"caller":"[email protected]/client.go:184","msg":"successfully downloaded available certificate chains","count":1,"first_url":"https://acme.zerossl.com/v2/DV90/cert/qtPr6rDKyKTLlkQUPTgsA"}

r6c avatar Jul 27 '22 09:07 r6c

If I use domains := []string{"example.com","aaa.example.com"}, also okay

Log

ObtainCertificate...
{"level":"info","ts":1658915614.185599,"caller":"[email protected]/client.go:394","msg":"trying to solve challenge","identifier":"example.com","challenge_type":"dns-01","ca":"https://acme.zerossl.com/v2/DV90"}
{"level":"info","ts":1658915616.3788662,"caller":"[email protected]/client.go:394","msg":"trying to solve challenge","identifier":"aaa.example.com","challenge_type":"dns-01","ca":"https://acme.zerossl.com/v2/DV90"}
{"level":"info","ts":1658915712.5194244,"caller":"[email protected]/client.go:164","msg":"validations succeeded; finalizing order","order":"https://acme.zerossl.com/v2/DV90/order/2q60LQpdNjoHlef5ZfGvA"}
{"level":"info","ts":1658915760.238464,"caller":"[email protected]/client.go:184","msg":"successfully downloaded available certificate chains","count":1,"first_url":"https://acme.zerossl.com/v2/DV90/cert/qoX3XjHX9dqYymkTI7q9Q"}

r6c avatar Jul 27 '22 09:07 r6c

issuer.go is copy from certmagic

package acme_issuer

import (
	"context"
	"fmt"
	"github.com/libdns/libdns"
	"github.com/mholt/acmez/acme"
	"sync"
	"time"
)

// DNS01Solver is a type that makes libdns providers usable
// as ACME dns-01 challenge solvers.
// See https://github.com/libdns/libdns
type DNS01Solver struct {
	// The implementation that interacts with the DNS
	// provider to set or delete records. (REQUIRED)
	DNSProvider ACMEDNSProvider

	// The TTL for the temporary challenge records.
	TTL time.Duration

	// How long to wait before starting propagation checks.
	// Default: 0 (no wait).
	PropagationDelay time.Duration

	// Maximum time to wait for temporary DNS record to appear.
	// Set to -1 to disable propagation checks.
	// Default: 2 minutes.
	PropagationTimeout time.Duration

	// PollingInterval.
	// Default: 2 seconds.
	PollingInterval time.Duration

	// Preferred DNS resolver(s) to use when doing DNS lookups.
	Resolvers []string

	// Override the domain to set the TXT record on. This is
	// to delegate the challenge to a different domain. Note
	// that the solver doesn't follow CNAME/NS record.
	OverrideDomain string

	txtRecords   map[string]dnsPresentMemory // keyed by domain name
	txtRecordsMu 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()

	// multiple identifiers can have the same ACME challenge
	// domain (e.g. example.com and *.example.com) so we need
	// to ensure that we don't solve those concurrently and
	// step on each challenges' metaphorical toes; see
	// https://github.com/caddyserver/caddy/issues/3474
	activeDNSChallenges.Lock(dnsName)

	zone, err := findZoneByFQDN(dnsName, recursiveNameservers(s.Resolvers))
	if err != nil {
		return fmt.Errorf("could not determine zone for domain %q: %v", dnsName, err)
	}

	rec := libdns.Record{
		Type:  "TXT",
		Name:  libdns.RelativeName(dnsName+".", zone),
		Value: keyAuth,
		TTL:   s.TTL,
	}

	results, err := s.DNSProvider.AppendRecords(ctx, zone, []libdns.Record{rec})
	if err != nil {
		return fmt.Errorf("adding temporary record for zone %s: %w", zone, err)
	}
	if len(results) != 1 {
		return 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.txtRecordsMu.Lock()
	if s.txtRecords == nil {
		s.txtRecords = make(map[string]dnsPresentMemory)
	}
	s.txtRecords[dnsName] = dnsPresentMemory{dnsZone: zone, rec: results[0]}
	s.txtRecordsMu.Unlock()

	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 {
	// 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 {
		select {
		case <-time.After(s.PropagationDelay):
		case <-ctx.Done():
			return ctx.Err()
		}
	}

	// skip propagation checks if configured to do so
	if s.PropagationTimeout == -1 {
		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 {
		timeout = 2 * time.Minute
	}

	interval := s.PollingInterval
	if interval == 0 {
		interval = 2 * time.Second
	}

	// how we'll do the checks
	resolvers := recursiveNameservers(s.Resolvers)

	var err error
	start := time.Now()
	for time.Since(start) < timeout {
		select {
		case <-time.After(interval):
		case <-ctx.Done():
			return ctx.Err()
		}
		var ready bool
		ready, err = checkDNSPropagation(dnsName, keyAuth, resolvers)
		if err != nil {
			return fmt.Errorf("checking DNS propagation of %s: %w", dnsName, err)
		}
		if ready {
			return nil
		}
	}

	return fmt.Errorf("timed out waiting for record to fully propagate")
}

// CleanUp deletes the DNS TXT record created in Present().
func (s *DNS01Solver) CleanUp(ctx context.Context, challenge acme.Challenge) error {
	dnsName := challenge.DNS01TXTRecordName()

	defer func() {
		// always forget about it so we don't leak memory
		s.txtRecordsMu.Lock()
		delete(s.txtRecords, dnsName)
		s.txtRecordsMu.Unlock()

		// always do this last - but always do it!
		activeDNSChallenges.Unlock(dnsName)
	}()

	// recall the record we created and zone we looked up
	s.txtRecordsMu.Lock()
	memory, ok := s.txtRecords[dnsName]
	if !ok {
		s.txtRecordsMu.Unlock()
		return fmt.Errorf("no memory of presenting a DNS record for %s (probably OK if presenting failed)", challenge.Identifier.Value)
	}
	s.txtRecordsMu.Unlock()

	// clean up the record
	_, err := s.DNSProvider.DeleteRecords(ctx, memory.dnsZone, []libdns.Record{memory.rec})
	if err != nil {
		return fmt.Errorf("deleting temporary record for zone %s: %w", memory.dnsZone, err)
	}

	return nil
}

type dnsPresentMemory struct {
	dnsZone string
	rec     libdns.Record
}

// 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 {
	libdns.RecordAppender
	libdns.RecordDeleter
}

// activeDNSChallenges synchronizes DNS challenges for
// names to ensure that challenges for the same ACME
// DNS name do not overlap; for example, the TXT record
// to make for both example.com and *.example.com are
// the same; thus we cannot solve them concurrently.
var activeDNSChallenges = newMapMutex()

// mapMutex implements named mutexes.
type mapMutex struct {
	cond *sync.Cond
	set  map[interface{}]struct{}
}

func newMapMutex() *mapMutex {
	return &mapMutex{
		cond: sync.NewCond(new(sync.Mutex)),
		set:  make(map[interface{}]struct{}),
	}
}

func (mmu *mapMutex) Lock(key interface{}) {
	mmu.cond.L.Lock()
	defer mmu.cond.L.Unlock()
	for mmu.locked(key) {
		mmu.cond.Wait()
	}
	mmu.set[key] = struct{}{}
}

func (mmu *mapMutex) Unlock(key interface{}) {
	mmu.cond.L.Lock()
	defer mmu.cond.L.Unlock()
	delete(mmu.set, key)
	mmu.cond.Broadcast()
}

func (mmu *mapMutex) locked(key interface{}) (ok bool) {
	_, ok = mmu.set[key]
	return
}

r6c avatar Jul 27 '22 10:07 r6c

Thanks for the additional information!

I see you're using ZeroSSL. They are working a known issue that involves slow response times.

If you try the []string{"*.example.com","example.com"} setup (the original one) the same way but with Let's Encrypt, does it work okay?

I'm trying to determine if it's a bug in acmez or in ZeroSSL.

mholt avatar Jul 27 '22 20:07 mholt

Change Directory to https://acme-v02.api.letsencrypt.org/directory, still pending

ObtainCertificate...
{"level":"info","ts":1658977033.7748215,"caller":"[email protected]/client.go:394","msg":"trying to solve challenge","identifier":"*.example.com","challenge_type":"dns-01","ca":"https://acme-v02.api.letsencrypt.org/directory"}
{"level":"info","ts":1658977035.9860744,"caller":"[email protected]/client.go:394","msg":"trying to solve challenge","identifier":"example.com","challenge_type":"dns-01","ca":"https://acme-v02.api.letsencrypt.org/directory"}

btw, I use acme.sh to obtain certificate from ZeroSSL for *.example.com,example.com, works fine. So, maybe isn't ZeroSSL bug.

r6c avatar Jul 28 '22 03:07 r6c

Thanks, good to know. Will look into this.

mholt avatar Jul 28 '22 06:07 mholt

I've been able to reproduce this behavior, now looking into a fix.

mholt avatar Aug 02 '22 16:08 mholt

What is your acme_issuer.DNS01Solver? And what is your DNS provider? Would like to see that code.

mholt avatar Aug 02 '22 17:08 mholt

I have confirmed this is not a bug in acmez but rather the interop with whatever the acme_issuer.DNS01Solver is.

mholt avatar Aug 02 '22 18:08 mholt

This bug only occurs if using an implementation of a DNS solver like what CertMagic did, where it mutexes challenges for each ACME DNS name, to avoid collisions with *.example.com and example.com, which use the same-named TXT record to solve the DNS challenge.

It was done because legacy code required it, but we no longer use those libraries (lego and its solvers) so I removed the mutexing. This removes the deadlock you're experiencing (assuming you're using CertMagic; I don't know what acme_issuer is though since you didn't share full code) and makes it faster and more efficient as well.

Fix is downstream here: https://github.com/caddyserver/certmagic/commit/dce2de273db7226cadfae948c6d70ddf633553e7

mholt avatar Aug 02 '22 21:08 mholt