From 29ff2636d7efcdfba60df669b6afaa5955cba6eb Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 30 Jun 2026 17:47:53 +0530 Subject: [PATCH 1/4] fix(ssl): serialize ssl operations with a process-wide file lock --- src/helper/class-ee-site.php | 55 ++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index 08ad268e..bbb75adb 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -55,6 +55,17 @@ abstract class EE_Site_Command { */ private $le_mail; + /** + * @var resource $ssl_lock_handle Open file handle for the process-wide SSL lock. + * + * Static so the lock is held once per PHP process: `ssl-renew --all` runs every + * per-site renewal in ONE process via EE::run_command, and flock denies a second + * LOCK_EX on the same file from a different fd in the same process. One shared + * handle lets the first acquire lock and every later acquire (any instance, nested + * call, or --all iteration) see it already held and return immediately. + */ + private static $ssl_lock_handle; + /** * @var array $site_data Associative array containing essential site related information. */ @@ -1522,6 +1533,42 @@ protected function init_ssl( $site_url, $site_fs_path, $ssl_type, $wildcard = fa } } + /** + * Acquire a process-wide lock serializing all SSL/ACME operations. + * + * Concurrent SSL runs (e.g. cron `ssl-renew --all` plus a manual `ee site ssl`) + * read/write the same shared ACME state (certificate_order.json, account key, + * acme-conf/var/{domain}/*), which corrupts JSON, duplicates ACME orders, and + * overwrites the account key. This guards the three ACME entry points so only one + * such operation runs per server at a time. + * + * Non-blocking (LOCK_NB): a held lock fails fast with a clear error instead of + * hanging cron. The handle is kept open for the whole operation and is never + * released or deleted here -- the advisory flock is tied to the fd and the kernel + * drops it automatically when the process exits, so it is crash-safe. + * + * @return void + */ + private function acquire_ssl_lock() { + // Already held by this process (reentrant: nested ssl_verify, or --all loop). + if ( isset( self::$ssl_lock_handle ) ) { + return; + } + + // EE_ROOT_DIR is defined at plugin load and always exists at runtime. + $lock_file = EE_ROOT_DIR . '/ssl-global.lock'; + $fh = fopen( $lock_file, 'c' ); + + if ( ! $fh || ! flock( $fh, LOCK_EX | LOCK_NB ) ) { + if ( $fh ) { + fclose( $fh ); + } + \EE::error( 'Another SSL operation is already in progress on this server. Wait for it to finish and retry.' ); + } + + self::$ssl_lock_handle = $fh; + } + /** * Runs the acme le registration and authorization. * @@ -1533,6 +1580,8 @@ protected function init_ssl( $site_url, $site_fs_path, $ssl_type, $wildcard = fa * @param array $alias_domains Array of alias domains if any. */ protected function init_le( $site_url, $site_fs_path, $wildcard = false, $www_or_non_www, $force = false, $alias_domains = [] ) { + // Serialize before register()/authorize() write the account key and order. + $this->acquire_ssl_lock(); $preferred_challenge = get_preferred_ssl_challenge( $alias_domains ); $is_solver_dns = ( $wildcard || 'dns' === $preferred_challenge ) ? true : false; \EE::debug( 'Wildcard in init_le: ' . ( bool ) $wildcard ); @@ -1670,6 +1719,9 @@ public function ssl_verify( $args = [], $assoc_args = [], $www_or_non_www = fals EE::log( 'Starting SSL verification.' ); + // Reentrant when called from init_le (lock already held); locks for standalone `ee site ssl-verify`. + $this->acquire_ssl_lock(); + // This checks if this method was called internally by ee or by user $called_by_ee = ! empty( $this->site_data['site_url'] ); $api_key_absent = empty( get_config_value( 'cloudflare-api-key' ) ); @@ -1944,6 +1996,9 @@ public function ssl_renew( $args, $assoc_args ) { EE::log( 'Starting SSL cert renewal' ); + // First call in a `ssl-renew --all` batch locks; later per-site calls are reentrant. + $this->acquire_ssl_lock(); + if ( ! isset( $this->le_mail ) ) { $this->le_mail = EE::get_config( 'le-mail' ) ?? EE::input( 'Enter your mail id: ' ); } From 44184308d82535752fba26f690a276eaf98521b3 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 10:42:06 +0000 Subject: [PATCH 2/4] fix(ssl): roll back instead of exiting when the ssl lock is busy mid-operation init_le() now throws when the lock is held, so site create (wp/php/html) and `ee site update --ssl=le` reach their existing catch/rollback paths instead of exiting after the site root, containers and WordPress are already set up but before the site DB entry exists. update_alias_domains() now takes the lock before it dumps the compose file with HTTPS disabled for the HTTP-01 challenge, so a busy lock can no longer leave an LE site serving without HTTPS and with the new alias only in the compose file. A failed fopen() of the lock file now reports that instead of claiming another SSL operation is running. --- src/helper/class-ee-site.php | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index bbb75adb..09281b79 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -540,6 +540,11 @@ protected function update_alias_domains( $args, $assoc_args ) { $array_data = (array) $this->site_data; $this->site_data = reset( $array_data ); + // Lock before the compose dump below drops HTTPS, so a busy lock can't leave the site half-updated. + if ( 'le' === $this->site_data['site_ssl'] ) { + $this->acquire_ssl_lock(); + } + // Validate data. $existing_alias_domains = []; $domains_to_add = []; @@ -1542,14 +1547,15 @@ protected function init_ssl( $site_url, $site_fs_path, $ssl_type, $wildcard = fa * overwrites the account key. This guards the three ACME entry points so only one * such operation runs per server at a time. * - * Non-blocking (LOCK_NB): a held lock fails fast with a clear error instead of - * hanging cron. The handle is kept open for the whole operation and is never - * released or deleted here -- the advisory flock is tied to the fd and the kernel - * drops it automatically when the process exits, so it is crash-safe. + * Non-blocking (LOCK_NB): a held lock fails fast instead of hanging cron. The handle + * is never released here; the kernel drops the flock when the process exits. + * + * @param bool $throw Throw an exception instead of exiting, so callers that already changed site state can roll back. * + * @throws \Exception When $throw is set and the lock can't be acquired. * @return void */ - private function acquire_ssl_lock() { + private function acquire_ssl_lock( $throw = false ) { // Already held by this process (reentrant: nested ssl_verify, or --all loop). if ( isset( self::$ssl_lock_handle ) ) { return; @@ -1562,8 +1568,14 @@ private function acquire_ssl_lock() { if ( ! $fh || ! flock( $fh, LOCK_EX | LOCK_NB ) ) { if ( $fh ) { fclose( $fh ); + $message = 'Another SSL operation is already in progress on this server. Wait for it to finish and retry.'; + } else { + $message = 'Unable to open SSL lock file: ' . $lock_file; + } + if ( $throw ) { + throw new \Exception( $message ); } - \EE::error( 'Another SSL operation is already in progress on this server. Wait for it to finish and retry.' ); + \EE::error( $message ); } self::$ssl_lock_handle = $fh; @@ -1580,8 +1592,8 @@ private function acquire_ssl_lock() { * @param array $alias_domains Array of alias domains if any. */ protected function init_le( $site_url, $site_fs_path, $wildcard = false, $www_or_non_www, $force = false, $alias_domains = [] ) { - // Serialize before register()/authorize() write the account key and order. - $this->acquire_ssl_lock(); + // Serialize before register()/authorize() write the account key and order. Throws so create/update roll back instead of exiting mid-way. + $this->acquire_ssl_lock( true ); $preferred_challenge = get_preferred_ssl_challenge( $alias_domains ); $is_solver_dns = ( $wildcard || 'dns' === $preferred_challenge ) ? true : false; \EE::debug( 'Wildcard in init_le: ' . ( bool ) $wildcard ); From 211217ed383e51a144fa6bf416afead3a8e32565 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 16:43:18 +0000 Subject: [PATCH 3/4] fix(ssl): wait for a busy ssl lock before failing Failing fast made a `--ssl=le` create that overlapped the nightly renewal install the whole site and then roll it back, and made the nightly `ssl-renew --all` skip every site when a manual SSL command was running. Poll the non-blocking flock for up to 120 seconds (600 for `ssl-renew`, which cron runs) with a waiting message, then fail with the same error or roll back as before. --- src/helper/class-ee-site.php | 38 ++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index 09281b79..61687ee7 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -35,6 +35,10 @@ * @package ee */ abstract class EE_Site_Command { + // Seconds to wait for another process's SSL lock before giving up; cron renewals can wait longer. + const SSL_LOCK_WAIT_SECONDS = 120; + const SSL_RENEW_LOCK_WAIT_SECONDS = 600; + /** * @var Filesystem $fs Symfony Filesystem object. */ @@ -1547,15 +1551,16 @@ protected function init_ssl( $site_url, $site_fs_path, $ssl_type, $wildcard = fa * overwrites the account key. This guards the three ACME entry points so only one * such operation runs per server at a time. * - * Non-blocking (LOCK_NB): a held lock fails fast instead of hanging cron. The handle + * Polls a non-blocking flock for up to $wait seconds, so the wait stays bounded and interruptible. The handle * is never released here; the kernel drops the flock when the process exits. * * @param bool $throw Throw an exception instead of exiting, so callers that already changed site state can roll back. + * @param int $wait Seconds to wait for a lock held by another process. * * @throws \Exception When $throw is set and the lock can't be acquired. * @return void */ - private function acquire_ssl_lock( $throw = false ) { + private function acquire_ssl_lock( $throw = false, $wait = self::SSL_LOCK_WAIT_SECONDS ) { // Already held by this process (reentrant: nested ssl_verify, or --all loop). if ( isset( self::$ssl_lock_handle ) ) { return; @@ -1564,13 +1569,30 @@ private function acquire_ssl_lock( $throw = false ) { // EE_ROOT_DIR is defined at plugin load and always exists at runtime. $lock_file = EE_ROOT_DIR . '/ssl-global.lock'; $fh = fopen( $lock_file, 'c' ); + $locked = false; + + if ( $fh ) { + $deadline = microtime( true ) + $wait; + $waiting = false; + while ( true ) { + $locked = flock( $fh, LOCK_EX | LOCK_NB, $would_block ); + if ( $locked || ! $would_block || microtime( true ) >= $deadline ) { + break; + } + if ( ! $waiting ) { + \EE::log( sprintf( 'Waiting up to %ds for another SSL operation to finish...', $wait ) ); + $waiting = true; + } + sleep( 1 ); + } + } - if ( ! $fh || ! flock( $fh, LOCK_EX | LOCK_NB ) ) { - if ( $fh ) { - fclose( $fh ); - $message = 'Another SSL operation is already in progress on this server. Wait for it to finish and retry.'; - } else { + if ( ! $locked ) { + if ( ! $fh ) { $message = 'Unable to open SSL lock file: ' . $lock_file; + } else { + fclose( $fh ); + $message = $would_block ? 'Another SSL operation is already in progress on this server. Wait for it to finish and retry.' : 'Unable to lock SSL lock file: ' . $lock_file; } if ( $throw ) { throw new \Exception( $message ); @@ -2009,7 +2031,7 @@ public function ssl_renew( $args, $assoc_args ) { EE::log( 'Starting SSL cert renewal' ); // First call in a `ssl-renew --all` batch locks; later per-site calls are reentrant. - $this->acquire_ssl_lock(); + $this->acquire_ssl_lock( false, self::SSL_RENEW_LOCK_WAIT_SECONDS ); if ( ! isset( $this->le_mail ) ) { $this->le_mail = EE::get_config( 'le-mail' ) ?? EE::input( 'Enter your mail id: ' ); From 98aa533c52d32c7274bf8312b997210f9ed72315 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 17:12:51 +0000 Subject: [PATCH 4/4] fix(ssl): let a signal end the ssl lock wait EE_Site_Command registers SIGINT/SIGTERM handlers, but class-ee-site.php has no declare(ticks), so during the wait loop a Ctrl-C stayed pending and sleep() just resumed: the command kept waiting the full 120 s (600 s for ssl-renew). Dispatch pending signals after each poll so the site type's rollback handler runs and the command exits. --- src/helper/class-ee-site.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index 61687ee7..b9973540 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -1584,6 +1584,8 @@ private function acquire_ssl_lock( $throw = false, $wait = self::SSL_LOCK_WAIT_S $waiting = true; } sleep( 1 ); + // This file has no declare(ticks), so run pending SIGINT/SIGTERM handlers here or Ctrl-C can't end the wait. + pcntl_signal_dispatch(); } }