diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index a7095ed8..f4dd033b 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. */ @@ -55,6 +59,13 @@ abstract class EE_Site_Command { */ private $le_mail; + /** + * @var resource $ssl_lock_handle Handle of the process-wide SSL lock. + * + * Static because `ssl-renew --all` renews every site in one process, and flock refuses a second handle there. + */ + private static $ssl_lock_handle; + /** * @var array $site_data Associative array containing essential site related information. */ @@ -512,6 +523,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(); + } + // Drop blanks so that e.g. `b.com,` never stores an empty alias domain. $existing_alias_domains = split_alias_domains( (string) $this->site_data['alias_domains'] ); $domains_to_add = split_alias_domains( $add_domains ); @@ -1554,6 +1570,63 @@ protected function init_ssl( $site_url, $site_fs_path, $ssl_type, $wildcard = fa } } + /** + * Acquire a process-wide lock serializing all SSL/ACME operations. + * + * Concurrent runs share the HTTP-01 challenge files and vhost.d/default (wiped by cleanup()), proxy reloads and per-domain ACME state. + * Polls for up to $wait seconds; the kernel drops the lock 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, $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; + } + + // 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 ); + // This file has no declare(ticks), so run pending SIGINT/SIGTERM handlers here or Ctrl-C can't end the wait. + pcntl_signal_dispatch(); + } + } + + 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 ); + } + \EE::error( $message ); + } + + self::$ssl_lock_handle = $fh; + } + /** * Runs the acme le registration and authorization. * @@ -1565,6 +1638,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. 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 ); @@ -1730,6 +1805,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' ) ); @@ -2019,6 +2097,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( false, self::SSL_RENEW_LOCK_WAIT_SECONDS ); + $force = get_flag_value( $assoc_args, 'force', false ); $all = get_flag_value( $assoc_args, 'all', false );