@@ -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