Skip to content

Commit a49eb43

Browse files
committed
fix(ssl): detect and rebuild stale ACME orders on verify/retry
1 parent 2a774eb commit a49eb43

1 file changed

Lines changed: 79 additions & 0 deletions

File tree

src/helper/Site_Letsencrypt.php

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,32 @@ public function check( Array $domains, $wildcard = false, $preferred_challenge =
360360
\EE::debug( sprintf( 'Loading the authorization token for domains %s ...', implode( ', ', $domains ) ) );
361361
}
362362

363+
// Self-heal stale orders: LE expires/deactivates a pending order/authorization (~7 days), after which the
364+
// stored order is dead and validation fails forever. Only init_le() calls authorize(), so on a retry via
365+
// ssl-verify we must rebuild the order here. Pending/valid authorizations are still live, so the common
366+
// "DNS not ready yet, retry later" case is left untouched and never triggers a rebuild.
367+
if ( $order && $this->isCertificateOrderStale( $order, $domains ) ) {
368+
\EE::debug( 'Stored ACME order is stale/expired; requesting a fresh order.' );
369+
$this->repository->removeCertificateOrder( $domains );
370+
$this->revokeAuthorizationChallenges( $domains ); // best-effort: clears stale challenge files.
371+
if ( ! $this->authorize( $domains, $wildcard, $preferred_challenge ) ) {
372+
return false;
373+
}
374+
375+
// Manual DNS-01 rebuild issues a brand-new TXT token that authorize() only printed above; the old record
376+
// is now wrong, so validating immediately would fail confusingly. Stop and let the user publish it first.
377+
// (HTTP-01 wrote the token file + reloaded nginx, and Cloudflare DNS publishes automatically — both fall through.)
378+
if ( $is_solver_dns && empty( get_config_value( 'cloudflare-api-key' ) ) ) {
379+
$primary_domain = str_replace( '*.', '', $domains[0] );
380+
\EE::warning( "The previous ACME order for $primary_domain had expired. A fresh DNS-01 challenge was issued and its new TXT record is printed above." );
381+
\EE::log( "Publish the new TXT record, then re-run: ee site ssl-verify $primary_domain" );
382+
383+
return false;
384+
}
385+
386+
$order = $this->repository->loadCertificateOrder( $domains );
387+
}
388+
363389
$authorizationChallengeToCleanup = [];
364390
foreach ( $domains as $domain ) {
365391
if ( $order ) {
@@ -431,6 +457,59 @@ public function check( Array $domains, $wildcard = false, $preferred_challenge =
431457
return true;
432458
}
433459

460+
/**
461+
* Determine whether a stored ACME order can no longer be used to validate the given domains.
462+
*
463+
* An order is stale when LE has expired/deactivated/revoked/invalidated its authorizations (which it does for
464+
* orders left pending for ~7 days) or when it lacks a challenge for a requested domain (e.g. the SAN set changed).
465+
* `pending` and `valid` authorizations are still live and are NOT stale, so an in-progress retry is preserved.
466+
*
467+
* @param CertificateOrder $order The loaded order to inspect.
468+
* @param array $domains Requested domains for this order.
469+
*
470+
* @return bool True if the order should be discarded and rebuilt.
471+
*/
472+
private function isCertificateOrderStale( $order, array $domains ) {
473+
foreach ( $domains as $domain ) {
474+
try {
475+
// Throws if the order has no challenge for this requested domain (e.g. SAN set changed).
476+
$authorizationChallenges = $order->getAuthorizationChallenges( $domain );
477+
} catch ( \Exception $e ) {
478+
\EE::debug( sprintf( 'No authorization challenge in stored order for %s: %s', $domain, $e->getMessage() ) );
479+
480+
return true;
481+
}
482+
483+
// All challenges of one authorization share its status, so reloading the first is enough; the break below
484+
// avoids redundant ACME round-trips (and a wider transient-error window) for the remaining challenges.
485+
foreach ( $authorizationChallenges as $challenge ) {
486+
try {
487+
// reloadAuthorization refetches live status from LE.
488+
$challenge = $this->client->reloadAuthorization( $challenge );
489+
} catch ( \Throwable $e ) {
490+
// Treat a failed reload as inconclusive, NOT stale: it also throws on transient LE errors (5xx,
491+
// 429, timeouts), and tearing down a healthy in-flight order on a blip would hit the rate-limited
492+
// newOrder endpoint. Trade-off: a fully-purged authz (404) is not auto-rebuilt; the common expiry
493+
// case reloads successfully with an `expired` status and is handled below.
494+
\EE::debug( sprintf( 'Reloading authorization for %s failed (treating as inconclusive, keeping order): %s', $domain, $e->getMessage() ) );
495+
496+
return false;
497+
}
498+
499+
// pending/valid are live; anything else (expired/deactivated/revoked/invalid) is unusable.
500+
if ( ! in_array( $challenge->getStatus(), [ 'pending', 'valid' ], true ) ) {
501+
\EE::debug( sprintf( 'Authorization for %s has stale status "%s".', $domain, $challenge->getStatus() ) );
502+
503+
return true;
504+
}
505+
506+
break;
507+
}
508+
}
509+
510+
return false;
511+
}
512+
434513
public function request( $domain, $altNames = [], $email, $force = false ) {
435514
$alternativeNames = array_unique( $altNames );
436515
sort( $alternativeNames );

0 commit comments

Comments
 (0)