Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions src/helper/class-ee-site.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -55,6 +59,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.
*/
Expand Down Expand Up @@ -529,6 +544,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 = [];
Expand Down Expand Up @@ -1522,6 +1542,69 @@ 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.
*
* 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, $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.
*
Expand All @@ -1533,6 +1616,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 );
Expand Down Expand Up @@ -1670,6 +1755,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' ) );
Expand Down Expand Up @@ -1944,6 +2032,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 );

if ( ! isset( $this->le_mail ) ) {
$this->le_mail = EE::get_config( 'le-mail' ) ?? EE::input( 'Enter your mail id: ' );
}
Expand Down
Loading