From d078fb058e3db88083a0e07a6be124d9c6978599 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 29 Sep 2026 08:22:41 +0530 Subject: [PATCH 01/27] fix(site): keep the proxy config valid when a proxy cache update fails update_proxy_cache() tested nginx before default.conf was regenerated and, on a failure, removed the site's cache zone plus whichever location file the alias loop had written last. The other locations kept `proxy_cache ` with no zone, so every later `nginx -t` failed, and after a proxy restart nginx could not start at all. - Regenerate default.conf before the test and retry it once after a second, since the proxy's docker-gen rewrites the file in place and a test racing it can read a partial file. reload_global_nginx_proxy() retries the same way when the failure is in default.conf. - On a failure, put back exactly the files this call wrote (restoring earlier contents, removing new ones), so a rollback never leaves a location without its zone. - A failed enable now exits non-zero and keeps proxy_cache unchanged; on alias changes and site create it warns instead, and create stores proxy_cache as off. - Alias changes only write locations, so they reload the proxy instead of restarting it (which took every site down for ~0.3 s). - Deleting an alias removes its proxy cache location, which would otherwise use this site's zone if the domain were ever served again. --- src/helper/class-ee-site.php | 84 +++++++++++++++++++++++------------- src/helper/site-utils.php | 68 +++++++++++++++++++++++++++-- 2 files changed, 119 insertions(+), 33 deletions(-) diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index cd9f01de..61dd565f 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -696,6 +696,14 @@ protected function update_alias_domains( $args, $assoc_args ) { EE::error( $e->getMessage() ); } + // A stale proxy cache location would use this site's cache zone if the domain is ever served again, e.g. after the site is deleted. + foreach ( array_diff( $domains_to_delete, [ $this->site_data['site_url'], '*.' . $this->site_data['site_url'] ] ) as $domain ) { + $location_file = EE_ROOT_DIR . '/services/nginx-proxy/vhost.d/' . $domain . '_location'; + if ( $this->fs->exists( $location_file ) ) { + $this->fs->remove( $location_file ); + } + } + if ( ! empty( $this->site_data['proxy_cache'] ) && 'on' === $this->site_data['proxy_cache'] ) { EE::log( 'As proxy cache is enabled on this site, updating config to enable it in newly added alias domains.' ); $this->site_data = get_site_info( $args, true, true, false ); @@ -791,6 +799,8 @@ protected function update_proxy_cache( $args, $assoc_args, $call_on_create = fal ] ); } + $failed = false; + if ( 'on' === $proxy_cache ) { $sanitized_site_url = str_replace( '.', '-', $this->site_data['site_url'] ); @@ -805,30 +815,33 @@ protected function update_proxy_cache( $args, $assoc_args, $call_on_create = fal ]; $proxy_conf_content = \EE\Utils\mustache_render( SITE_TEMPLATE_ROOT . '/config/nginx-proxy/proxy.conf.mustache', $data ); - if ( ! $force ) { - $this->fs->dumpFile( $proxy_conf_location, $proxy_conf_content ); + // In force mode (alias domain changes) the cache zone in conf.d is kept, only the locations using it are written. + $location_files = [ $proxy_vhost_location ]; + if ( 'subdom' === $this->site_data['app_sub_type'] ) { + $location_files[] = $proxy_vhost_location_subdom; + } + foreach ( $alias_domains as $ad ) { + $location_files[] = EE_ROOT_DIR . '/services/nginx-proxy/vhost.d/' . $ad . '_location'; } - $proxy_vhost_content = \EE\Utils\mustache_render( SITE_TEMPLATE_ROOT . '/config/nginx-proxy/vhost_location.conf.mustache', $data ); - $this->fs->dumpFile( $proxy_vhost_location, $proxy_vhost_content ); + $written_files = $force ? $location_files : array_merge( [ $proxy_conf_location ], $location_files ); + $backup = \EE\Site\Utils\backup_files( $written_files ); - if ( 'subdom' === $this->site_data['app_sub_type'] ) { - $this->fs->dumpFile( $proxy_vhost_location_subdom, $proxy_vhost_content ); + if ( ! $force ) { + $this->fs->dumpFile( $proxy_conf_location, $proxy_conf_content ); } - foreach ( $alias_domains as $ad ) { - - $proxy_vhost_location = EE_ROOT_DIR . '/services/nginx-proxy/vhost.d/' . $ad . '_location'; - $proxy_vhost_content = \EE\Utils\mustache_render( SITE_TEMPLATE_ROOT . '/config/nginx-proxy/vhost_location.conf.mustache', $data ); - $this->fs->dumpFile( $proxy_vhost_location, $proxy_vhost_content ); + $proxy_vhost_content = \EE\Utils\mustache_render( SITE_TEMPLATE_ROOT . '/config/nginx-proxy/vhost_location.conf.mustache', $data ); + foreach ( $location_files as $location_file ) { + $this->fs->dumpFile( $location_file, $proxy_vhost_content ); } } else { $reload = false; - $conf_locations = [ $proxy_conf_location, $proxy_vhost_location, $proxy_vhost_location_subdom ]; - - $reload = false; + foreach ( $alias_domains as $ad ) { + $conf_locations[] = EE_ROOT_DIR . '/services/nginx-proxy/vhost.d/' . $ad . '_location'; + } foreach ( $conf_locations as $cl ) { @@ -838,32 +851,45 @@ protected function update_proxy_cache( $args, $assoc_args, $call_on_create = fal } } - foreach ( $alias_domains as $ad ) { - - $proxy_vhost_location = EE_ROOT_DIR . '/services/nginx-proxy/vhost.d/' . $ad . '_location'; - if ( $this->fs->exists( $proxy_vhost_location ) ) { - $this->fs->remove( $proxy_vhost_location ); - $reload = true; - } - } - if ( $reload ) { \EE\Site\Utils\reload_global_nginx_proxy(); EE::exec( 'docker exec ' . EE_PROXY_TYPE . " bash -c 'rm -rf /var/cache/nginx/" . $this->site_data['site_url'] . "'" ); } } - if ( EE::exec( 'docker exec ' . EE_PROXY_TYPE . " bash -c 'nginx -t'" ) ) { + + $test = \EE\Site\Utils\test_global_nginx_proxy_config( true ); + if ( 0 === $test->return_code ) { \EE\Site\Utils\reload_global_nginx_proxy(); - EE::exec( 'docker restart ' . EE_PROXY_TYPE ); - } else { - $this->fs->remove( $proxy_conf_location ); - $this->fs->remove( $proxy_vhost_location ); - $this->fs->remove( $proxy_vhost_location_subdom ); + // A changed cache zone needs a fresh nginx master; alias changes only add locations, which the reload applies. + if ( ! $force ) { + EE::exec( 'docker restart ' . EE_PROXY_TYPE ); + } + } elseif ( 'on' === $proxy_cache ) { + // Put back exactly what this call replaced, so no location is left using a cache zone that is gone. + \EE\Site\Utils\restore_files( $backup ); \EE\Site\Utils\reload_global_nginx_proxy(); + $failed = true; + } else { + // Removing the zone together with every location that uses it keeps the config valid, so the failure is elsewhere. + EE::warning( "nginx config test failed after disabling proxy cache:\n" . trim( $test->stderr ) ); } } catch ( \Exception $e ) { EE::error( $e->getMessage() ); } + + if ( $failed ) { + $message = 'Could not enable proxy cache on ' . $this->site_data['site_url'] . ", nginx config test failed. The previous proxy config was restored.\n" . trim( $test->stderr ); + if ( $force || $call_on_create ) { + EE::warning( $message ); + if ( $call_on_create ) { + $this->site_data['proxy_cache'] = 'off'; + } + + return; + } + EE::error( $message ); + } + if ( ! $call_on_create ) { $site->proxy_cache = $proxy_cache; $site->save(); diff --git a/src/helper/site-utils.php b/src/helper/site-utils.php index eaa512ca..a32c9f72 100644 --- a/src/helper/site-utils.php +++ b/src/helper/site-utils.php @@ -793,11 +793,8 @@ function configure_postfix( $site_url, $site_fs_path ) { */ function reload_global_nginx_proxy() { - // Regenerate default.conf first so `nginx -t` validates the config that will actually be served. - \EE::launch( sprintf( 'docker exec %s sh -c "/app/docker-entrypoint.sh /usr/local/bin/docker-gen /app/nginx.tmpl /etc/nginx/conf.d/default.conf"', EE_PROXY_TYPE ) ); - // `EE::launch()` returns a ProcessRun object (truthy), so gate on the exit code to avoid reloading a broken config. - $test = \EE::launch( sprintf( 'docker exec %s sh -c "nginx -t"', EE_PROXY_TYPE ) ); + $test = test_global_nginx_proxy_config(); if ( 0 !== $test->return_code ) { \EE::warning( 'nginx config test failed, skipping reload of ' . EE_PROXY_TYPE . ":\n" . $test->stderr ); @@ -807,6 +804,69 @@ function reload_global_nginx_proxy() { return \EE::launch( sprintf( 'docker exec %s sh -c "/usr/sbin/nginx -s reload"', EE_PROXY_TYPE ) ); } +/** + * Regenerates the global proxy's default.conf and runs `nginx -t` on it. + * + * @param bool $retry_any_failure Retry once on any failure, not only on one reported in default.conf. + * + * @return \EE\ProcessRun Result of the last `nginx -t`. + */ +function test_global_nginx_proxy_config( $retry_any_failure = false ) { + + $regenerate = sprintf( 'docker exec %s sh -c "/app/docker-entrypoint.sh /usr/local/bin/docker-gen /app/nginx.tmpl /etc/nginx/conf.d/default.conf"', EE_PROXY_TYPE ); + $test_cmd = sprintf( 'docker exec %s sh -c "nginx -t"', EE_PROXY_TYPE ); + + // Regenerate first so `nginx -t` validates the config that will actually be served. + \EE::launch( $regenerate ); + $test = \EE::launch( $test_cmd ); + + // The proxy's own docker-gen rewrites default.conf in place after container events, so a test racing it can read a partial file. + if ( 0 !== $test->return_code && ( $retry_any_failure || false !== strpos( $test->stderr, '/etc/nginx/conf.d/default.conf' ) ) ) { + \EE::debug( "nginx config test failed, retrying once:\n" . $test->stderr ); + sleep( 1 ); + \EE::launch( $regenerate ); + $test = \EE::launch( $test_cmd ); + } + + return $test; +} + +/** + * Reads files so that they can be put back with `restore_files()`. + * + * @param array $paths Absolute file paths. + * + * @return array Path => content, or null for a file that does not exist. + */ +function backup_files( array $paths ) { + + $backup = []; + foreach ( array_unique( $paths ) as $path ) { + $backup[ $path ] = is_file( $path ) ? file_get_contents( $path ) : null; + } + + return $backup; +} + +/** + * Puts files back as `backup_files()` found them: rewrites the ones that existed and removes the others. + * + * @param array $backup Return value of `backup_files()`. + */ +function restore_files( array $backup ) { + + $fs = new Filesystem(); + foreach ( $backup as $path => $content ) { + if ( null === $content ) { + if ( file_exists( $path ) ) { + $fs->remove( $path ); + } + } else { + $fs->dumpFile( $path, $content ); + } + } +} + /** * Get global auth if it exists. */ From 60605701905ba2cd0f004ff22877c5aad24964f8 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 29 Sep 2026 08:51:09 +0530 Subject: [PATCH 02/27] fix(site): roll back an alias change when its certificate can't be issued When the Let's Encrypt order, validation or request failed during `--add-alias-domains`/`--delete-alias-domains`, init_le() only warned and cleared site_ssl. The command then printed "SSL renewal completed" and "Alias domains updated", revoked the certificate that was still being served, and saved the new aliases with SSL "Not Enabled", so later renewals failed with "Only Letsencrypt certificate renewal is supported.". The certificate is now issued through reissue_le_certificate(), which throws when init_le() did not issue one, after putting back the site's certificate, ACME and redirect files. The alias change then takes the existing revert path: the site is refreshed from the unchanged DB, the failure hook fires, the new domains' authorization challenges are removed, and the command exits non-zero. The old certificate is only revoked after a successful reissue. `ee site ssl-renew ` gets the same restore and now exits non-zero on a failed renewal instead of printing "SSL renewal completed."; under `--all` it warns and moves on to the next site. --- src/helper/class-ee-site.php | 70 ++++++++++++++++++++++++++++------ src/helper/site-utils.php | 74 ++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 12 deletions(-) diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index 61dd565f..30224754 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -657,23 +657,33 @@ protected function update_alias_domains( $args, $assoc_args ) { } } - $client = new Site_Letsencrypt(); - - $old_certs = $client->loadDomainCertificates( $all_domains ); - if ( $is_ssl ) { // Only Let's Encrypt certs can be reissued by EE to cover the new alias-domain set. if ( 'le' === $this->site_data['site_ssl'] ) { + $client = new Site_Letsencrypt(); + $old_certs = $client->loadDomainCertificates( $all_domains ); + // Update SSL. EE::log( 'Updating and force renewing SSL certificate to accomodated alias domain changes.' ); try { - $this->ssl_renew( [ $this->site_data['site_url'] ], [ 'force' => true ] ); + if ( ! isset( $this->le_mail ) ) { + $this->le_mail = EE::get_config( 'le-mail' ) ?? EE::input( 'Enter your mail id: ' ); + } + $this->reissue_le_certificate( true ); } catch ( \Exception $e ) { EE::warning( 'Certificate could not be issued. Reverting back to original state.' ); + // The DB still has the old alias domains, so a refresh regenerates the site's compose file and proxy config from it. $this->enable( [ $this->site_data['site_url'] ], [ 'refresh' => 'true' ] ); \EE::do_hook( 'site_alias_domains_update_failed', $site->site_url, $domains_to_add ); - EE::error( $e->getMessage() ); + // The failed order left an authorization challenge for each new domain. + foreach ( array_diff( $domains_to_add, [ \EE\Site\Utils\get_www_counterpart( $site->site_url ) ] ) as $domain ) { + $this->fs->remove( EE_ROOT_DIR . '/services/nginx-proxy/acme-conf/var/' . $domain ); + } + EE::error( sprintf( 'Alias domains of %s were not changed: %s', $site->site_url, $e->getMessage() ) ); } + + // Revoke the old certificate, which the new one replaces. + $client->revokeCertificates( $old_certs ); } elseif ( 'custom' === $this->site_data['site_ssl'] ) { EE::warning( 'Custom SSL certificate is not renewed automatically. Please ensure the certificate you provided covers the updated alias-domain set.' ); } else { @@ -682,9 +692,6 @@ protected function update_alias_domains( $args, $assoc_args ) { } } - // Revoke old certificate which will not be used - $client->revokeCertificates( $old_certs ); - chdir( $this->site_data['site_fs_path'] ); // Required as env variables have changed. \EE_DOCKER::docker_compose_up( $this->site_data['site_fs_path'], [ 'nginx' ] ); @@ -2174,11 +2181,25 @@ public function ssl_renew( $args, $assoc_args ) { } continue; } - $this->renew_ssl_cert( [ $site->site_url ], $force ); + try { + $this->renew_ssl_cert( [ $site->site_url ], $force ); + } catch ( \Exception $e ) { + EE::warning( $e->getMessage() . ' The current certificate is kept.' ); + } } } else { $args = auto_site_name( $args, 'site', __FUNCTION__ ); - $this->renew_ssl_cert( $args, $force ); + try { + $this->renew_ssl_cert( $args, $force ); + } catch ( \Exception $e ) { + // `ssl-renew --all` runs this once per site, and one failed site must not stop the others. + if ( ! empty( EE::get_runner()->assoc_args['all'] ) ) { + EE::warning( $e->getMessage() . ' The current certificate is kept.' ); + + return; + } + EE::error( $e->getMessage() . ' The current certificate is kept.' ); + } } EE::success( 'SSL renewal completed.' ); } @@ -2233,9 +2254,34 @@ private function renew_ssl_cert( $args, $force ) { } self::$le_renewal_started = true; + $this->reissue_le_certificate( $force ); + } + + /** + * Issues the site's Let's Encrypt certificate for its current domains. + * + * @param bool $force Whether to force renewal of cert or not. + * + * @throws \Exception When no certificate was issued. The site's certificate, ACME and redirect files are put back first. + */ + private function reissue_le_certificate( $force ) { + + $backup = \EE\Site\Utils\backup_files( \EE\Site\Utils\get_site_ssl_file_paths( $this->site_data['site_url'] ) ); $postfix_exists = \EE_DOCKER::service_exists( 'postfix', $this->site_data['site_fs_path'] ); $containers_to_start = $postfix_exists ? [ 'nginx', 'postfix' ] : [ 'nginx' ]; - $this->www_ssl_wrapper( $containers_to_start, false, $force, true ); + + try { + $this->www_ssl_wrapper( $containers_to_start, false, $force, true ); + // init_le() only warns and clears site_ssl when the order, the validation or the request fails. + if ( 'le' !== $this->site_data['site_ssl'] ) { + throw new \Exception( sprintf( 'Let\'s Encrypt certificate could not be issued for %s. See the warnings above.', $this->site_data['site_url'] ) ); + } + } catch ( \Exception $e ) { + $this->site_data['site_ssl'] = 'le'; + \EE\Site\Utils\restore_files( $backup ); + reload_global_nginx_proxy(); + throw $e; + } reload_global_nginx_proxy(); } diff --git a/src/helper/site-utils.php b/src/helper/site-utils.php index a32c9f72..b8d04847 100644 --- a/src/helper/site-utils.php +++ b/src/helper/site-utils.php @@ -867,6 +867,80 @@ function restore_files( array $backup ) { } } +/** + * Paths of a site's certificate and ACME files that an LE issuance can change. + * + * @param string $site_url Name of the site. + * + * @return array Absolute file paths. + */ +function get_site_ssl_file_paths( $site_url ) { + + $proxy_dir = EE_ROOT_DIR . '/services/nginx-proxy'; + $acme_dir = "$proxy_dir/acme-conf"; + + return [ + "$proxy_dir/certs/$site_url.crt", + "$proxy_dir/certs/$site_url.key", + "$proxy_dir/certs/$site_url.chain.pem", + "$proxy_dir/conf.d/$site_url-redirect.conf", + "$acme_dir/certs/$site_url/private/key.public.pem", + "$acme_dir/certs/$site_url/private/key.private.pem", + "$acme_dir/certs/$site_url/private/combined.pem", + "$acme_dir/certs/$site_url/public/cert.pem", + "$acme_dir/certs/$site_url/public/chain.pem", + "$acme_dir/certs/$site_url/public/fullchain.pem", + "$acme_dir/var/$site_url/distinguished_name.json", + ]; +} + +/** + * Removes a site's certificate files from nginx-proxy and its ACME state, whatever the site's current SSL type. + * + * @param string $site_url Name of the site. + * @param array $domains Other domains of the site (alias domains, www variant) whose ACME state goes too. + */ +function remove_site_ssl_files( $site_url, array $domains = [] ) { + + $proxy_dir = EE_ROOT_DIR . '/services/nginx-proxy'; + $paths = [ + "$proxy_dir/certs/$site_url.crt", + "$proxy_dir/certs/$site_url.key", + "$proxy_dir/certs/$site_url.chain.pem", + "$proxy_dir/acme-conf/certs/$site_url", + ]; + + foreach ( array_unique( array_merge( [ $site_url ], $domains ) ) as $domain ) { + if ( '' !== $domain && false === strpos( $domain, '/' ) ) { + $paths[] = "$proxy_dir/acme-conf/var/$domain"; + } + } + + $paths = array_values( array_filter( $paths, 'file_exists' ) ); + if ( empty( $paths ) ) { + return; + } + + \EE::log( 'Removing ssl certs and other config files.' ); + try { + ( new Filesystem() )->remove( $paths ); + } catch ( \Exception $e ) { + \EE::warning( $e->getMessage() ); + } +} + +/** + * The www or non-www counterpart of a domain. + * + * @param string $domain Domain name. + * + * @return string + */ +function get_www_counterpart( $domain ) { + + return 0 === strpos( $domain, 'www.' ) ? substr( $domain, 4 ) : 'www.' . $domain; +} + /** * Get global auth if it exists. */ From 11080996ea399beb02934bf2f4e76852ffdb80f0 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 29 Sep 2026 09:03:27 +0530 Subject: [PATCH 03/27] fix(site): remove a site's certificate files whenever they exist `ee site delete` only removed the site's certificate and ACME files when site_ssl was set, and `--ssl=off` never removed them. Certificates left after SSL was turned off or lost stayed in nginx-proxy's certs forever, where its closest-name matching keeps serving them for the site and for similarly named sites. Delete now removes `certs/.{crt,key,chain.pem}`, `acme-conf/certs/` and the `acme-conf/var` state of the site, its alias domains and its www counterpart (unless that name belongs to another site), whatever the SSL type. This also removes the empty alias directories and the www authorization challenge that deleting an LE site used to leave behind. `--ssl=off` rewrites the site's www redirect without its HTTPS block, which loads the certificate, and then removes the same files. --- src/helper/class-ee-site.php | 48 +++++++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index 30224754..1d4ce41a 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -388,21 +388,8 @@ protected function delete_site( $level, $site_url, $site_fs_path, $db_data = [] \EE::do_hook( 'site_cleanup', $site_url ); if ( $level > 4 ) { - if ( $this->site_data['site_ssl'] ) { - \EE::log( 'Removing ssl certs and other config files.' ); - $crt_file = EE_ROOT_DIR . "/services/nginx-proxy/certs/$site_url.crt"; - $key_file = EE_ROOT_DIR . "/services/nginx-proxy/certs/$site_url.key"; - $pem_file = EE_ROOT_DIR . "/services/nginx-proxy/certs/$site_url.chain.pem"; - $conf_certs = EE_ROOT_DIR . "/services/nginx-proxy/acme-conf/certs/$site_url"; - $conf_var = EE_ROOT_DIR . "/services/nginx-proxy/acme-conf/var/$site_url"; - - $delete_files = [ $conf_certs, $conf_var, $crt_file, $key_file, $pem_file ]; - try { - $this->fs->remove( $delete_files ); - } catch ( \Exception $e ) { - \EE::warning( $e ); - } - } + // Also when site_ssl is empty: SSL may have been turned off or lost while the files stayed, and nginx-proxy would keep matching them. + \EE\Site\Utils\remove_site_ssl_files( $site_url, $this->get_ssl_domains( $site_url ) ); if ( Site::find( $site_url )->delete() ) { \EE::log( 'Removed database entry.' ); @@ -1075,10 +1062,41 @@ protected function update_ssl( $assoc_args ) { */ private function disable_ssl() { + $site_url = $this->site_data['site_url']; + $this->dump_docker_compose_yml( [ 'nohttps' => true ] ); \EE\Site\Utils\start_site_containers( $this->site_data['site_fs_path'], [ 'nginx' ] ); + + // The redirect's HTTPS block loads the certificate that is removed below. + if ( $this->fs->exists( EE_ROOT_DIR . '/services/nginx-proxy/conf.d/' . $site_url . '-redirect.conf' ) ) { + \EE\Site\Utils\add_site_redirects( $site_url, false, false ); + } \EE\Site\Utils\reload_global_nginx_proxy(); + + // Left behind, nginx-proxy would still match the certificate to this site's (and similarly named sites') hosts. + \EE\Site\Utils\remove_site_ssl_files( $site_url, $this->get_ssl_domains( $site_url ) ); + } + + /** + * Domains other than the site itself that can have ACME state for its certificate: its alias domains and its www counterpart. + * + * @param string $site_url Name of the site. + * + * @return array + */ + private function get_ssl_domains( $site_url ) { + + $domains = empty( $this->site_data['alias_domains'] ) ? [] : explode( ',', $this->site_data['alias_domains'] ); + + // Never touch the state of the www counterpart when it is another site or another site's alias. + $www = \EE\Site\Utils\get_www_counterpart( $site_url ); + $parent = get_parent_of_alias( $www ); + if ( ! Site::find( $www ) && ( empty( $parent ) || $site_url === $parent ) ) { + $domains[] = $www; + } + + return array_values( array_diff( array_unique( array_map( 'trim', $domains ) ), [ $site_url, '' ] ) ); } /** From 26b45d969ddd8963aebf8690d0350af70c454372 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 29 Sep 2026 09:08:14 +0530 Subject: [PATCH 04/27] fix(site): keep sites without SSL HTTP-only on alias changes update_alias_domains() dumped the compose file with `nohttps` only for LE sites, so adding or deleting an alias dropped `HTTPS_METHOD=nohttps` from a site without SSL until the next refresh. nginx-proxy then falls back to `redirect` and turns HTTPS on as soon as a certificate file name matches the host, e.g. the site's own leftover certificate or a parent site's. --- src/helper/class-ee-site.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index 1d4ce41a..94a96c24 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -614,8 +614,9 @@ protected function update_alias_domains( $args, $assoc_args ) { $this->site_data['alias_domains'] = implode( ',', $final_alias_domains ); $is_ssl = $this->site_data['site_ssl'] ? true : false; $preferred_ssl_challenge = get_preferred_ssl_challenge( get_domains_of_site( $this->site_data['site_url'] ) ); - // Only LE sites drop HTTPS here for the HTTP-01 challenge; the renewal below turns it back on, other SSL types keep theirs. - $nohttps = 'le' === $this->site_data['site_ssl'] && 'dns' !== $preferred_ssl_challenge; + // Sites without SSL stay HTTP-only, else nginx-proxy serves HTTPS with any leftover cert whose name matches. + // LE sites drop HTTPS here for the HTTP-01 challenge; the renewal below turns it back on, other SSL types keep theirs. + $nohttps = ! $is_ssl || ( 'le' === $this->site_data['site_ssl'] && 'dns' !== $preferred_ssl_challenge ); $this->dump_docker_compose_yml( [ 'nohttps' => $nohttps ] ); \EE_DOCKER::docker_compose_up( $this->site_data['site_fs_path'], [ 'nginx' ] ); } catch ( \Exception $e ) { From 773af89b9f481892691164556e4dd970513b71cc Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 29 Sep 2026 09:24:50 +0530 Subject: [PATCH 05/27] fix(site): skip ACME challenges EE can't solve when ordering a certificate acmephp 1.3's requestOrder() builds an AuthorizationChallenge from every challenge of every authorization and reads `token` from each. A challenge type without a token, such as the draft dns-persist-01 that Pebble 2.10 offers on every authorization and that Let's Encrypt has announced, makes the order throw, so every issuance failed with "It seems you're in local environment or using non-public domain". EEAcmeClient now overrides requestOrder() and keeps only http-01 and dns-01 challenges that carry a token. An authorization left without any still reaches authorize(), which reports the domain as unsupported. No vendor code is patched. --- src/helper/Site_Letsencrypt.php | 60 +++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/src/helper/Site_Letsencrypt.php b/src/helper/Site_Letsencrypt.php index 7285f4fd..0efe822b 100644 --- a/src/helper/Site_Letsencrypt.php +++ b/src/helper/Site_Letsencrypt.php @@ -19,6 +19,7 @@ use AcmePhp\Core\Exception\Protocol\CertificateRevocationException; use AcmePhp\Core\Exception\Server\RateLimitedServerException; use AcmePhp\Core\Protocol\AuthorizationChallenge; +use AcmePhp\Core\Protocol\CertificateOrder; use AcmePhp\Core\Protocol\ResourcesDirectory; use AcmePhp\Core\Protocol\RevocationReason; use AcmePhp\Core\Http\Base64SafeEncoder; @@ -70,6 +71,65 @@ private function getResourceAccount() return $this->account; } + /** + * Same as acmephp's requestOrder(), but keeps only the challenge types EE can solve. + * + * acmephp 1.3 reads a token from every challenge, so one without a token (the draft dns-persist-01 that Pebble 2.10 offers) made the whole order throw. + * + * @param array $domains Domains to order a certificate for. + * + * @return CertificateOrder + */ + public function requestOrder( array $domains ) { + \Webmozart\Assert\Assert::allStringNotEmpty( $domains, 'requestOrder::$domains expected a list of strings. Got: %s' ); + + $payload = [ + 'identifiers' => array_map( + function ( $domain ) { + return [ + 'type' => 'dns', + 'value' => $domain, + ]; + }, + array_values( $domains ) + ), + ]; + + $client = $this->getHttpClient(); + $resourceUrl = $this->getResourceUrl( ResourcesDirectory::NEW_ORDER ); + $response = $client->request( 'POST', $resourceUrl, $client->signKidPayload( $resourceUrl, $this->getResourceAccount(), $payload ) ); + if ( ! isset( $response['authorizations'] ) || ! $response['authorizations'] ) { + throw new ChallengeNotSupportedException(); + } + + $orderEndpoint = $client->getLastLocation(); + $authorizationsChallenges = []; + $base64encoder = $client->getBase64Encoder(); + foreach ( $response['authorizations'] as $authorizationEndpoint ) { + $authorizationsResponse = $client->request( 'POST', $authorizationEndpoint, $client->signKidPayload( $authorizationEndpoint, $this->getResourceAccount(), null ) ); + $domain = ( empty( $authorizationsResponse['wildcard'] ) ? '' : '*.' ) . $authorizationsResponse['identifier']['value']; + + // An empty list still reaches authorize(), which reports the domain as unsupported. + $authorizationsChallenges[ $domain ] = []; + foreach ( $authorizationsResponse['challenges'] as $challenge ) { + if ( ! in_array( $challenge['type'] ?? '', [ 'http-01', 'dns-01' ], true ) || empty( $challenge['token'] ) || ! is_string( $challenge['token'] ) ) { + \EE::debug( 'Skipping unsupported ACME challenge ' . ( $challenge['type'] ?? '(no type)' ) . ' for ' . $domain ); + continue; + } + $authorizationsChallenges[ $domain ][] = new AuthorizationChallenge( + $authorizationsResponse['identifier']['value'], + $challenge['status'], + $challenge['type'], + $challenge['url'], + $challenge['token'], + $challenge['token'] . '.' . $base64encoder->encode( $client->getJWKThumbprint() ) + ); + } + } + + return new CertificateOrder( $authorizationsChallenges, $orderEndpoint ); + } + public function revokeAuthorizationChallenge(AuthorizationChallenge $challenge) { $payload = [ From 4d2da2e5749cf6f0d9fc80f3be397c635ef2843f Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 29 Sep 2026 09:38:33 +0530 Subject: [PATCH 06/27] fix(site): parse certificates that have no subject CN acmephp's CertificateParser throws `Missing expected key "subject.cn"` for SAN-only certificates, which Let's Encrypt's tlsserver and shortlived profiles and Pebble's default profile issue. `ee site ssl-info` then printed "Could not parse certificate", and the renewal checks (isRenewalNecessary(), ssl_needs_creation()) threw. EECertificateParser falls back to the first SAN as the subject, as the classic profile does, and is used everywhere site-command parses a certificate. --- src/helper/Site_Letsencrypt.php | 44 ++++++++++++++++++++++++++++++--- src/helper/class-ee-site.php | 2 +- src/helper/site-utils.php | 3 +-- 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/helper/Site_Letsencrypt.php b/src/helper/Site_Letsencrypt.php index 0efe822b..772267bd 100644 --- a/src/helper/Site_Letsencrypt.php +++ b/src/helper/Site_Letsencrypt.php @@ -25,9 +25,11 @@ use AcmePhp\Core\Http\Base64SafeEncoder; use AcmePhp\Core\Http\SecureHttpClient; use AcmePhp\Core\Http\ServerErrorHandler; +use AcmePhp\Ssl\Certificate; use AcmePhp\Ssl\CertificateRequest; use AcmePhp\Ssl\DistinguishedName; use AcmePhp\Ssl\Generator\KeyPairGenerator; +use AcmePhp\Ssl\ParsedCertificate; use AcmePhp\Ssl\Parser\CertificateParser; use AcmePhp\Ssl\Parser\KeyParser; use AcmePhp\Ssl\Signer\CertificateRequestSigner; @@ -157,6 +159,42 @@ public function revokeAuthorizationChallenge(AuthorizationChallenge $challenge) } +/** + * acmephp's parser requires a subject CN, which certificates from LE's newer profiles (and Pebble's default one) don't have. + */ +class EECertificateParser extends CertificateParser { + + public function parse( Certificate $certificate ) { + $rawData = openssl_x509_parse( $certificate->getPEM() ); + + if ( ! is_array( $rawData ) || isset( $rawData['subject']['CN'] ) || ! isset( $rawData['extensions']['subjectAltName'], $rawData['serialNumber'], $rawData['validFrom_time_t'], $rawData['validTo_time_t'] ) ) { + return parent::parse( $certificate ); + } + + $san = []; + foreach ( explode( ',', $rawData['extensions']['subjectAltName'] ) as $item ) { + if ( false !== strpos( $item, ':' ) ) { + $san[] = explode( ':', trim( $item ), 2 )[1]; + } + } + if ( empty( $san ) ) { + return parent::parse( $certificate ); + } + + // Use the first SAN as the subject, as LE's classic profile does. + return new ParsedCertificate( + $certificate, + $san[0], + isset( $rawData['issuer']['CN'] ) ? $rawData['issuer']['CN'] : null, + $rawData['subject'] === $rawData['issuer'], + new \DateTime( '@' . $rawData['validFrom_time_t'] ), + new \DateTime( '@' . $rawData['validTo_time_t'] ), + $rawData['serialNumber'], + $san + ); + } +} + class Site_Letsencrypt { private $accountKeyPair; @@ -728,7 +766,7 @@ public function isAlreadyExpired( $domain ) { \EE::log( "Loading current certificate for $domain" ); $certificate = $this->repository->loadDomainCertificate( $domain ); - $certificateParser = new CertificateParser(); + $certificateParser = new EECertificateParser(); $parsedCertificate = $certificateParser->parse( $certificate ); if ( $parsedCertificate->getValidTo()->format( 'U' ) - time() < 0 ) { @@ -759,7 +797,7 @@ public function isRenewalNecessary( $domain ) { \EE::log( "Loading current certificate for $domain" ); $certificate = $this->repository->loadDomainCertificate( $domain ); - $certificateParser = new CertificateParser(); + $certificateParser = new EECertificateParser(); $parsedCertificate = $certificateParser->parse( $certificate ); // 3024000 = 35 days. @@ -809,7 +847,7 @@ private function executeRenewal( $domain, array $alternativeNames, $email, $forc $certificate = $this->repository->loadDomainCertificate( $domain ); if ( ! $force ) { - $certificateParser = new CertificateParser(); + $certificateParser = new EECertificateParser(); $parsedCertificate = $certificateParser->parse( $certificate ); // 3024000 = 35 days. diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index 94a96c24..f127930d 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -2085,7 +2085,7 @@ public function ssl_info( $args, $assoc_args ) { try { $certificate = new \AcmePhp\Ssl\Certificate( file_get_contents( $crt_file ) ); - $certificateParser = new \AcmePhp\Ssl\Parser\CertificateParser(); + $certificateParser = new \EE\Site\Type\EECertificateParser(); $parsedCertificate = $certificateParser->parse( $certificate ); $issuer = $parsedCertificate->getIssuer(); diff --git a/src/helper/site-utils.php b/src/helper/site-utils.php index b8d04847..f5ddaaa1 100644 --- a/src/helper/site-utils.php +++ b/src/helper/site-utils.php @@ -3,7 +3,6 @@ namespace EE\Site\Utils; use AcmePhp\Ssl\Certificate; -use AcmePhp\Ssl\Parser\CertificateParser; use EE; use EE\Model\Option; use EE\Model\Site; @@ -1239,7 +1238,7 @@ function ssl_needs_creation( $site_url ) { if ( file_exists( $certificatePath ) ) { $certificate = new Certificate( file_get_contents( $certificatePath ) ); - $certificateParser = new CertificateParser(); + $certificateParser = new \EE\Site\Type\EECertificateParser(); $parsedCertificate = $certificateParser->parse( $certificate ); // 3024000 = 35 days. From b795f55994028d046957e54f1cbbce40c18fb2ba Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 29 Sep 2026 09:43:02 +0530 Subject: [PATCH 07/27] fix(site): allow turning SSL off on sites stored as wildcard update_ssl() compared the stored wildcard flag with `--wildcard` for every change, including `--ssl=off`. Sites created with `--ssl=self` are stored with site_ssl_wildcard=1, as are wildcard LE sites, so `--ssl=off` failed with "Update from wildcard SSL to normal SSL is not supported yet." and `--ssl=off --wildcard` with "You cannot use --wildcard flag with --ssl=off": SSL could never be turned off on them. The wildcard checks now only apply when SSL is being enabled. --- src/helper/class-ee-site.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index f127930d..59f34c66 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -999,11 +999,12 @@ protected function update_ssl( $assoc_args ) { $ssl = false; } - if ( ! $this->site_data->site_ssl_wildcard && $wildcard ) { + // Only enabling SSL depends on the wildcard flag; self-signed sites are stored as wildcard, so this blocked --ssl=off for them. + if ( $ssl && ! $this->site_data->site_ssl_wildcard && $wildcard ) { EE::error( 'Update from normal SSL to wildcard SSL is not supported yet.' ); } - if ( $this->site_data->site_ssl_wildcard && ! $wildcard ) { + if ( $ssl && $this->site_data->site_ssl_wildcard && ! $wildcard ) { EE::error( 'Update from wildcard SSL to normal SSL is not supported yet.' ); } From 96fade4e28bf55f6de81ec9ee674c49ab744eb24 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 29 Sep 2026 09:47:39 +0530 Subject: [PATCH 08/27] fix(site): reload the proxy after removing a deleted site's www redirect delete_site() removed `-redirect.conf` without setting `$reload`, and the proxy had already reloaded when the site's containers went down, so nginx kept redirecting `www.` to the deleted site until some later reload. --- src/helper/class-ee-site.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index 59f34c66..5c9a0c18 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -317,10 +317,12 @@ protected function delete_site( $level, $site_url, $site_fs_path, $db_data = [] \EE\Site\Utils\remove_etc_hosts_entry( $site_url ); $config_file_path = EE_ROOT_DIR . '/services/nginx-proxy/conf.d/' . $site_url . '-redirect.conf'; + $redirect_removed = false; if ( $this->fs->exists( $config_file_path ) ) { try { $this->fs->remove( $config_file_path ); + $redirect_removed = true; } catch ( \Exception $e ) { \EE::debug( $e ); \EE::error( 'Could not remove site redirection file. Please check if you have sufficient rights.' ); @@ -342,7 +344,8 @@ protected function delete_site( $level, $site_url, $site_fs_path, $db_data = [] $conf_locations = [ $proxy_conf_location, $proxy_vhost_location, $proxy_vhost_location_subdom ]; - $reload = false; + // The proxy reloaded when the containers went down, before the www redirect was removed. + $reload = $redirect_removed; foreach ( $conf_locations as $cl ) { From b052b8082202524b532ab6015034ecc7e246b26c Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 29 Sep 2026 09:53:16 +0530 Subject: [PATCH 09/27] fix(site): fail `update --ssl=le` when no certificate was issued When the Let's Encrypt registration, order or validation failed, init_le() only warned and cleared site_ssl on the array copy, while update_ssl() saved `le` on the model and printed "Enabled SSL". The site was then recorded as an LE site without a certificate. update_ssl() now checks the result, puts the site back to HTTP-only through disable_ssl() (which also removes the failed order's ACME state), and exits non-zero without saving. --- src/helper/class-ee-site.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index 5c9a0c18..0b8286c2 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -1040,6 +1040,11 @@ protected function update_ssl( $assoc_args ) { $this->custom_site_ssl(); } $this->www_ssl_wrapper( [ 'nginx' ] ); + // init_le() only warns and clears site_ssl when the certificate could not be issued. + if ( empty( $this->site_data['site_ssl'] ) ) { + $this->disable_ssl(); + throw new \Exception( sprintf( 'SSL could not be enabled on %s, the site stays without SSL. See the warnings above.', $this->site_data['site_url'] ) ); + } } else { $this->disable_ssl(); } From 1fea072b648cd48237fad1b0dbb6ecdf2770f631 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 29 Sep 2026 09:58:44 +0530 Subject: [PATCH 10/27] fix(site): keep token-bearing ACME challenges of every type The requestOrder() override kept only http-01 and dns-01. An authorization that is already valid through another challenge type lists only that challenge, so it came back empty and authorize() refused a domain that acmephp's own code accepted. Now only challenges acmephp can't represent (no token, type, status or url, e.g. dns-persist-01) are skipped; authorize() still picks the one its solver supports, as before. --- src/helper/Site_Letsencrypt.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/helper/Site_Letsencrypt.php b/src/helper/Site_Letsencrypt.php index 772267bd..6e2c131d 100644 --- a/src/helper/Site_Letsencrypt.php +++ b/src/helper/Site_Letsencrypt.php @@ -74,9 +74,9 @@ private function getResourceAccount() } /** - * Same as acmephp's requestOrder(), but keeps only the challenge types EE can solve. + * Same as acmephp's requestOrder(), but skips challenges it can't represent. * - * acmephp 1.3 reads a token from every challenge, so one without a token (the draft dns-persist-01 that Pebble 2.10 offers) made the whole order throw. + * acmephp 1.3 reads a token from every challenge, so one without a token (the draft dns-persist-01 that Pebble 2.10 offers) made the whole order throw. Other types are kept as before: authorize() picks the one its solver supports, or a valid one. * * @param array $domains Domains to order a certificate for. * @@ -114,8 +114,8 @@ function ( $domain ) { // An empty list still reaches authorize(), which reports the domain as unsupported. $authorizationsChallenges[ $domain ] = []; foreach ( $authorizationsResponse['challenges'] as $challenge ) { - if ( ! in_array( $challenge['type'] ?? '', [ 'http-01', 'dns-01' ], true ) || empty( $challenge['token'] ) || ! is_string( $challenge['token'] ) ) { - \EE::debug( 'Skipping unsupported ACME challenge ' . ( $challenge['type'] ?? '(no type)' ) . ' for ' . $domain ); + if ( empty( $challenge['token'] ) || ! is_string( $challenge['token'] ) || ! isset( $challenge['type'], $challenge['status'], $challenge['url'] ) ) { + \EE::debug( 'Skipping ACME challenge without a token: ' . ( $challenge['type'] ?? '(no type)' ) . ' for ' . $domain ); continue; } $authorizationsChallenges[ $domain ][] = new AuthorizationChallenge( From 9b5aa02b48f2955b0d12ce694b32860490686665 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 29 Sep 2026 10:05:21 +0530 Subject: [PATCH 11/27] fix(site): revoke only certificates an alias change replaced A wildcard or DNS-01 LE site without Cloudflare credentials returns from init_le() with a "run ssl-verify" notice and no new certificate, so the alias change counted as a success and revoked the certificate still being served. Old certificates are now revoked only when a different one was stored for the same domain. --- src/helper/class-ee-site.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index 0b8286c2..c5686c68 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -673,8 +673,16 @@ protected function update_alias_domains( $args, $assoc_args ) { EE::error( sprintf( 'Alias domains of %s were not changed: %s', $site->site_url, $e->getMessage() ) ); } - // Revoke the old certificate, which the new one replaces. - $client->revokeCertificates( $old_certs ); + // Revoke only certificates a new one replaced: a DNS-01 order without Cloudflare credentials returns before issuing. + $new_certs = $client->loadDomainCertificates( $all_domains ); + $replaced = array_filter( + $old_certs, + function ( $cert, $domain ) use ( $new_certs ) { + return isset( $new_certs[ $domain ] ) && $new_certs[ $domain ]->getPEM() !== $cert->getPEM(); + }, + ARRAY_FILTER_USE_BOTH + ); + $client->revokeCertificates( $replaced ); } elseif ( 'custom' === $this->site_data['site_ssl'] ) { EE::warning( 'Custom SSL certificate is not renewed automatically. Please ensure the certificate you provided covers the updated alias-domain set.' ); } else { From 46e8ef6c60cdf7ff08d3e31872b079b2d2a61bd8 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 29 Sep 2026 10:11:03 +0530 Subject: [PATCH 12/27] fix(site): refuse `--ssl=off` while other sites inherit the certificate With `--ssl=off` allowed on wildcard sites, turning it off on a parent removed the certificate its `inherit` children load in their www redirects, and nginx could no longer load its config. The command now names those children and stops before any change. Turning SSL off also keeps the stored wildcard flag, so the site's SSL (e.g. a subdom multisite's wildcard) can be enabled again with the same flags. --- src/helper/class-ee-site.php | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index c5686c68..aadf2ba4 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -1031,10 +1031,32 @@ protected function update_ssl( $assoc_args ) { EE::error( 'You cannot use --wildcard flag with --ssl=off' ); } + // Their redirect configs load this site's certificate, which --ssl=off removes; nginx would then fail to load its config. + if ( ! $ssl ) { + $children = array_filter( + Site::all( [ 'site_url', 'site_ssl' ] ), + function ( $child ) { + return 'inherit' === $child->site_ssl && implode( '.', array_slice( explode( '.', $child->site_url ), 1 ) ) === $this->site_data->site_url; + } + ); + if ( ! empty( $children ) ) { + $names = array_map( + function ( $child ) { + return $child->site_url; + }, + $children + ); + EE::error( sprintf( 'Cannot disable SSL on %s: %s inherit its certificate. Disable SSL on them first.', $this->site_data->site_url, implode( ', ', $names ) ) ); + } + } + EE::log( 'Starting SSL update for: ' . $this->site_data->site_url ); try { - $this->site_data->site_ssl = $ssl; - $this->site_data->site_ssl_wildcard = $wildcard ? 1 : 0; + $this->site_data->site_ssl = $ssl; + // Keep the stored wildcard flag when turning SSL off, so the same SSL can be enabled again. + if ( $ssl ) { + $this->site_data->site_ssl_wildcard = $wildcard ? 1 : 0; + } $site = $this->site_data; $array_data = ( array ) $this->site_data; From 3f1759e52e33ea0e579746e3badd77ec95eccb24 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 29 Sep 2026 10:16:25 +0530 Subject: [PATCH 13/27] fix(site): put the site back HTTP-only when `update --ssl=` throws A registration or finalize exception skipped the new site_ssl check and went straight to the error, leaving the half-configured SSL state behind. Any failure while enabling SSL now runs disable_ssl() first, and the error says to re-run `ee site update --ssl=` instead of the `ssl-verify` hint, which can't work once the order is gone. --- src/helper/class-ee-site.php | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index aadf2ba4..3563f312 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -1067,13 +1067,20 @@ function ( $child ) { // www_ssl_wrapper() skips cert work for custom SSL, so validate and copy the provided pair first. if ( 'custom' === $ssl ) { $this->validate_site_custom_ssl( get_flag_value( $assoc_args, 'ssl-key' ), get_flag_value( $assoc_args, 'ssl-crt' ) ); - $this->custom_site_ssl(); } - $this->www_ssl_wrapper( [ 'nginx' ] ); - // init_le() only warns and clears site_ssl when the certificate could not be issued. - if ( empty( $this->site_data['site_ssl'] ) ) { + try { + // Inside the try, so a failure after the copy removes the copied pair again. + if ( 'custom' === $ssl ) { + $this->custom_site_ssl(); + } + $this->www_ssl_wrapper( [ 'nginx' ] ); + // init_le() only warns and clears site_ssl when the certificate could not be issued. + if ( empty( $this->site_data['site_ssl'] ) ) { + throw new \Exception( 'See the warnings above.' ); + } + } catch ( \Exception $e ) { $this->disable_ssl(); - throw new \Exception( sprintf( 'SSL could not be enabled on %s, the site stays without SSL. See the warnings above.', $this->site_data['site_url'] ) ); + throw new \Exception( sprintf( 'SSL could not be enabled on %1$s, the site stays without SSL: %2$s Fix the issue and re-run `ee site update %1$s --ssl=%3$s`.', $this->site_data['site_url'], rtrim( $e->getMessage(), '.' ) . '.', $ssl ) ); } } else { $this->disable_ssl(); From e33671a5b6400781e59e8755955e3ab92e32165f Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 29 Sep 2026 10:20:40 +0530 Subject: [PATCH 14/27] fix(site): remove a deleted alias's ACME state Removing an alias left acme-conf/var/ (its authorization challenge) behind, and since the site no longer lists the alias, deleting the site later couldn't find it either (SSL-5). Found by the le.feature "no ACME state behind" scenario on Pebble. --- src/helper/class-ee-site.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index 3563f312..3c28dab4 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -710,6 +710,11 @@ function ( $cert, $domain ) use ( $new_certs ) { } } + // A removed alias's ACME state (its authorization challenge) is no longer tracked by the site, so a later site delete can't find it. + foreach ( array_diff( $domains_to_delete, [ $this->site_data['site_url'], \EE\Site\Utils\get_www_counterpart( $this->site_data['site_url'] ) ] ) as $domain ) { + $this->fs->remove( EE_ROOT_DIR . '/services/nginx-proxy/acme-conf/var/' . $domain ); + } + if ( ! empty( $this->site_data['proxy_cache'] ) && 'on' === $this->site_data['proxy_cache'] ) { EE::log( 'As proxy cache is enabled on this site, updating config to enable it in newly added alias domains.' ); $this->site_data = get_site_info( $args, true, true, false ); From 809e092a4f8229e29fe91368e8aea373658f5ea3 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 29 Sep 2026 10:24:12 +0530 Subject: [PATCH 15/27] refactor(site): drop the unvalidated le-mail fallback from the alias update Since #496, update_alias_domains() resolves and validates le-mail for LE sites before the compose dump, and exits with an error if that fails, so the fallback before reissue_le_certificate() can't run. It also bypassed that validation. --- src/helper/class-ee-site.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index 3c28dab4..acadbafe 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -657,9 +657,7 @@ protected function update_alias_domains( $args, $assoc_args ) { // Update SSL. EE::log( 'Updating and force renewing SSL certificate to accomodated alias domain changes.' ); try { - if ( ! isset( $this->le_mail ) ) { - $this->le_mail = EE::get_config( 'le-mail' ) ?? EE::input( 'Enter your mail id: ' ); - } + // le-mail was resolved and validated before the site dropped HTTPS. $this->reissue_le_certificate( true ); } catch ( \Exception $e ) { EE::warning( 'Certificate could not be issued. Reverting back to original state.' ); From 8b3daa7144860f18d5b29f209912a0c7afc1380e Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 29 Sep 2026 10:59:07 +0530 Subject: [PATCH 16/27] fix(site): keep a custom certificate the user keeps in the certs dir when enabling SSL fails `ee site update --ssl=custom --ssl-key=/.key --ssl-crt=/.crt` is allowed (custom_site_ssl() skips copying a file onto itself), so there the files in nginx-proxy's certs dir are the user's only copy. A later failure in `update --ssl=custom` ran disable_ssl(), which removed them, and the error then asked to re-run with files that no longer existed (SSL-16). The rollback now leaves the passed key and certificate in place; a pair copied from elsewhere is still removed. --- src/helper/class-ee-site.php | 9 ++++++--- src/helper/site-utils.php | 13 +++++++++++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index acadbafe..d8fb3150 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -1082,7 +1082,8 @@ function ( $child ) { throw new \Exception( 'See the warnings above.' ); } } catch ( \Exception $e ) { - $this->disable_ssl(); + // A custom pair passed from the certs dir itself is the user's only copy. + $this->disable_ssl( 'custom' === $ssl ? [ $this->site_data['ssl_key'], $this->site_data['ssl_crt'] ] : [] ); throw new \Exception( sprintf( 'SSL could not be enabled on %1$s, the site stays without SSL: %2$s Fix the issue and re-run `ee site update %1$s --ssl=%3$s`.', $this->site_data['site_url'], rtrim( $e->getMessage(), '.' ) . '.', $ssl ) ); } } else { @@ -1108,9 +1109,11 @@ function ( $child ) { /** * Disables SSL on a site. * + * @param array $keep_files Certificate files to leave in place. + * * @throws \Exception */ - private function disable_ssl() { + private function disable_ssl( array $keep_files = [] ) { $site_url = $this->site_data['site_url']; @@ -1125,7 +1128,7 @@ private function disable_ssl() { \EE\Site\Utils\reload_global_nginx_proxy(); // Left behind, nginx-proxy would still match the certificate to this site's (and similarly named sites') hosts. - \EE\Site\Utils\remove_site_ssl_files( $site_url, $this->get_ssl_domains( $site_url ) ); + \EE\Site\Utils\remove_site_ssl_files( $site_url, $this->get_ssl_domains( $site_url ), $keep_files ); } /** diff --git a/src/helper/site-utils.php b/src/helper/site-utils.php index f5ddaaa1..a2862841 100644 --- a/src/helper/site-utils.php +++ b/src/helper/site-utils.php @@ -898,8 +898,9 @@ function get_site_ssl_file_paths( $site_url ) { * * @param string $site_url Name of the site. * @param array $domains Other domains of the site (alias domains, www variant) whose ACME state goes too. + * @param array $keep Files to leave in place, e.g. a custom pair the user keeps in the certs dir. */ -function remove_site_ssl_files( $site_url, array $domains = [] ) { +function remove_site_ssl_files( $site_url, array $domains = [], array $keep = [] ) { $proxy_dir = EE_ROOT_DIR . '/services/nginx-proxy'; $paths = [ @@ -915,7 +916,15 @@ function remove_site_ssl_files( $site_url, array $domains = [] ) { } } - $paths = array_values( array_filter( $paths, 'file_exists' ) ); + $keep = array_filter( array_map( 'realpath', array_filter( $keep ) ) ); + $paths = array_values( + array_filter( + $paths, + function ( $path ) use ( $keep ) { + return file_exists( $path ) && ! in_array( realpath( $path ), $keep, true ); + } + ) + ); if ( empty( $paths ) ) { return; } From aa4766ddea02148193ed352ea3ba73bca533233d Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 29 Sep 2026 10:59:51 +0530 Subject: [PATCH 17/27] fix(site): let a self-signed site enable SSL again after `--ssl=off` Sites created with `--ssl=self` are stored with site_ssl_wildcard=1, because self-signed certificates always cover `*.`. `--ssl=off` kept that flag, so the next `ee site update --ssl=self`, the same flags the site was created with, and `--ssl=le` both failed with "Update from wildcard SSL to normal SSL is not supported yet." unless `--wildcard` was added, which for Let's Encrypt forces DNS-01. Turning off self-signed SSL now clears the flag, except on subdomain multisites, which need a wildcard certificate whatever the SSL type. Wildcard Let's Encrypt and custom sites keep it as before. --- src/helper/class-ee-site.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index d8fb3150..99de15fb 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -1055,10 +1055,14 @@ function ( $child ) { EE::log( 'Starting SSL update for: ' . $this->site_data->site_url ); try { + $old_ssl = $this->site_data->site_ssl; $this->site_data->site_ssl = $ssl; // Keep the stored wildcard flag when turning SSL off, so the same SSL can be enabled again. if ( $ssl ) { $this->site_data->site_ssl_wildcard = $wildcard ? 1 : 0; + } elseif ( 'self' === $old_ssl && 'subdom' !== $this->site_data->app_sub_type ) { + // Self-signed certs are always wildcard, so the kept flag would refuse `--ssl=self` or `--ssl=le` without --wildcard. + $this->site_data->site_ssl_wildcard = 0; } $site = $this->site_data; From 09217cb50f6cf63f7dc4a4f4613e1173e9ff3aed Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 29 Sep 2026 11:00:11 +0530 Subject: [PATCH 18/27] fix(site): name every needed flag in the `update --ssl=` re-run hint The error of a failed `ee site update --ssl=` suggested re-running with only `--ssl=`. For `--ssl= --wildcard` that command fails with "Update from wildcard SSL to normal SSL is not supported yet.", and for `--ssl=custom` with "Pass --ssl-key and --ssl-crt for custom SSL". The hint now repeats `--wildcard` and the key and certificate paths that were passed. --- src/helper/class-ee-site.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index 99de15fb..fe461cbb 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -1088,7 +1088,11 @@ function ( $child ) { } catch ( \Exception $e ) { // A custom pair passed from the certs dir itself is the user's only copy. $this->disable_ssl( 'custom' === $ssl ? [ $this->site_data['ssl_key'], $this->site_data['ssl_crt'] ] : [] ); - throw new \Exception( sprintf( 'SSL could not be enabled on %1$s, the site stays without SSL: %2$s Fix the issue and re-run `ee site update %1$s --ssl=%3$s`.', $this->site_data['site_url'], rtrim( $e->getMessage(), '.' ) . '.', $ssl ) ); + $rerun = '--ssl=' . $ssl . ( $wildcard ? ' --wildcard' : '' ); + if ( 'custom' === $ssl ) { + $rerun .= ' --ssl-key=' . $this->site_data['ssl_key'] . ' --ssl-crt=' . $this->site_data['ssl_crt']; + } + throw new \Exception( sprintf( 'SSL could not be enabled on %1$s, the site stays without SSL: %2$s Fix the issue and re-run `ee site update %1$s %3$s`.', $this->site_data['site_url'], rtrim( $e->getMessage(), '.' ) . '.', $rerun ) ); } } else { $this->disable_ssl(); From a6074e69bdcc307c3f4c831fb7bd59e78e0916ba Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 29 Sep 2026 11:00:51 +0530 Subject: [PATCH 19/27] fix(site): remove a wildcard certificate's `*.` ACME state with the site A wildcard Let's Encrypt order stores the authorization of `*.` under `acme-conf/var/*.`, whether or not `*.` is also an alias domain. Site delete and `--ssl=off` only removed the state of the site, its alias domains and its www counterpart, so that directory stayed behind (SSL-5). It is now removed too, unless `*.` is another site's alias domain. --- src/helper/class-ee-site.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index fe461cbb..3f6dd32a 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -1140,7 +1140,7 @@ private function disable_ssl( array $keep_files = [] ) { } /** - * Domains other than the site itself that can have ACME state for its certificate: its alias domains and its www counterpart. + * Domains other than the site itself that can have ACME state for its certificate: its alias domains, its www counterpart and its wildcard name. * * @param string $site_url Name of the site. * @@ -1157,6 +1157,13 @@ private function get_ssl_domains( $site_url ) { $domains[] = $www; } + // A wildcard order stores the `*.` authorization under its own name, also when it isn't an alias. + $wildcard = '*.' . $site_url; + $parent = get_parent_of_alias( $wildcard ); + if ( empty( $parent ) || $site_url === $parent ) { + $domains[] = $wildcard; + } + return array_values( array_diff( array_unique( array_map( 'trim', $domains ) ), [ $site_url, '' ] ) ); } From b5128d39a092b85effc5b1c1cdf6ad2a11cfd624 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 29 Sep 2026 11:01:09 +0530 Subject: [PATCH 20/27] docs(site): say which files a failed certificate reissue puts back The docblocks of get_site_ssl_file_paths() and reissue_le_certificate() read as if every ACME file an issuance touches were restored. Only the served certificate, the ACME key pair, certificates and DN, and the www redirect are; authorization challenges and orders are left as they are, because the next order revokes and replaces them. --- src/helper/class-ee-site.php | 2 +- src/helper/site-utils.php | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index 3f6dd32a..1e5187ce 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -2348,7 +2348,7 @@ private function renew_ssl_cert( $args, $force ) { * * @param bool $force Whether to force renewal of cert or not. * - * @throws \Exception When no certificate was issued. The site's certificate, ACME and redirect files are put back first. + * @throws \Exception When no certificate was issued. The files from `get_site_ssl_file_paths()` are put back first. */ private function reissue_le_certificate( $force ) { diff --git a/src/helper/site-utils.php b/src/helper/site-utils.php index a2862841..a72dfe16 100644 --- a/src/helper/site-utils.php +++ b/src/helper/site-utils.php @@ -867,7 +867,9 @@ function restore_files( array $backup ) { } /** - * Paths of a site's certificate and ACME files that an LE issuance can change. + * Paths of the files a failed LE issuance must not leave changed: the served certificate, its ACME key pair, certificates and DN, and the www redirect. + * + * Authorization and order state is left out: the next order revokes and replaces it. * * @param string $site_url Name of the site. * From 8c1ed2aa8dbfa57b290cb7dee28dfb1dff608185 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 29 Sep 2026 11:11:22 +0530 Subject: [PATCH 21/27] fix(site): refuse normal SSL on a wildcard site only when it needs the wildcard aa4766d cleared the stored wildcard flag when a self-signed site turned SSL off. That let `--ssl=self` and `--ssl=le` work again, but broke the flags the site may have been created with: `--ssl=self --wildcard` and `--ssl=le --wildcard` were then refused as "Update from normal SSL to wildcard SSL", and a `*.` alias domain ended up with a flag that no longer matched it. The flag is kept again on `--ssl=off`. Instead, "Update from wildcard SSL to normal SSL is not supported yet." is only raised when a normal certificate can't cover the site: a subdomain multisite, or a site with a `*.` alias domain. Self-signed sites are stored as wildcard although they don't need it, so after `--ssl=off` they can enable SSL again with or without `--wildcard`. --- src/helper/class-ee-site.php | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index 1e5187ce..f0e9ecb2 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -1018,7 +1018,10 @@ protected function update_ssl( $assoc_args ) { EE::error( 'Update from normal SSL to wildcard SSL is not supported yet.' ); } - if ( $ssl && $this->site_data->site_ssl_wildcard && ! $wildcard ) { + // Self-signed sites are stored as wildcard too, so only refuse a normal certificate where one can't cover the site. + $alias_domains = empty( $this->site_data->alias_domains ) ? [] : explode( ',', $this->site_data->alias_domains ); + $needs_wildcard = 'subdom' === $this->site_data->app_sub_type || in_array( '*.' . $this->site_data->site_url, $alias_domains, true ); + if ( $ssl && $this->site_data->site_ssl_wildcard && ! $wildcard && $needs_wildcard ) { EE::error( 'Update from wildcard SSL to normal SSL is not supported yet.' ); } @@ -1055,14 +1058,10 @@ function ( $child ) { EE::log( 'Starting SSL update for: ' . $this->site_data->site_url ); try { - $old_ssl = $this->site_data->site_ssl; $this->site_data->site_ssl = $ssl; // Keep the stored wildcard flag when turning SSL off, so the same SSL can be enabled again. if ( $ssl ) { $this->site_data->site_ssl_wildcard = $wildcard ? 1 : 0; - } elseif ( 'self' === $old_ssl && 'subdom' !== $this->site_data->app_sub_type ) { - // Self-signed certs are always wildcard, so the kept flag would refuse `--ssl=self` or `--ssl=le` without --wildcard. - $this->site_data->site_ssl_wildcard = 0; } $site = $this->site_data; From a9372b43aff851bf9176ea6ec2baebf71daad5c7 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 29 Sep 2026 12:47:17 +0530 Subject: [PATCH 22/27] fix(site): stop before changing files when a backup can't be read backup_files() recorded a failed read as the file's content, so a later rollback would replace the file with an empty one. It now throws before the caller changes anything. --- src/helper/site-utils.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/helper/site-utils.php b/src/helper/site-utils.php index a72dfe16..3515b11c 100644 --- a/src/helper/site-utils.php +++ b/src/helper/site-utils.php @@ -836,12 +836,19 @@ function test_global_nginx_proxy_config( $retry_any_failure = false ) { * @param array $paths Absolute file paths. * * @return array Path => content, or null for a file that does not exist. + * + * @throws \Exception When an existing file can't be read. */ function backup_files( array $paths ) { $backup = []; foreach ( array_unique( $paths ) as $path ) { - $backup[ $path ] = is_file( $path ) ? file_get_contents( $path ) : null; + $content = is_file( $path ) ? file_get_contents( $path ) : null; + // Restoring a failed read would overwrite the file with an empty one. + if ( false === $content ) { + throw new \Exception( "Could not read $path to back it up." ); + } + $backup[ $path ] = $content; } return $backup; From 5c34be874d23fb132bf955dab199ead226e030af Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 29 Sep 2026 12:54:54 +0530 Subject: [PATCH 23/27] fix(site): treat self-signed SSL as wildcard when updating a site Self-signed certificates always cover *., but update --ssl=self stored the wildcard flag from --wildcard, so it saved 0 without the flag, and the wildcard checks refused --ssl=self on a subdomain multisite or a site with a *. alias after --ssl=off unless --wildcard was repeated. --ssl=self now skips both checks and stores the flag as 1, as create does. --- src/helper/class-ee-site.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index f0e9ecb2..8ecc1c6a 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -1014,14 +1014,15 @@ protected function update_ssl( $assoc_args ) { } // Only enabling SSL depends on the wildcard flag; self-signed sites are stored as wildcard, so this blocked --ssl=off for them. - if ( $ssl && ! $this->site_data->site_ssl_wildcard && $wildcard ) { + // Self-signed certificates are always wildcard, so neither check applies to them. + if ( $ssl && 'self' !== $ssl && ! $this->site_data->site_ssl_wildcard && $wildcard ) { EE::error( 'Update from normal SSL to wildcard SSL is not supported yet.' ); } // Self-signed sites are stored as wildcard too, so only refuse a normal certificate where one can't cover the site. $alias_domains = empty( $this->site_data->alias_domains ) ? [] : explode( ',', $this->site_data->alias_domains ); $needs_wildcard = 'subdom' === $this->site_data->app_sub_type || in_array( '*.' . $this->site_data->site_url, $alias_domains, true ); - if ( $ssl && $this->site_data->site_ssl_wildcard && ! $wildcard && $needs_wildcard ) { + if ( $ssl && 'self' !== $ssl && $this->site_data->site_ssl_wildcard && ! $wildcard && $needs_wildcard ) { EE::error( 'Update from wildcard SSL to normal SSL is not supported yet.' ); } @@ -1061,7 +1062,7 @@ function ( $child ) { $this->site_data->site_ssl = $ssl; // Keep the stored wildcard flag when turning SSL off, so the same SSL can be enabled again. if ( $ssl ) { - $this->site_data->site_ssl_wildcard = $wildcard ? 1 : 0; + $this->site_data->site_ssl_wildcard = ( $wildcard || 'self' === $ssl ) ? 1 : 0; } $site = $this->site_data; From 3df337e8c8e18757bed9aa31fa661f080530b40f Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 29 Sep 2026 13:32:21 +0530 Subject: [PATCH 24/27] fix(site): restore the proxy cache files when writing them fails The backup was only restored after a failed nginx -t. If a write threw part-way (for example a full disk), the command exited with the zone or some locations already written and the site still recorded without proxy cache, so the next proxy reload applied a partial cache config. Restore the backup before rethrowing. --- src/helper/class-ee-site.php | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index 8ecc1c6a..d57d7098 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -836,13 +836,19 @@ protected function update_proxy_cache( $args, $assoc_args, $call_on_create = fal $written_files = $force ? $location_files : array_merge( [ $proxy_conf_location ], $location_files ); $backup = \EE\Site\Utils\backup_files( $written_files ); - if ( ! $force ) { - $this->fs->dumpFile( $proxy_conf_location, $proxy_conf_content ); - } + try { + if ( ! $force ) { + $this->fs->dumpFile( $proxy_conf_location, $proxy_conf_content ); + } - $proxy_vhost_content = \EE\Utils\mustache_render( SITE_TEMPLATE_ROOT . '/config/nginx-proxy/vhost_location.conf.mustache', $data ); - foreach ( $location_files as $location_file ) { - $this->fs->dumpFile( $location_file, $proxy_vhost_content ); + $proxy_vhost_content = \EE\Utils\mustache_render( SITE_TEMPLATE_ROOT . '/config/nginx-proxy/vhost_location.conf.mustache', $data ); + foreach ( $location_files as $location_file ) { + $this->fs->dumpFile( $location_file, $proxy_vhost_content ); + } + } catch ( \Exception $e ) { + // A write that fails part-way must not leave the files already written for the next reload. + \EE\Site\Utils\restore_files( $backup ); + throw $e; } } else { $reload = false; From 4d549a54e7e4432a71e4f7f7a74a230b465c5639 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 29 Sep 2026 13:32:22 +0530 Subject: [PATCH 25/27] fix(site): show the first SAN as the subject in ssl-info when there is no CN ssl-info re-read the subject CN from the raw certificate and showed an empty "issued to" for certificates without one, dropping the parser's first-SAN fallback. --- src/helper/class-ee-site.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index d57d7098..1d5344a9 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -2171,7 +2171,8 @@ public function ssl_info( $args, $assoc_args ) { $crt_pem = file_get_contents( $crt_file ); if ( function_exists( 'openssl_x509_parse' ) ) { $cert_data = openssl_x509_parse( $crt_pem ); - $subjectCN = isset( $cert_data['subject']['CN'] ) ? $cert_data['subject']['CN'] : ''; + // Without a subject CN, the parser's subject is the first SAN. + $subjectCN = isset( $cert_data['subject']['CN'] ) ? $cert_data['subject']['CN'] : (string) $subject; $issuer_full = isset( $cert_data['issuer'] ) ? $cert_data['issuer'] : []; $le_found = false; foreach ( $issuer_full as $field => $value ) { From 0fbab4d920d9c31f015c204d5a4f416db30bba2a Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 29 Sep 2026 13:32:22 +0530 Subject: [PATCH 26/27] fix(site): quote the certificate paths in the update --ssl=custom re-run hint A key or certificate path with spaces or shell characters made the suggested command unusable as printed. --- src/helper/class-ee-site.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index 1d5344a9..c50b27e5 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -1096,7 +1096,7 @@ function ( $child ) { $this->disable_ssl( 'custom' === $ssl ? [ $this->site_data['ssl_key'], $this->site_data['ssl_crt'] ] : [] ); $rerun = '--ssl=' . $ssl . ( $wildcard ? ' --wildcard' : '' ); if ( 'custom' === $ssl ) { - $rerun .= ' --ssl-key=' . $this->site_data['ssl_key'] . ' --ssl-crt=' . $this->site_data['ssl_crt']; + $rerun .= ' --ssl-key=' . escapeshellarg( $this->site_data['ssl_key'] ) . ' --ssl-crt=' . escapeshellarg( $this->site_data['ssl_crt'] ); } throw new \Exception( sprintf( 'SSL could not be enabled on %1$s, the site stays without SSL: %2$s Fix the issue and re-run `ee site update %1$s %3$s`.', $this->site_data['site_url'], rtrim( $e->getMessage(), '.' ) . '.', $rerun ) ); } From 38c68ff11674f9f93b26d9dde15c46a919c3723c Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 29 Sep 2026 16:02:50 +0530 Subject: [PATCH 27/27] fix(site): don't restart the proxy when the reload refused the new config --- src/helper/class-ee-site.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index c50b27e5..eab6869d 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -873,8 +873,8 @@ protected function update_proxy_cache( $args, $assoc_args, $call_on_create = fal } $test = \EE\Site\Utils\test_global_nginx_proxy_config( true ); - if ( 0 === $test->return_code ) { - \EE\Site\Utils\reload_global_nginx_proxy(); + // The reload regenerates and tests again; restarting after it refused would load a config nginx rejected. + if ( 0 === $test->return_code && false !== \EE\Site\Utils\reload_global_nginx_proxy() ) { // A changed cache zone needs a fresh nginx master; alias changes only add locations, which the reload applies. if ( ! $force ) { EE::exec( 'docker restart ' . EE_PROXY_TYPE );