mholt / acmez

Premier ACME client library for Go
https://pkg.go.dev/github.com/mholt/acmez/v2
Apache License 2.0
272 stars 32 forks source link

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

Closed r6c closed 2 years ago

r6c commented 2 years ago
        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 commented 2 years ago

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 commented 2 years ago
{"level":"info","ts":1658569561.968958,"caller":"acmez@v1.0.3/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":"acmez@v1.0.3/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.

mholt commented 2 years ago

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.

r6c commented 2 years ago
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:you@example.com"},
        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":"acmez@v1.0.3/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":"acmez@v1.0.3/client.go:394","msg":"trying to solve challenge","identifier":"*.example.com","challenge_type":"dns-01","ca":"https://acme.zerossl.com/v2/DV90"}
r6c commented 2 years ago

Snipaste_2022-07-27_17-27-49

r6c commented 2 years ago

no more any log.

r6c commented 2 years ago

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

Log:

ObtainCertificate...
{"level":"info","ts":1658915211.612598,"caller":"acmez@v1.0.3/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":"acmez@v1.0.3/client.go:164","msg":"validations succeeded; finalizing order","order":"https://acme.zerossl.com/v2/DV90/order/AYzAMUuxKdymhNRtrvTYw"}
{"level":"info","ts":1658915317.5602314,"caller":"acmez@v1.0.3/client.go:184","msg":"successfully downloaded available certificate chains","count":1,"first_url":"https://acme.zerossl.com/v2/DV90/cert/qtPr6rDKyKTLlkQUPTgsA"}
r6c commented 2 years ago

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

Log

ObtainCertificate...
{"level":"info","ts":1658915614.185599,"caller":"acmez@v1.0.3/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":"acmez@v1.0.3/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":"acmez@v1.0.3/client.go:164","msg":"validations succeeded; finalizing order","order":"https://acme.zerossl.com/v2/DV90/order/2q60LQpdNjoHlef5ZfGvA"}
{"level":"info","ts":1658915760.238464,"caller":"acmez@v1.0.3/client.go:184","msg":"successfully downloaded available certificate chains","count":1,"first_url":"https://acme.zerossl.com/v2/DV90/cert/qoX3XjHX9dqYymkTI7q9Q"}
r6c commented 2 years ago

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
}
mholt commented 2 years ago

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.

r6c commented 2 years ago

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

ObtainCertificate...
{"level":"info","ts":1658977033.7748215,"caller":"acmez@v1.0.3/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":"acmez@v1.0.3/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.

mholt commented 2 years ago

Thanks, good to know. Will look into this.

mholt commented 2 years ago

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

mholt commented 2 years ago

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

mholt commented 2 years ago

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

mholt commented 2 years ago

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