From a49eb43fe0583b27358c84bf760173f0ec696abf Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 30 Jun 2026 16:35:38 +0530 Subject: [PATCH 1/4] fix(ssl): detect and rebuild stale ACME orders on verify/retry --- src/helper/Site_Letsencrypt.php | 79 +++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/src/helper/Site_Letsencrypt.php b/src/helper/Site_Letsencrypt.php index 0669eaf1..968546bd 100644 --- a/src/helper/Site_Letsencrypt.php +++ b/src/helper/Site_Letsencrypt.php @@ -360,6 +360,32 @@ public function check( Array $domains, $wildcard = false, $preferred_challenge = \EE::debug( sprintf( 'Loading the authorization token for domains %s ...', implode( ', ', $domains ) ) ); } + // Self-heal stale orders: LE expires/deactivates a pending order/authorization (~7 days), after which the + // stored order is dead and validation fails forever. Only init_le() calls authorize(), so on a retry via + // ssl-verify we must rebuild the order here. Pending/valid authorizations are still live, so the common + // "DNS not ready yet, retry later" case is left untouched and never triggers a rebuild. + if ( $order && $this->isCertificateOrderStale( $order, $domains ) ) { + \EE::debug( 'Stored ACME order is stale/expired; requesting a fresh order.' ); + $this->repository->removeCertificateOrder( $domains ); + $this->revokeAuthorizationChallenges( $domains ); // best-effort: clears stale challenge files. + if ( ! $this->authorize( $domains, $wildcard, $preferred_challenge ) ) { + return false; + } + + // Manual DNS-01 rebuild issues a brand-new TXT token that authorize() only printed above; the old record + // is now wrong, so validating immediately would fail confusingly. Stop and let the user publish it first. + // (HTTP-01 wrote the token file + reloaded nginx, and Cloudflare DNS publishes automatically — both fall through.) + if ( $is_solver_dns && empty( get_config_value( 'cloudflare-api-key' ) ) ) { + $primary_domain = str_replace( '*.', '', $domains[0] ); + \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." ); + \EE::log( "Publish the new TXT record, then re-run: ee site ssl-verify $primary_domain" ); + + return false; + } + + $order = $this->repository->loadCertificateOrder( $domains ); + } + $authorizationChallengeToCleanup = []; foreach ( $domains as $domain ) { if ( $order ) { @@ -431,6 +457,59 @@ public function check( Array $domains, $wildcard = false, $preferred_challenge = return true; } + /** + * Determine whether a stored ACME order can no longer be used to validate the given domains. + * + * An order is stale when LE has expired/deactivated/revoked/invalidated its authorizations (which it does for + * orders left pending for ~7 days) or when it lacks a challenge for a requested domain (e.g. the SAN set changed). + * `pending` and `valid` authorizations are still live and are NOT stale, so an in-progress retry is preserved. + * + * @param CertificateOrder $order The loaded order to inspect. + * @param array $domains Requested domains for this order. + * + * @return bool True if the order should be discarded and rebuilt. + */ + private function isCertificateOrderStale( $order, array $domains ) { + foreach ( $domains as $domain ) { + try { + // Throws if the order has no challenge for this requested domain (e.g. SAN set changed). + $authorizationChallenges = $order->getAuthorizationChallenges( $domain ); + } catch ( \Exception $e ) { + \EE::debug( sprintf( 'No authorization challenge in stored order for %s: %s', $domain, $e->getMessage() ) ); + + return true; + } + + // All challenges of one authorization share its status, so reloading the first is enough; the break below + // avoids redundant ACME round-trips (and a wider transient-error window) for the remaining challenges. + foreach ( $authorizationChallenges as $challenge ) { + try { + // reloadAuthorization refetches live status from LE. + $challenge = $this->client->reloadAuthorization( $challenge ); + } catch ( \Throwable $e ) { + // Treat a failed reload as inconclusive, NOT stale: it also throws on transient LE errors (5xx, + // 429, timeouts), and tearing down a healthy in-flight order on a blip would hit the rate-limited + // newOrder endpoint. Trade-off: a fully-purged authz (404) is not auto-rebuilt; the common expiry + // case reloads successfully with an `expired` status and is handled below. + \EE::debug( sprintf( 'Reloading authorization for %s failed (treating as inconclusive, keeping order): %s', $domain, $e->getMessage() ) ); + + return false; + } + + // pending/valid are live; anything else (expired/deactivated/revoked/invalid) is unusable. + if ( ! in_array( $challenge->getStatus(), [ 'pending', 'valid' ], true ) ) { + \EE::debug( sprintf( 'Authorization for %s has stale status "%s".', $domain, $challenge->getStatus() ) ); + + return true; + } + + break; + } + } + + return false; + } + public function request( $domain, $altNames = [], $email, $force = false ) { $alternativeNames = array_unique( $altNames ); sort( $alternativeNames ); From 2e5191adb9464916f7a66b394ffeb532c0c684cd Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 10:40:57 +0000 Subject: [PATCH 2/4] fix(ssl): keep the stale order until the rebuild succeeds The stale order was deleted before authorize() ran, so a failed rebuild (rate limit, network error, rejected identifier) left no order behind: the next ssl-verify fell into the order-less path and stopped with "not yet authorized" instead of retrying. authorize() overwrites the stored order on success, so the explicit removal isn't needed. revokeAuthorizationChallenges() only catches revocation/CLI exceptions, while its newOrder request can throw ACME server or client exceptions, so the "best-effort" cleanup could abort check() before the rebuild. Catch and log those. --- src/helper/Site_Letsencrypt.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/helper/Site_Letsencrypt.php b/src/helper/Site_Letsencrypt.php index 968546bd..7406cc8e 100644 --- a/src/helper/Site_Letsencrypt.php +++ b/src/helper/Site_Letsencrypt.php @@ -366,8 +366,12 @@ public function check( Array $domains, $wildcard = false, $preferred_challenge = // "DNS not ready yet, retry later" case is left untouched and never triggers a rebuild. if ( $order && $this->isCertificateOrderStale( $order, $domains ) ) { \EE::debug( 'Stored ACME order is stale/expired; requesting a fresh order.' ); - $this->repository->removeCertificateOrder( $domains ); - $this->revokeAuthorizationChallenges( $domains ); // best-effort: clears stale challenge files. + try { + $this->revokeAuthorizationChallenges( $domains ); + } catch ( \Exception $e ) { + \EE::debug( 'Revoking stale authorization challenges failed: ' . $e->getMessage() ); + } + // The stale order is kept until authorize() overwrites it, so a failed rebuild is retried on the next run. if ( ! $this->authorize( $domains, $wildcard, $preferred_challenge ) ) { return false; } From 8d92afd5a3980f61c98a30317b4c3e5997e39380 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 10:40:57 +0000 Subject: [PATCH 3/4] fix(ssl): treat expired authorizations as stale reloadAuthorization() fetches the challenge URL, and Let's Encrypt answers 404 ("Expired authorization") for a challenge once its authorization has expired; it never returns an "expired" or "deactivated" status there (challenge statuses are pending, processing, valid and invalid). The expiry case the stale-order check was written for was therefore treated as inconclusive, and check() then died on the same 404 in its own reload. Treat a 404 as stale, keep other errors inconclusive, and count "processing" as live. --- src/helper/Site_Letsencrypt.php | 35 +++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/src/helper/Site_Letsencrypt.php b/src/helper/Site_Letsencrypt.php index 7406cc8e..b2070a65 100644 --- a/src/helper/Site_Letsencrypt.php +++ b/src/helper/Site_Letsencrypt.php @@ -14,6 +14,7 @@ use AcmePhp\Core\Challenge\Http\HttpValidator; use AcmePhp\Core\Challenge\Http\SimpleHttpSolver; use AcmePhp\Core\Challenge\WaitingValidator; +use AcmePhp\Core\Exception\AcmeCoreServerException; use AcmePhp\Core\Exception\Protocol\ChallengeNotSupportedException; use AcmePhp\Core\Exception\Protocol\CertificateRevocationException; use AcmePhp\Core\Protocol\AuthorizationChallenge; @@ -360,10 +361,9 @@ public function check( Array $domains, $wildcard = false, $preferred_challenge = \EE::debug( sprintf( 'Loading the authorization token for domains %s ...', implode( ', ', $domains ) ) ); } - // Self-heal stale orders: LE expires/deactivates a pending order/authorization (~7 days), after which the - // stored order is dead and validation fails forever. Only init_le() calls authorize(), so on a retry via - // ssl-verify we must rebuild the order here. Pending/valid authorizations are still live, so the common - // "DNS not ready yet, retry later" case is left untouched and never triggers a rebuild. + // Self-heal stale orders: once LE invalidates or expires (~7 days) an authorization, the stored order can never + // validate, and only init_le() calls authorize(), so a retry via ssl-verify must rebuild the order here. + // A live (pending) order is left untouched, so the "DNS not ready yet, retry later" case is unchanged. if ( $order && $this->isCertificateOrderStale( $order, $domains ) ) { \EE::debug( 'Stored ACME order is stale/expired; requesting a fresh order.' ); try { @@ -381,7 +381,7 @@ public function check( Array $domains, $wildcard = false, $preferred_challenge = // (HTTP-01 wrote the token file + reloaded nginx, and Cloudflare DNS publishes automatically — both fall through.) if ( $is_solver_dns && empty( get_config_value( 'cloudflare-api-key' ) ) ) { $primary_domain = str_replace( '*.', '', $domains[0] ); - \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." ); + \EE::warning( "The previous ACME order for $primary_domain had expired or failed. A fresh DNS-01 challenge was issued and its new TXT record is printed above." ); \EE::log( "Publish the new TXT record, then re-run: ee site ssl-verify $primary_domain" ); return false; @@ -464,9 +464,9 @@ public function check( Array $domains, $wildcard = false, $preferred_challenge = /** * Determine whether a stored ACME order can no longer be used to validate the given domains. * - * An order is stale when LE has expired/deactivated/revoked/invalidated its authorizations (which it does for - * orders left pending for ~7 days) or when it lacks a challenge for a requested domain (e.g. the SAN set changed). - * `pending` and `valid` authorizations are still live and are NOT stale, so an in-progress retry is preserved. + * An order is stale when LE reports a challenge as `invalid`, answers 404 for it (the authorization expired, which + * LE does for orders left pending for ~7 days, or was purged) or when it lacks a challenge for a requested domain. + * `pending`, `processing` and `valid` challenges are still live and are NOT stale, so an in-progress retry is kept. * * @param CertificateOrder $order The loaded order to inspect. * @param array $domains Requested domains for this order. @@ -488,20 +488,25 @@ private function isCertificateOrderStale( $order, array $domains ) { // avoids redundant ACME round-trips (and a wider transient-error window) for the remaining challenges. foreach ( $authorizationChallenges as $challenge ) { try { - // reloadAuthorization refetches live status from LE. + // reloadAuthorization refetches the challenge's live status from LE. $challenge = $this->client->reloadAuthorization( $challenge ); } catch ( \Throwable $e ) { - // Treat a failed reload as inconclusive, NOT stale: it also throws on transient LE errors (5xx, - // 429, timeouts), and tearing down a healthy in-flight order on a blip would hit the rate-limited - // newOrder endpoint. Trade-off: a fully-purged authz (404) is not auto-rebuilt; the common expiry - // case reloads successfully with an `expired` status and is handled below. + // LE answers 404 ("Expired authorization") once the authorization has expired. + if ( $e instanceof AcmeCoreServerException && 404 === $e->getCode() ) { + \EE::debug( sprintf( 'Authorization for %s has expired or no longer exists: %s', $domain, $e->getMessage() ) ); + + return true; + } + + // Any other failure (5xx, 429, timeouts) is inconclusive, NOT stale: tearing down a healthy + // in-flight order on a blip would hit the rate-limited newOrder endpoint. \EE::debug( sprintf( 'Reloading authorization for %s failed (treating as inconclusive, keeping order): %s', $domain, $e->getMessage() ) ); return false; } - // pending/valid are live; anything else (expired/deactivated/revoked/invalid) is unusable. - if ( ! in_array( $challenge->getStatus(), [ 'pending', 'valid' ], true ) ) { + // A challenge is pending, processing, valid or invalid (RFC 8555 7.1.6); only invalid is unusable. + if ( ! in_array( $challenge->getStatus(), [ 'pending', 'processing', 'valid' ], true ) ) { \EE::debug( sprintf( 'Authorization for %s has stale status "%s".', $domain, $challenge->getStatus() ) ); return true; From 2e19b1c219744890ddbeb2498633d621c4219556 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 11:24:35 +0000 Subject: [PATCH 4/4] fix(ssl): check the solver's challenge when detecting stale orders isCertificateOrderStale() reloaded the first challenge of each authorization, which is often tls-alpn-01. Once any challenge has been attempted, Let's Encrypt drops the others and answers 404 "No such challenge" for them. So a valid order was treated as stale (and rebuilt on every ssl-verify) now that a 404 means stale, and an invalid one was never seen as invalid. Reload the challenge that the solver supports, the same one check() validates. --- src/helper/Site_Letsencrypt.php | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/helper/Site_Letsencrypt.php b/src/helper/Site_Letsencrypt.php index b2070a65..6d5a76a1 100644 --- a/src/helper/Site_Letsencrypt.php +++ b/src/helper/Site_Letsencrypt.php @@ -364,7 +364,7 @@ public function check( Array $domains, $wildcard = false, $preferred_challenge = // Self-heal stale orders: once LE invalidates or expires (~7 days) an authorization, the stored order can never // validate, and only init_le() calls authorize(), so a retry via ssl-verify must rebuild the order here. // A live (pending) order is left untouched, so the "DNS not ready yet, retry later" case is unchanged. - if ( $order && $this->isCertificateOrderStale( $order, $domains ) ) { + if ( $order && $this->isCertificateOrderStale( $order, $domains, $solver ) ) { \EE::debug( 'Stored ACME order is stale/expired; requesting a fresh order.' ); try { $this->revokeAuthorizationChallenges( $domains ); @@ -470,10 +470,11 @@ public function check( Array $domains, $wildcard = false, $preferred_challenge = * * @param CertificateOrder $order The loaded order to inspect. * @param array $domains Requested domains for this order. + * @param SolverInterface $solver Solver whose challenge type is checked, as in check(). * * @return bool True if the order should be discarded and rebuilt. */ - private function isCertificateOrderStale( $order, array $domains ) { + private function isCertificateOrderStale( $order, array $domains, $solver ) { foreach ( $domains as $domain ) { try { // Throws if the order has no challenge for this requested domain (e.g. SAN set changed). @@ -484,9 +485,12 @@ private function isCertificateOrderStale( $order, array $domains ) { return true; } - // All challenges of one authorization share its status, so reloading the first is enough; the break below - // avoids redundant ACME round-trips (and a wider transient-error window) for the remaining challenges. + // Check the challenge check() will use: once one challenge is attempted, LE drops the others (404). foreach ( $authorizationChallenges as $challenge ) { + if ( ! $solver->supports( $challenge ) ) { + continue; + } + try { // reloadAuthorization refetches the challenge's live status from LE. $challenge = $this->client->reloadAuthorization( $challenge );