Skip to content

Commit 55ccb9f

Browse files
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 <[email protected]> Co-authored-by: Claude Opus 4.5 <[email protected]>
1 parent a5816f1 commit 55ccb9f

File tree

1 file changed

+62
-15
lines changed

1 file changed

+62
-15
lines changed

agent/utils/ssl/manual_client.go

Lines changed: 62 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,39 @@ type Resolve struct {
7171
}
7272

7373
func (c *ManualClient) GetDNSResolve(ctx context.Context, websiteSSL *model.WebsiteSSL) (map[string]Resolve, error) {
74-
order, err := c.client.AuthorizeOrder(ctx, acme.DomainIDs(getWebsiteSSLDomains(websiteSSL)...))
74+
var order *acme.Order
75+
var err error
76+
77+
// Check if we have an existing valid order for this SSL
78+
existingOrder, exists := Orders[websiteSSL.ID]
79+
if exists && existingOrder != nil {
80+
// Verify the order is still valid (not expired and still pending)
81+
// If Expires is zero, order is still valid (ACME doesn't always set expiry immediately)
82+
isNotExpired := existingOrder.Expires.IsZero() || existingOrder.Expires.After(time.Now())
83+
if isNotExpired && (existingOrder.Status == acme.StatusPending || existingOrder.Status == acme.StatusReady) {
84+
// Try to reuse the existing order
85+
records, err := c.extractDNSChallenges(ctx, existingOrder)
86+
if err == nil && len(records) > 0 {
87+
return records, nil
88+
}
89+
// If extraction failed, fall through to create a new order
90+
}
91+
// Existing order is expired or invalid, remove it
92+
delete(Orders, websiteSSL.ID)
93+
}
94+
95+
// Create a new order
96+
order, err = c.client.AuthorizeOrder(ctx, acme.DomainIDs(getWebsiteSSLDomains(websiteSSL)...))
7597
if err != nil {
7698
return nil, err
7799
}
78100
Orders[websiteSSL.ID] = order
79101

102+
return c.extractDNSChallenges(ctx, order)
103+
}
104+
105+
// extractDNSChallenges extracts DNS-01 challenge values from an ACME order
106+
func (c *ManualClient) extractDNSChallenges(ctx context.Context, order *acme.Order) (map[string]Resolve, error) {
80107
records := make(map[string]Resolve)
81108

82109
for _, authzURL := range order.AuthzURLs {
@@ -103,23 +130,30 @@ func (c *ManualClient) GetDNSResolve(ctx context.Context, websiteSSL *model.Webs
103130
return nil, err
104131
}
105132

106-
records[domain] = Resolve{
133+
// Use different map key for wildcard vs non-wildcard to avoid overwriting
134+
// Both use the same DNS record name (_acme-challenge.domain) but different values
135+
mapKey := domain
136+
if authz.Wildcard {
137+
mapKey = "*." + domain
138+
}
139+
140+
records[mapKey] = Resolve{
107141
Key: fmt.Sprintf("_acme-challenge.%s", domain),
108142
Value: txtValue,
109143
}
110144
}
111145
return records, nil
112146
}
113147

114-
func queryDNSRecords(domain string) (map[string]string, error) {
148+
func queryDNSRecords(domain string) (map[string][]string, error) {
115149
recordName := fmt.Sprintf("_acme-challenge.%s", domain)
116150
txts, err := net.LookupTXT(recordName)
117151
if err != nil {
118152
return nil, err
119153
}
120-
records := make(map[string]string)
154+
records := make(map[string][]string)
121155
if len(txts) > 0 {
122-
records[recordName] = txts[0]
156+
records[recordName] = txts
123157
}
124158
return records, nil
125159
}
@@ -159,7 +193,7 @@ func (c *ManualClient) handleAuthorization(ctx context.Context, authzURL string,
159193

160194
for {
161195
c.logger.Printf("[INFO] [%s] acme: Checking DNS record propagation.", domain)
162-
var currentRecords map[string]string
196+
var currentRecords map[string][]string
163197
var queryErr error
164198
if len(nameservers) == 0 {
165199
currentRecords, queryErr = queryDNSRecords(domain)
@@ -177,16 +211,26 @@ func (c *ManualClient) handleAuthorization(ctx context.Context, authzURL string,
177211
return fmt.Errorf("failed to query DNS records: %v", queryErr)
178212
}
179213
recordName := fmt.Sprintf("_acme-challenge.%s", domain)
180-
providedRecord, exists := currentRecords[recordName]
181-
if exists && providedRecord == expectedRecord {
214+
providedRecords, exists := currentRecords[recordName]
215+
// Check if expected record is in any of the TXT values
216+
found := false
217+
if exists {
218+
for _, record := range providedRecords {
219+
if record == expectedRecord {
220+
found = true
221+
break
222+
}
223+
}
224+
}
225+
if found {
182226
break
183227
}
184228
if time.Now().After(deadline) {
185-
if !exists {
229+
if !exists || len(providedRecords) == 0 {
186230
return fmt.Errorf("TXT record not provided for domain %s after retrying", domain)
187231
}
188-
c.logger.Printf("[INFO] [%s] TXT record mismatch for %s: expected %s, got %s\"", domain, domain, expectedRecord, providedRecord)
189-
return fmt.Errorf("TXT record mismatch for %s: expected %s, got %s", domain, expectedRecord, providedRecord)
232+
c.logger.Printf("[INFO] [%s] TXT record mismatch for %s: expected %s, got %v", domain, domain, expectedRecord, providedRecords)
233+
return fmt.Errorf("TXT record mismatch for %s: expected %s, got %v", domain, expectedRecord, providedRecords)
190234
}
191235
time.Sleep(pollingInterval)
192236
}
@@ -339,7 +383,7 @@ func handleNameserver(nameserver string) string {
339383
return fmt.Sprintf("%s:53", nameserver)
340384
}
341385

342-
func queryDNSRecordsWithResolver(ctx context.Context, logger *log.Logger, domain string, dnsServer string) (map[string]string, error) {
386+
func queryDNSRecordsWithResolver(ctx context.Context, logger *log.Logger, domain string, dnsServer string) (map[string][]string, error) {
343387
recordName := fmt.Sprintf("_acme-challenge.%s", domain)
344388
c := new(dns.Client)
345389
c.Timeout = 10 * time.Second
@@ -367,16 +411,19 @@ func queryDNSRecordsWithResolver(ctx context.Context, logger *log.Logger, domain
367411
return nil, fmt.Errorf("DNS query failed with code: %s", dns.RcodeToString[r.Rcode])
368412
}
369413

370-
records := make(map[string]string)
414+
records := make(map[string][]string)
415+
var txtValues []string
371416

372417
for _, answer := range r.Answer {
373418
if txt, ok := answer.(*dns.TXT); ok {
374419
if len(txt.Txt) > 0 {
375-
records[recordName] = txt.Txt[0]
376-
break
420+
txtValues = append(txtValues, txt.Txt[0])
377421
}
378422
}
379423
}
424+
if len(txtValues) > 0 {
425+
records[recordName] = txtValues
426+
}
380427

381428
return records, nil
382429
}

0 commit comments

Comments
 (0)