fix: DNS Manual SSL certificate issues for wildcard domains (#11642)

This fix addresses three bugs in the DNS Manual SSL certificate flow:

1. **Order caching fails when Expires is zero**: ACME orders often have
   zero Expires initially. The condition `!Expires.IsZero()` caused valid
   cached orders to be deleted and recreated with different TXT values.
   Fixed by checking `Expires.IsZero() || Expires.After(now)`.

2. **Wildcard and base domain TXT records overwrite each other**: When
   requesting SSL for both `example.com` and `*.example.com`, both
   authorizations have identifier `example.com`, causing one TXT value
   to overwrite the other. Fixed by using `*.domain` as the map key.

3. **Only first TXT record checked**: When multiple TXT records exist,
   only the first was checked. Fixed by returning all TXT values and
   checking if expected value exists in any of them.

```release-note
Fix DNS Manual SSL certificate issues for wildcard domains
```

Co-authored-by: DeployThemAll <deploythemall@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
DeployThemAll
2026-01-15 10:42:59 +08:00
committed by GitHub
co-authored by DeployThemAll Claude Opus 4.5
parent a5816f1dc9
commit 55ccb9fe61
+62 -15
View File
@@ -71,12 +71,39 @@ type Resolve struct {
}
func (c *ManualClient) GetDNSResolve(ctx context.Context, websiteSSL *model.WebsiteSSL) (map[string]Resolve, error) {
order, err := c.client.AuthorizeOrder(ctx, acme.DomainIDs(getWebsiteSSLDomains(websiteSSL)...))
var order *acme.Order
var err error
// Check if we have an existing valid order for this SSL
existingOrder, exists := Orders[websiteSSL.ID]
if exists && existingOrder != nil {
// Verify the order is still valid (not expired and still pending)
// If Expires is zero, order is still valid (ACME doesn't always set expiry immediately)
isNotExpired := existingOrder.Expires.IsZero() || existingOrder.Expires.After(time.Now())
if isNotExpired && (existingOrder.Status == acme.StatusPending || existingOrder.Status == acme.StatusReady) {
// Try to reuse the existing order
records, err := c.extractDNSChallenges(ctx, existingOrder)
if err == nil && len(records) > 0 {
return records, nil
}
// If extraction failed, fall through to create a new order
}
// Existing order is expired or invalid, remove it
delete(Orders, websiteSSL.ID)
}
// Create a new order
order, err = c.client.AuthorizeOrder(ctx, acme.DomainIDs(getWebsiteSSLDomains(websiteSSL)...))
if err != nil {
return nil, err
}
Orders[websiteSSL.ID] = order
return c.extractDNSChallenges(ctx, order)
}
// extractDNSChallenges extracts DNS-01 challenge values from an ACME order
func (c *ManualClient) extractDNSChallenges(ctx context.Context, order *acme.Order) (map[string]Resolve, error) {
records := make(map[string]Resolve)
for _, authzURL := range order.AuthzURLs {
@@ -103,7 +130,14 @@ func (c *ManualClient) GetDNSResolve(ctx context.Context, websiteSSL *model.Webs
return nil, err
}
records[domain] = Resolve{
// Use different map key for wildcard vs non-wildcard to avoid overwriting
// Both use the same DNS record name (_acme-challenge.domain) but different values
mapKey := domain
if authz.Wildcard {
mapKey = "*." + domain
}
records[mapKey] = Resolve{
Key: fmt.Sprintf("_acme-challenge.%s", domain),
Value: txtValue,
}
@@ -111,15 +145,15 @@ func (c *ManualClient) GetDNSResolve(ctx context.Context, websiteSSL *model.Webs
return records, nil
}
func queryDNSRecords(domain string) (map[string]string, error) {
func queryDNSRecords(domain string) (map[string][]string, error) {
recordName := fmt.Sprintf("_acme-challenge.%s", domain)
txts, err := net.LookupTXT(recordName)
if err != nil {
return nil, err
}
records := make(map[string]string)
records := make(map[string][]string)
if len(txts) > 0 {
records[recordName] = txts[0]
records[recordName] = txts
}
return records, nil
}
@@ -159,7 +193,7 @@ func (c *ManualClient) handleAuthorization(ctx context.Context, authzURL string,
for {
c.logger.Printf("[INFO] [%s] acme: Checking DNS record propagation.", domain)
var currentRecords map[string]string
var currentRecords map[string][]string
var queryErr error
if len(nameservers) == 0 {
currentRecords, queryErr = queryDNSRecords(domain)
@@ -177,16 +211,26 @@ func (c *ManualClient) handleAuthorization(ctx context.Context, authzURL string,
return fmt.Errorf("failed to query DNS records: %v", queryErr)
}
recordName := fmt.Sprintf("_acme-challenge.%s", domain)
providedRecord, exists := currentRecords[recordName]
if exists && providedRecord == expectedRecord {
providedRecords, exists := currentRecords[recordName]
// Check if expected record is in any of the TXT values
found := false
if exists {
for _, record := range providedRecords {
if record == expectedRecord {
found = true
break
}
}
}
if found {
break
}
if time.Now().After(deadline) {
if !exists {
if !exists || len(providedRecords) == 0 {
return fmt.Errorf("TXT record not provided for domain %s after retrying", domain)
}
c.logger.Printf("[INFO] [%s] TXT record mismatch for %s: expected %s, got %s\"", domain, domain, expectedRecord, providedRecord)
return fmt.Errorf("TXT record mismatch for %s: expected %s, got %s", domain, expectedRecord, providedRecord)
c.logger.Printf("[INFO] [%s] TXT record mismatch for %s: expected %s, got %v", domain, domain, expectedRecord, providedRecords)
return fmt.Errorf("TXT record mismatch for %s: expected %s, got %v", domain, expectedRecord, providedRecords)
}
time.Sleep(pollingInterval)
}
@@ -339,7 +383,7 @@ func handleNameserver(nameserver string) string {
return fmt.Sprintf("%s:53", nameserver)
}
func queryDNSRecordsWithResolver(ctx context.Context, logger *log.Logger, domain string, dnsServer string) (map[string]string, error) {
func queryDNSRecordsWithResolver(ctx context.Context, logger *log.Logger, domain string, dnsServer string) (map[string][]string, error) {
recordName := fmt.Sprintf("_acme-challenge.%s", domain)
c := new(dns.Client)
c.Timeout = 10 * time.Second
@@ -367,16 +411,19 @@ func queryDNSRecordsWithResolver(ctx context.Context, logger *log.Logger, domain
return nil, fmt.Errorf("DNS query failed with code: %s", dns.RcodeToString[r.Rcode])
}
records := make(map[string]string)
records := make(map[string][]string)
var txtValues []string
for _, answer := range r.Answer {
if txt, ok := answer.(*dns.TXT); ok {
if len(txt.Txt) > 0 {
records[recordName] = txt.Txt[0]
break
txtValues = append(txtValues, txt.Txt[0])
}
}
}
if len(txtValues) > 0 {
records[recordName] = txtValues
}
return records, nil
}