From bac7174de17c23c3956615f5dac118a5054b3b6b Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Fri, 23 Oct 2026 21:08:58 +0530 Subject: [PATCH 01/10] fix(ssl): keep a site on plain HTTP when its certificate could not be issued When Let's Encrypt registration, authorization or validation failed during `ee site create --ssl=le`, init_le() cleared site_ssl, but www_ssl_wrapper() still wrote the compose file without HTTPS_METHOD=nohttps. nginx-proxy then served the site over HTTPS with the closest matching certificate of another site (a name-mismatch error in browsers), while the site was saved with SSL not enabled. The wrapper now writes the compose file with nohttps, keeps the HTTP-only www redirect and skips the HTTPS redirect when no certificate was issued, and says how to enable SSL once the issue is fixed. Renewals are unchanged: their caller restores the previous certificate, which keeps serving. --- src/helper/class-ee-site.php | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index f9dd4ff2..d5ded3be 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -1653,7 +1653,15 @@ protected function www_ssl_wrapper( $containers_to_start = [], $site_enable = fa * we don't need to create or get new certs. We can use the existing ones. */ if ( $force || EE\Site\Utils\ssl_needs_creation( $this->site_data['site_url'] ) ) { - $this->init_ssl( $this->site_data['site_url'], $this->site_data['site_fs_path'], $this->site_data['site_ssl'], $wildcard, $is_www_or_non_www_pointed, $force, $alias_domains ); + $ssl_type = $this->site_data['site_ssl']; + $this->init_ssl( $this->site_data['site_url'], $this->site_data['site_fs_path'], $ssl_type, $wildcard, $is_www_or_non_www_pointed, $force, $alias_domains ); + + // init_le() only warns and clears site_ssl. A renewal keeps serving the old certificate, which its caller restores. + if ( empty( $this->site_data['site_ssl'] ) && ! $renew ) { + $this->serve_without_https( $ssl_type, $containers_to_start ); + + return; + } } } @@ -1670,6 +1678,27 @@ protected function www_ssl_wrapper( $containers_to_start = [], $site_enable = fa } } + /** + * Keeps a site whose certificate could not be issued on plain HTTP. + * + * With HTTPS enabled in its compose file, nginx-proxy would serve the site over HTTPS with the closest matching + * certificate of another site, while EasyEngine records the site without SSL. + * + * @param string $ssl_type The SSL type that was requested. + * @param array $containers_to_start Containers to start for the site. Empty starts all. + */ + private function serve_without_https( $ssl_type, $containers_to_start ) { + + $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'], $containers_to_start ); + \EE\Site\Utils\reload_global_nginx_proxy(); + + $wildcard = empty( $this->site_data['site_ssl_wildcard'] ) ? '' : ' --wildcard'; + EE::warning( sprintf( 'SSL could not be enabled on %1$s, so it is served over HTTP only. Once the issue is fixed, enable SSL with `ee site update %1$s --ssl=%2$s%3$s`.', $site_url, $ssl_type, $wildcard ) ); + } + /** * Runs the acme le registration and authorization. * From 8836deec7a1ebf9079a701cd6180361abe1b7a34 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Fri, 23 Oct 2026 21:09:10 +0530 Subject: [PATCH 02/10] fix(ssl): wait for the DNS-01 records on the zone's authoritative nameservers With the Cloudflare DNS solver, EasyEngine waited a fixed 10 seconds after adding the TXT records and then asked Let's Encrypt to validate. Until a new `_acme-challenge.www.` record is served everywhere, a zone with a wildcard record answers that name with the wildcard's target, e.g. the apex's TXT records, so validation of the www name failed with "Incorrect TXT record". The fixed wait is replaced by polling: the nameservers of the zone that holds each record are looked up, and each of their IPv4 addresses is queried directly, without recursion, until all of them serve the expected value, with backoff and a 120 second limit. On timeout the validation is tried anyway, as before, and the records and nameservers still missing are named. Rebuilding a stale order in check() waits the same way. The queries use a small UDP client with an EDNS0 buffer and a TCP retry for truncated answers, so no new dependency is needed. --- src/helper/SimpleDnsCloudflareSolver.php | 15 ++ src/helper/Site_Letsencrypt.php | 24 ++- src/helper/class-ee-site.php | 2 +- src/helper/site-utils.php | 262 +++++++++++++++++++++++ 4 files changed, 301 insertions(+), 2 deletions(-) diff --git a/src/helper/SimpleDnsCloudflareSolver.php b/src/helper/SimpleDnsCloudflareSolver.php index 81f71208..4a18ae35 100644 --- a/src/helper/SimpleDnsCloudflareSolver.php +++ b/src/helper/SimpleDnsCloudflareSolver.php @@ -33,6 +33,11 @@ class SimpleDnsCloudflareSolver implements SolverInterface { */ protected $zones; + /** + * @var array TXT values this solver added, by record name without the trailing dot. + */ + private $created_records = []; + /** * @param DnsDataExtractor $extractor * @param OutputInterface $output @@ -71,6 +76,7 @@ public function solve( AuthorizationChallenge $authorizationChallenge ) { try { if ( $this->dns->addRecord( $zoneID, "TXT", $recordName, $recordValue, 0, false ) === true ) { EE::log( "Created DNS record: $recordName with value $recordValue." . PHP_EOL ); + $this->created_records[ rtrim( $recordName, '.' ) ][] = $recordValue; } else { $manual = true; } @@ -104,6 +110,15 @@ public function solve( AuthorizationChallenge $authorizationChallenge ) { } } + /** + * TXT records this solver added through the Cloudflare API. + * + * @return array Expected values by record name. + */ + public function get_created_records() { + return $this->created_records; + } + /** * {@inheritdoc} */ diff --git a/src/helper/Site_Letsencrypt.php b/src/helper/Site_Letsencrypt.php index 4189ca4e..b9ebf977 100644 --- a/src/helper/Site_Letsencrypt.php +++ b/src/helper/Site_Letsencrypt.php @@ -210,6 +210,11 @@ class Site_Letsencrypt { private $repository; private $conf_dir; + /** + * @var array TXT values the last authorize() published through the DNS API, by record name. + */ + private $published_dns_records = []; + function __construct() { $this->conf_dir = EE_ROOT_DIR . '/services/nginx-proxy/acme-conf'; $this->setRepository(); @@ -324,7 +329,8 @@ public function authorize( Array $domains, $wildcard = false, $preferred_challen } else { $solver = new SimpleHttpSolver(); } - $solverName = $is_solver_dns ? 'dns-01' : 'http-01'; + $solverName = $is_solver_dns ? 'dns-01' : 'http-01'; + $this->published_dns_records = []; try { $order = $this->client->requestOrder( $domains ); } catch ( \Exception $e ) { @@ -389,11 +395,24 @@ public function authorize( Array $domains, $wildcard = false, $preferred_challen } } + if ( $solver instanceof SimpleDnsCloudflareSolver ) { + $this->published_dns_records = $solver->get_created_records(); + } + $this->repository->storeCertificateOrder( $domains, $order ); return true; } + /** + * TXT records the last authorize() published through the DNS API, to wait for before validation. + * + * @return array Expected values by record name. + */ + public function getPublishedDnsRecords() { + return $this->published_dns_records; + } + public function revokeAuthorizationChallenges( array $domains ) { foreach ( $domains as $domain ) { if ( $this->repository->hasDomainAuthorizationChallenge( $domain ) ) { @@ -522,6 +541,9 @@ public function check( Array $domains, $wildcard = false, $preferred_challenge = return false; } + if ( $is_solver_dns ) { + \EE\Site\Utils\wait_for_dns_txt_records( $this->published_dns_records ); + } $order = $this->repository->loadCertificateOrder( $domains ); } diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index d5ded3be..db98c50c 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -1866,7 +1866,7 @@ protected function init_le( $site_url, $site_fs_path, $wildcard = false, $www_or } else { if ( ! $api_key_absent && $is_solver_dns ) { EE::log( 'Waiting for DNS entry propagation.' ); - sleep( 10 ); + \EE\Site\Utils\wait_for_dns_txt_records( $client->getPublishedDnsRecords() ); } if ( ! $this->ssl_verify( [], [ 'force' => $force ], $www_or_non_www ) ) { $this->site_data['site_ssl'] = null; diff --git a/src/helper/site-utils.php b/src/helper/site-utils.php index a4766b19..7971e420 100644 --- a/src/helper/site-utils.php +++ b/src/helper/site-utils.php @@ -1400,6 +1400,268 @@ function ssl_needs_creation( $site_url ) { return true; } +/** + * Waits until every authoritative nameserver of each record's zone serves the expected TXT values. + * + * The nameservers are queried directly: a recursive resolver can keep serving what it cached before the record existed, + * such as the records a wildcard matched. + * + * @param array $records Expected TXT values by record name. + * @param int $timeout Seconds to wait in total. + * @param array $nameservers Nameservers to query by record name, as [ ip => name ], instead of looking them up. + * + * @return bool True once every nameserver serves every value, false when they don't within $timeout. + */ +function wait_for_dns_txt_records( array $records, $timeout = 120, array $nameservers = [] ) { + + $deadline = microtime( true ) + $timeout; + $pending = []; + foreach ( $records as $name => $values ) { + $servers = isset( $nameservers[ $name ] ) ? $nameservers[ $name ] : get_authoritative_nameservers( $name ); + if ( empty( $servers ) ) { + EE::warning( "Could not find the authoritative nameservers of $name, so its propagation can't be checked." ); + continue; + } + foreach ( $servers as $ip => $server ) { + $pending[] = [ $name, $ip, $server ]; + } + } + + $delay = 2; + while ( ! empty( $pending ) ) { + foreach ( $pending as $key => list( $name, $ip ) ) { + $served = query_txt_records( $ip, $name ); + if ( null !== $served && ! array_diff( $records[ $name ], $served ) ) { + EE::debug( "$name serves the DNS challenge value on {$pending[ $key ][2]} ($ip)." ); + unset( $pending[ $key ] ); + } + if ( microtime( true ) >= $deadline ) { + break; + } + } + $left = $deadline - microtime( true ); + if ( empty( $pending ) || $left <= 0 ) { + break; + } + usleep( (int) ( min( $delay, $left ) * 1000000 ) ); + // sleep()/usleep() don't run pending signal handlers on their own; the caller has no declare(ticks). + if ( function_exists( 'pcntl_signal_dispatch' ) ) { + pcntl_signal_dispatch(); + } + $delay = min( $delay * 2, 16 ); + } + + if ( empty( $pending ) ) { + return true; + } + + $missing = array_map( + function ( $entry ) { + return sprintf( '%s on %s (%s)', $entry[0], $entry[2], $entry[1] ); + }, + $pending + ); + EE::warning( sprintf( 'The DNS challenge value is not yet served after %ds for: %s. Trying the validation anyway.', $timeout, implode( ', ', $missing ) ) ); + + return false; +} + +/** + * Finds the authoritative nameservers of the zone that holds a name. + * + * @param string $name A domain name. + * + * @return array Nameserver names by IPv4 address, empty when none are found. + */ +function get_authoritative_nameservers( $name ) { + + $labels = explode( '.', rtrim( $name, '.' ) ); + // The closest enclosing zone cut; a name a wildcard matches is answered with its target's records, so require the queried owner. + while ( count( $labels ) > 1 ) { + $zone = implode( '.', $labels ); + $servers = []; + foreach ( (array) @dns_get_record( $zone, DNS_NS ) as $record ) { + if ( isset( $record['type'], $record['host'], $record['target'] ) && 'NS' === $record['type'] && 0 === strcasecmp( rtrim( $record['host'], '.' ), $zone ) ) { + foreach ( (array) gethostbynamel( $record['target'] ) as $ip ) { + if ( $ip ) { + $servers[ $ip ] = $record['target']; + } + } + } + } + if ( ! empty( $servers ) ) { + return $servers; + } + array_shift( $labels ); + } + + return []; +} + +/** + * Asks one nameserver, without recursion, for the TXT records of a name. + * + * @param string $server Nameserver IPv4 address. + * @param string $name Record name. + * @param int $timeout Seconds to wait for the answer. + * + * @return array|null The TXT values in the answer (empty for NXDOMAIN), null when there is no usable answer. + */ +function query_txt_records( $server, $name, $timeout = 3 ) { + + $qname = ''; + foreach ( explode( '.', rtrim( $name, '.' ) ) as $label ) { + if ( '' === $label || strlen( $label ) > 63 ) { + return null; + } + $qname .= chr( strlen( $label ) ) . $label; + } + $id = random_int( 0, 0xffff ); + // One question (TXT, IN), no RD flag, and an EDNS0 OPT record so a large answer fits in one UDP packet. + $query = pack( 'n6', $id, 0, 1, 0, 0, 1 ) . $qname . "\0" . pack( 'n2', 16, 1 ) . "\0" . pack( 'n2Nn', 41, 1232, 0, 0 ); + + $answer = parse_dns_txt_answer( dns_exchange( $server, $query, false, $timeout ), $id ); + if ( 'truncated' === $answer ) { + $answer = parse_dns_txt_answer( dns_exchange( $server, $query, true, $timeout ), $id ); + } + + return is_array( $answer ) ? $answer : null; +} + +/** + * Sends a DNS query to port 53 and returns the response matching its ID. + * + * @param string $server Nameserver IPv4 address. + * @param string $query Wire-format query. + * @param bool $tcp Use TCP instead of UDP. + * @param int $timeout Seconds to wait. + * + * @return string The response, or an empty string. + */ +function dns_exchange( $server, $query, $tcp, $timeout ) { + + $deadline = microtime( true ) + $timeout; + $socket = @stream_socket_client( ( $tcp ? 'tcp' : 'udp' ) . "://$server:53", $errno, $errstr, $timeout ); + if ( ! $socket ) { + return ''; + } + fwrite( $socket, $tcp ? pack( 'n', strlen( $query ) ) . $query : $query ); + + $response = ''; + $expected = null; + while ( ( $left = $deadline - microtime( true ) ) > 0 ) { + $read = [ $socket ]; + $write = null; + $except = null; + if ( ! @stream_select( $read, $write, $except, (int) $left, (int) ( fmod( $left, 1 ) * 1000000 ) ) ) { + break; + } + $chunk = fread( $socket, 65535 ); + if ( false === $chunk || ( '' === $chunk && feof( $socket ) ) ) { + break; + } + if ( ! $tcp ) { + // Skip a stray datagram, such as a late answer to an earlier query. + if ( strlen( $chunk ) >= 2 && substr( $chunk, 0, 2 ) === substr( $query, 0, 2 ) ) { + $response = $chunk; + break; + } + continue; + } + $response .= $chunk; + if ( null === $expected && strlen( $response ) >= 2 ) { + $expected = unpack( 'n', $response )[1]; + } + if ( null !== $expected && strlen( $response ) >= $expected + 2 ) { + $response = substr( $response, 2, $expected ); + break; + } + } + fclose( $socket ); + + return ( $tcp && null !== $expected && strlen( $response ) !== $expected ) ? '' : $response; +} + +/** + * Reads the TXT values from the answer section of a DNS response. + * + * @param string $response Wire-format response. + * @param int $id ID of the query. + * + * @return array|string|null TXT values, 'truncated' when the answer needs TCP, or null for an unusable response. + */ +function parse_dns_txt_answer( $response, $id ) { + + $length = strlen( $response ); + if ( $length < 12 ) { + return null; + } + $header = unpack( 'nid/nflags/nqd/nan', $response ); + if ( $header['id'] !== $id || ! ( $header['flags'] & 0x8000 ) ) { + return null; + } + if ( $header['flags'] & 0x0200 ) { + return 'truncated'; + } + $rcode = $header['flags'] & 0x000f; + if ( 3 === $rcode ) { + return []; + } + if ( 0 !== $rcode ) { + return null; + } + + $skip_name = function ( $offset ) use ( $response, $length ) { + while ( $offset < $length ) { + $label = ord( $response[ $offset ] ); + if ( 0 === $label ) { + return $offset + 1; + } + if ( 0xc0 === ( $label & 0xc0 ) ) { + return $offset + 2; + } + $offset += $label + 1; + } + + return null; + }; + + $offset = 12; + for ( $i = 0; $i < $header['qd']; $i++ ) { + $offset = $skip_name( $offset ); + if ( null === $offset ) { + return null; + } + $offset += 4; + } + + $values = []; + for ( $i = 0; $i < $header['an']; $i++ ) { + $offset = $skip_name( $offset ); + if ( null === $offset || $offset + 10 > $length ) { + return null; + } + $record = unpack( 'ntype/nclass/Nttl/nlength', substr( $response, $offset, 10 ) ); + $offset += 10; + if ( $offset + $record['length'] > $length ) { + return null; + } + // A wildcard match can also put a CNAME and its target's records in the answer; only TXT data counts. + if ( 16 === $record['type'] ) { + $rdata = substr( $response, $offset, $record['length'] ); + $value = ''; + for ( $pos = 0; $pos < $record['length']; $pos += $size + 1 ) { + $size = ord( $rdata[ $pos ] ); + $value .= substr( $rdata, $pos + 1, $size ); + } + $values[] = $value; + } + $offset += $record['length']; + } + + return $values; +} + /** * Get a new available subnet. * From 56e4f57b13667eec7cd540f057eba7cb57ad9dea Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Fri, 23 Oct 2026 21:09:15 +0530 Subject: [PATCH 03/10] fix(ssl): keep the www redirect when only the HTTPS hop of the www check fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The www check fetches a random file through the www name and follows redirects. On a site that is already redirected to HTTPS but has no matching certificate yet, the chain http://www → http:// → https:// failed certificate verification, so the check decided www was not pointed at the server, removed the site's www redirect and issued the certificate without it. The check now follows a redirect to HTTPS without verifying the certificate, as Let's Encrypt's HTTP-01 validator does. The random file content still has to come back, which is what proves the name reaches this site. --- 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 db98c50c..70b87259 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -1978,6 +1978,10 @@ protected function check_www_or_non_www_domain( $site_url, $site_path, $site_con curl_setopt( $curl, CURLOPT_URL, $site_url ); curl_setopt( $curl, CURLOPT_RETURNTRANSFER, true ); curl_setopt( $curl, CURLOPT_FOLLOWLOCATION, true ); + // Like Let's Encrypt's HTTP-01 validator, follow a redirect to HTTPS without validating its certificate: it may not exist yet + // (or nginx-proxy serves another site's), and the random content alone proves the request reached this site. + curl_setopt( $curl, CURLOPT_SSL_VERIFYPEER, false ); + curl_setopt( $curl, CURLOPT_SSL_VERIFYHOST, 0 ); curl_setopt( $curl, CURLOPT_HEADER, false ); $data = curl_exec( $curl ); $http_code = curl_getinfo( $curl, CURLINFO_HTTP_CODE ); From 6f4e02beb89fe1b2747be9ff481f0c168d52f6cf Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Fri, 23 Oct 2026 21:09:15 +0530 Subject: [PATCH 04/10] fix(ssl): print the DNS record name once in the Cloudflare cleanup messages --- src/helper/SimpleDnsCloudflareSolver.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/helper/SimpleDnsCloudflareSolver.php b/src/helper/SimpleDnsCloudflareSolver.php index 4a18ae35..40bc0b55 100644 --- a/src/helper/SimpleDnsCloudflareSolver.php +++ b/src/helper/SimpleDnsCloudflareSolver.php @@ -131,12 +131,12 @@ public function cleanup( AuthorizationChallenge $authorizationChallenge ) { foreach ( $record_ids as $record_id ) { if ( $this->dns->deleteRecord( $zoneID, $record_id ) ) { - EE::log( "Cleaned up DNS record: _acme-challenge.$recordName" ); + EE::log( "Cleaned up DNS record: $recordName" ); } else { $this->output->writeln( sprintf( <<<'EOF' - You can now cleanup your DNS by removing the domain _acme-challenge.%s. + You can now cleanup your DNS by removing the domain %s EOF , $recordName From 7e30209ef5c7347704e3b62b07bc2bbe65d9923e Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Fri, 23 Oct 2026 21:33:06 +0530 Subject: [PATCH 05/10] fix(ssl): keep a 10 second minimum DNS-01 wait and ask for recursion in the nameserver queries Every authoritative nameserver of a zone can answer from the anycast site nearest to this server, while Let's Encrypt validates from several regions, so the time the fixed wait used to give is kept as a minimum once the values are served. The queries set RD: authoritative servers ignore it, and a network that redirects port 53 to its own resolver would otherwise only answer from its cache. Pending signal handlers now run after each query. --- src/helper/site-utils.php | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/helper/site-utils.php b/src/helper/site-utils.php index 7971e420..01cf5a3e 100644 --- a/src/helper/site-utils.php +++ b/src/helper/site-utils.php @@ -1409,12 +1409,17 @@ function ssl_needs_creation( $site_url ) { * @param array $records Expected TXT values by record name. * @param int $timeout Seconds to wait in total. * @param array $nameservers Nameservers to query by record name, as [ ip => name ], instead of looking them up. + * @param int $min_wait Seconds to wait at least once the values are served. * * @return bool True once every nameserver serves every value, false when they don't within $timeout. */ -function wait_for_dns_txt_records( array $records, $timeout = 120, array $nameservers = [] ) { +function wait_for_dns_txt_records( array $records, $timeout = 120, array $nameservers = [], $min_wait = 10 ) { - $deadline = microtime( true ) + $timeout; + if ( empty( $records ) ) { + return true; + } + $start = microtime( true ); + $deadline = $start + $timeout; $pending = []; foreach ( $records as $name => $values ) { $servers = isset( $nameservers[ $name ] ) ? $nameservers[ $name ] : get_authoritative_nameservers( $name ); @@ -1435,6 +1440,9 @@ function wait_for_dns_txt_records( array $records, $timeout = 120, array $namese EE::debug( "$name serves the DNS challenge value on {$pending[ $key ][2]} ($ip)." ); unset( $pending[ $key ] ); } + if ( function_exists( 'pcntl_signal_dispatch' ) ) { + pcntl_signal_dispatch(); + } if ( microtime( true ) >= $deadline ) { break; } @@ -1444,14 +1452,16 @@ function wait_for_dns_txt_records( array $records, $timeout = 120, array $namese break; } usleep( (int) ( min( $delay, $left ) * 1000000 ) ); - // sleep()/usleep() don't run pending signal handlers on their own; the caller has no declare(ticks). - if ( function_exists( 'pcntl_signal_dispatch' ) ) { - pcntl_signal_dispatch(); - } $delay = min( $delay * 2, 16 ); } if ( empty( $pending ) ) { + // Anycast nameservers answer from the site nearest to this server; give the others the time the fixed wait used to. + $left = $start + $min_wait - microtime( true ); + if ( $left > 0 ) { + usleep( (int) ( $left * 1000000 ) ); + } + return true; } @@ -1517,8 +1527,9 @@ function query_txt_records( $server, $name, $timeout = 3 ) { $qname .= chr( strlen( $label ) ) . $label; } $id = random_int( 0, 0xffff ); - // One question (TXT, IN), no RD flag, and an EDNS0 OPT record so a large answer fits in one UDP packet. - $query = pack( 'n6', $id, 0, 1, 0, 0, 1 ) . $qname . "\0" . pack( 'n2', 16, 1 ) . "\0" . pack( 'n2Nn', 41, 1232, 0, 0 ); + // One question (TXT, IN) and an EDNS0 OPT record so a large answer fits in one UDP packet. Authoritative servers ignore RD, + // but a network that redirects port 53 to its own resolver needs it. + $query = pack( 'n6', $id, 0x0100, 1, 0, 0, 1 ) . $qname . "\0" . pack( 'n2', 16, 1 ) . "\0" . pack( 'n2Nn', 41, 1232, 0, 0 ); $answer = parse_dns_txt_answer( dns_exchange( $server, $query, false, $timeout ), $id ); if ( 'truncated' === $answer ) { From 4141b892a86aefee937ab1913a7baecd746b51fe Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Fri, 23 Oct 2026 23:14:14 +0530 Subject: [PATCH 06/10] fix(ssl): keep a manual DNS-01 site on HTTP until ssl-verify issues its certificate Without a Cloudflare API key, a DNS-01 create or `update --ssl=le` saves the site as Let's Encrypt and returns before any certificate exists, but still wrote the compose file with HTTPS on. nginx-proxy then redirected the site to HTTPS and served it with the closest matching certificate of another site until `ee site ssl-verify` succeeded. Such a site now stays on plain HTTP: `site_ssl` stays `le`, so ssl-verify, ssl-info and delete keep working on it, and a missing certificate marks it as pending (`is_le_ssl_pending()`). www_ssl_wrapper() writes the compose file with `nohttps` for it, and site refresh/enable, the PHP version update and alias domain updates keep `nohttps` while it is pending. Once a standalone `ee site ssl-verify` issues the certificate, it turns HTTPS on (compose file, nginx container, HTTPS www redirect when www points here) and calls the new `site_scheme_changed()` hook, so site types that store the site URL can switch it. `update --ssl=le` on a pending site points to ssl-verify instead of saying SSL is already enabled, and a pending update no longer reports SSL as enabled. --- src/helper/class-ee-site.php | 86 +++++++++++++++++++++++++++++------- src/helper/site-utils.php | 16 +++++++ 2 files changed, 85 insertions(+), 17 deletions(-) diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index 70b87259..943eca6b 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -616,10 +616,11 @@ 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; + $pending_le = \EE\Site\Utils\is_le_ssl_pending( $this->site_data['site_url'], $this->site_data['site_ssl'] ); $preferred_ssl_challenge = get_preferred_ssl_challenge( get_domains_of_site( $this->site_data['site_url'] ) ); // 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 ); + $nohttps = ! $is_ssl || $pending_le || ( '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 ) { @@ -929,7 +930,7 @@ protected function update_php( $args, $assoc_args ) { try { $old_php_version = $this->site_data->php_version; $this->site_data->php_version = $php_version; - $no_https = $this->site_data->site_ssl ? false : true; + $no_https = ! $this->site_data->site_ssl || \EE\Site\Utils\is_le_ssl_pending( $this->site_data->site_url, $this->site_data->site_ssl ); $site = $this->site_data; $array_data = ( array ) $this->site_data; $this->site_data = reset( $array_data ); @@ -1033,6 +1034,9 @@ protected function update_ssl( $assoc_args ) { } if ( $this->site_data->site_ssl && $ssl ) { + if ( \EE\Site\Utils\is_le_ssl_pending( $this->site_data->site_url, $this->site_data->site_ssl ) ) { + EE::error( sprintf( 'Let\'s Encrypt SSL on %1$s waits for its DNS challenge. Run `ee site ssl-verify %1$s` once the DNS records have propagated.', $this->site_data->site_url ) ); + } EE::error( 'SSL is already enabled on ' . $this->site_data->site_url ); } @@ -1111,10 +1115,12 @@ function ( $child ) { $site->save(); - if ( $ssl ) { - EE::success( 'Enabled SSL for ' . $this->site_data['site_url'] ); - } else { + if ( ! $ssl ) { EE::success( 'Disabled SSL for ' . $this->site_data['site_url'] ); + } elseif ( \EE\Site\Utils\is_le_ssl_pending( $this->site_data['site_url'], $ssl ) ) { + EE::success( sprintf( 'Let\'s Encrypt SSL set up for %s, pending DNS verification.', $this->site_data['site_url'] ) ); + } else { + EE::success( 'Enabled SSL for ' . $this->site_data['site_url'] ); } delem_log( 'site ssl update end' ); @@ -1223,7 +1229,7 @@ public function enable( $args, $assoc_args, $exit_on_error = true ) { } if ( $refresh ) { - $no_https = $this->site_data->site_ssl ? false : true; + $no_https = ! $this->site_data->site_ssl || \EE\Site\Utils\is_le_ssl_pending( $this->site_data->site_url, $this->site_data->site_ssl ); $site = $this->site_data; $array_data = ( array ) $this->site_data; $this->site_data = reset( $array_data ); @@ -1658,7 +1664,17 @@ protected function www_ssl_wrapper( $containers_to_start = [], $site_enable = fa // init_le() only warns and clears site_ssl. A renewal keeps serving the old certificate, which its caller restores. if ( empty( $this->site_data['site_ssl'] ) && ! $renew ) { - $this->serve_without_https( $ssl_type, $containers_to_start ); + $this->serve_without_https( $containers_to_start ); + $wildcard_flag = empty( $this->site_data['site_ssl_wildcard'] ) ? '' : ' --wildcard'; + EE::warning( sprintf( 'SSL could not be enabled on %1$s, so it is served over HTTP only. Once the issue is fixed, enable SSL with `ee site update %1$s --ssl=%2$s%3$s`.', $this->site_data['site_url'], $ssl_type, $wildcard_flag ) ); + + return; + } + + // A manual DNS-01 challenge keeps site_ssl without a certificate until `ee site ssl-verify` completes it. + if ( \EE\Site\Utils\is_le_ssl_pending( $this->site_data['site_url'], $this->site_data['site_ssl'] ) ) { + $this->serve_without_https( $containers_to_start ); + EE::log( sprintf( '%1$s is served over HTTP until `ee site ssl-verify %1$s` succeeds.', $this->site_data['site_url'] ) ); return; } @@ -1679,24 +1695,53 @@ protected function www_ssl_wrapper( $containers_to_start = [], $site_enable = fa } /** - * Keeps a site whose certificate could not be issued on plain HTTP. + * Keeps a site without a certificate on plain HTTP. * * With HTTPS enabled in its compose file, nginx-proxy would serve the site over HTTPS with the closest matching - * certificate of another site, while EasyEngine records the site without SSL. + * certificate of another site. * - * @param string $ssl_type The SSL type that was requested. - * @param array $containers_to_start Containers to start for the site. Empty starts all. + * @param array $containers_to_start Containers to start for the site. Empty starts all. */ - private function serve_without_https( $ssl_type, $containers_to_start ) { - - $site_url = $this->site_data['site_url']; + private function serve_without_https( $containers_to_start ) { $this->dump_docker_compose_yml( [ 'nohttps' => true ] ); \EE\Site\Utils\start_site_containers( $this->site_data['site_fs_path'], $containers_to_start ); \EE\Site\Utils\reload_global_nginx_proxy(); + } + + /** + * Turns HTTPS on for a site whose pending Let's Encrypt certificate `ee site ssl-verify` just issued. + */ + private function enable_verified_https() { - $wildcard = empty( $this->site_data['site_ssl_wildcard'] ) ? '' : ' --wildcard'; - EE::warning( sprintf( 'SSL could not be enabled on %1$s, so it is served over HTTP only. Once the issue is fixed, enable SSL with `ee site update %1$s --ssl=%2$s%3$s`.', $site_url, $ssl_type, $wildcard ) ); + $site_url = $this->site_data['site_url']; + + try { + $this->dump_docker_compose_yml( [ 'nohttps' => false ] ); + // A disabled site gets HTTPS once it is enabled again. + if ( ! empty( $this->site_data['site_enabled'] ) ) { + \EE\Site\Utils\start_site_containers( $this->site_data['site_fs_path'], [ 'nginx' ] ); + } + } catch ( \Exception $e ) { + EE::error( sprintf( 'The certificate for %1$s was issued, but HTTPS could not be enabled: %2$s Fix the issue and run `ee site refresh %1$s`.', $site_url, $e->getMessage() ) ); + } + + // www_ssl_wrapper() kept the HTTP-only www redirect only when www pointed here (always for wildcard sites). + if ( $this->site_data['site_ssl_wildcard'] || $this->fs->exists( EE_ROOT_DIR . '/services/nginx-proxy/conf.d/' . $site_url . '-redirect.conf' ) ) { + \EE\Site\Utils\add_site_redirects( $site_url, true, false ); + } + + $this->site_scheme_changed( 'https' ); + EE::log( sprintf( 'HTTPS is enabled for %s.', $site_url ) ); + } + + /** + * Called when a site starts being served over another scheme outside `ee site update --ssl`, e.g. once `ee site ssl-verify` + * issued its pending certificate. Site types that store the site's URL override it. + * + * @param string $scheme The scheme the site is served over now: http or https. + */ + protected function site_scheme_changed( $scheme ) { } /** @@ -1862,7 +1907,7 @@ protected function init_le( $site_url, $site_fs_path, $wildcard = false, $www_or } $api_key_absent = empty( get_config_value( 'cloudflare-api-key' ) ); if ( $is_solver_dns && $api_key_absent ) { - echo \cli\Colors::colorize( '%YIMPORTANT:%n Run `ee site ssl-verify ' . $site_url . '` once the DNS changes have propagated to complete the certification generation and installation.', null ); + echo \cli\Colors::colorize( '%YIMPORTANT:%n Run `ee site ssl-verify ' . $site_url . '` once the DNS changes have propagated to complete the certification generation and installation.', null ) . PHP_EOL; } else { if ( ! $api_key_absent && $is_solver_dns ) { EE::log( 'Waiting for DNS entry propagation.' ); @@ -2043,6 +2088,9 @@ public function ssl_verify( $args = [], $assoc_args = [], $www_or_non_www = fals EE::error( 'SSL verification is only applicable to Let\'s Encrypt certificates.' ); } + // A manual DNS-01 create or update left the site on HTTP until this command issues its certificate. + $enable_https = ! $called_by_ee && \EE\Site\Utils\is_le_ssl_pending( $this->site_data['site_url'], $this->site_data['site_ssl'] ); + if ( ! isset( $this->le_mail ) ) { try { $this->le_mail = $this->get_validated_le_mail( \EE::get_config( 'le-mail' ) ); @@ -2082,6 +2130,10 @@ public function ssl_verify( $args = [], $assoc_args = [], $www_or_non_www = fals $client->cleanup(); } + if ( $enable_https ) { + $this->enable_verified_https(); + } + reload_global_nginx_proxy(); EE::success( 'SSL verification completed.' ); diff --git a/src/helper/site-utils.php b/src/helper/site-utils.php index 01cf5a3e..0dc5298e 100644 --- a/src/helper/site-utils.php +++ b/src/helper/site-utils.php @@ -1365,6 +1365,22 @@ function remove_etc_hosts_entry( $site_url ) { $fs->dumpFile( '/etc/hosts', $hosts_file_new ); } +/** + * Whether a Let's Encrypt site still waits for its first certificate, e.g. for `ee site ssl-verify` after a manual DNS-01 challenge. + * + * Such a site is served over HTTP only: with HTTPS on, nginx-proxy would serve it with the closest matching certificate of another site. + * + * @param string $site_url Name of the site. + * @param string|null $site_ssl The site's SSL type. + * + * @return bool + */ +function is_le_ssl_pending( $site_url, $site_ssl ) { + $cert = EE_SERVICE_DIR . '/nginx-proxy/certs/' . $site_url; + + return 'le' === $site_ssl && ! ( file_exists( "$cert.crt" ) && file_exists( "$cert.key" ) ); +} + /** * Checks if the site certificate needs renewal. * From 8c5ba6440a41253c50aef01574644bbb1275ae26 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Fri, 23 Oct 2026 23:14:20 +0530 Subject: [PATCH 07/10] fix(ssl): point a failed Let's Encrypt setup to the right retry command After a failed create, init_le() and the ACME client suggested `ee site ssl-verify`, which can't work there: the site has no SSL then. The site-level messages now come from the caller: a create says to run `ee site update --ssl=le`, `update --ssl=le` says to re-run itself, a renewal keeps the current certificate. A standalone ssl-verify still prints its own retry hint, and the manual DNS-01 "run ssl-verify once the DNS changes have propagated" message is unchanged. A failed `update --ssl=le` printed the create-time "served over HTTP only" warning and then update_ssl()'s own error, and rolled the site back twice. www_ssl_wrapper() now leaves both to update_ssl(). --- src/helper/Site_Letsencrypt.php | 8 ------- src/helper/class-ee-site.php | 40 +++++++++++++++++++++++---------- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/src/helper/Site_Letsencrypt.php b/src/helper/Site_Letsencrypt.php index b9ebf977..54cf2ffe 100644 --- a/src/helper/Site_Letsencrypt.php +++ b/src/helper/Site_Letsencrypt.php @@ -340,7 +340,6 @@ public function authorize( Array $domains, $wildcard = false, $preferred_challen } else { \EE::warning( 'Let\'s Encrypt order request failed (' . $e->getMessage() . '). It seems you\'re in local environment or using non-public domain, please check logs. Skipping letsencrypt.' ); } - \EE::log( 'You can fix the issue and re-run: ee site ssl-verify ' . $domains[0] ); return false; } @@ -362,7 +361,6 @@ public function authorize( Array $domains, $wildcard = false, $preferred_challen } if ( null === $authorizationChallenge ) { \EE::warning( 'Challenge not supported for domain ' . $domainKey ); - \EE::log( 'You can fix the issue and re-run: ee site ssl-verify ' . $domainKey ); return false; } @@ -583,7 +581,6 @@ public function check( Array $domains, $wildcard = false, $preferred_challenge = \EE::debug( sprintf( 'Testing the challenge for domain %s', $domain ) ); if ( ! $validator->isValid( $authorizationChallenge ) ) { \EE::warning( 'Can not validate challenge for domain ' . $domain ); - \EE::log( 'You can fix the issue and re-run: ee site ssl-verify ' . $domain ); return false; } @@ -595,11 +592,6 @@ public function check( Array $domains, $wildcard = false, $preferred_challenge = \EE::debug( $e->getMessage() ); \EE::warning( 'Challenge Authorization failed (' . $e->getMessage() . '). Check logs and check if your domain is pointed correctly to this server.' ); - $site_name = $domains[0]; - $site_name = str_replace( '*.', '', $site_name ); - - \EE::log( "Re-run `ee site ssl-verify $site_name` after fixing the issue." ); - return false; } } diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index 943eca6b..36dbfc9f 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -59,6 +59,11 @@ abstract class EE_Site_Command { */ private $le_mail; + /** + * @var bool $ssl_failure_handled_by_caller Set while update_ssl() runs www_ssl_wrapper(): it rolls back and reports a failed certificate itself. + */ + private $ssl_failure_handled_by_caller = false; + /** * @var resource $ssl_lock_handle Handle of the process-wide SSL lock. * @@ -1090,7 +1095,12 @@ function ( $child ) { if ( 'custom' === $ssl ) { $this->custom_site_ssl(); } - $this->www_ssl_wrapper( [ 'nginx' ] ); + $this->ssl_failure_handled_by_caller = true; + try { + $this->www_ssl_wrapper( [ 'nginx' ] ); + } finally { + $this->ssl_failure_handled_by_caller = false; + } // 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.' ); @@ -1664,9 +1674,11 @@ protected function www_ssl_wrapper( $containers_to_start = [], $site_enable = fa // init_le() only warns and clears site_ssl. A renewal keeps serving the old certificate, which its caller restores. if ( empty( $this->site_data['site_ssl'] ) && ! $renew ) { - $this->serve_without_https( $containers_to_start ); - $wildcard_flag = empty( $this->site_data['site_ssl_wildcard'] ) ? '' : ' --wildcard'; - EE::warning( sprintf( 'SSL could not be enabled on %1$s, so it is served over HTTP only. Once the issue is fixed, enable SSL with `ee site update %1$s --ssl=%2$s%3$s`.', $this->site_data['site_url'], $ssl_type, $wildcard_flag ) ); + if ( ! $this->ssl_failure_handled_by_caller ) { + $this->serve_without_https( $containers_to_start ); + $wildcard_flag = empty( $this->site_data['site_ssl_wildcard'] ) ? '' : ' --wildcard'; + EE::warning( sprintf( 'SSL could not be enabled on %1$s, so it is served over HTTP only. Once the issue is fixed, enable SSL with `ee site update %1$s --ssl=%2$s%3$s`.', $this->site_data['site_url'], $ssl_type, $wildcard_flag ) ); + } return; } @@ -1901,7 +1913,8 @@ protected function init_le( $site_url, $site_fs_path, $wildcard = false, $www_or if ( ! $client->authorize( $domains, $wildcard, $preferred_challenge ) ) { $this->site_data['site_ssl'] = null; - \EE::warning( 'SSL authorization failed. Site will be created without SSL. You can fix the issue and re-run: ee site ssl-verify ' . $site_url ); + // The caller says how to retry: ssl-verify can't, the site has no SSL now. + \EE::warning( 'SSL authorization failed.' ); return; } @@ -1913,9 +1926,9 @@ protected function init_le( $site_url, $site_fs_path, $wildcard = false, $www_or EE::log( 'Waiting for DNS entry propagation.' ); \EE\Site\Utils\wait_for_dns_txt_records( $client->getPublishedDnsRecords() ); } + // ssl_verify() reports why; the caller says how to retry. if ( ! $this->ssl_verify( [], [ 'force' => $force ], $www_or_non_www ) ) { $this->site_data['site_ssl'] = null; - \EE::warning( 'SSL verification failed. You can fix the issue and re-run: ee site ssl-verify ' . $site_url ); return; } @@ -2111,12 +2124,15 @@ public function ssl_verify( $args = [], $assoc_args = [], $www_or_non_www = fals $is_solver_dns = ( $this->site_data['site_ssl_wildcard'] || 'dns' === $preferred_challenge ) ? true : false; $api_key_present = ! empty( get_config_value( 'cloudflare-api-key' ) ); - $warning = ( $is_solver_dns && $api_key_present ) - ? "The dns entries have not yet propogated. Manually check: \nhost -t TXT _acme-challenge." . $this->site_data['site_url'] . "\nBefore retrying `ee site ssl " . $this->site_data['site_url'] . "`" - : 'Failed to verify SSL.'; - - EE::warning( $warning ); - EE::warning( sprintf( 'Check logs and retry `ee site ssl-verify %s` once the issue is resolved.', $this->site_data['site_url'] ) ); + if ( $is_solver_dns && $api_key_present ) { + EE::warning( 'The DNS records have not propagated yet. Check them with `host -t TXT _acme-challenge.' . $this->site_data['site_url'] . '`.' ); + } elseif ( ! $called_by_ee ) { + EE::warning( 'Failed to verify SSL.' ); + } + // During a create, update or renewal, the caller says how to retry. + if ( ! $called_by_ee ) { + EE::warning( sprintf( 'Check logs and retry `ee site ssl-verify %s` once the issue is resolved.', $this->site_data['site_url'] ) ); + } return false; } From 169ca006598d7931cb51722dd4270dd9b3a92f29 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Fri, 23 Oct 2026 23:30:27 +0530 Subject: [PATCH 08/10] fix(ssl): switch a pending site's URLs when a renewal issues its certificate, and refuse to inherit from it A pending manual DNS-01 site can also get its first certificate from `ee site ssl-renew` or an alias domain update, e.g. once a Cloudflare API key is set. www_ssl_wrapper() then turns HTTPS on, but only a standalone ssl-verify told the site type, so WordPress kept its http URLs. reissue_le_certificate() now calls site_scheme_changed() too when the site was pending before and is issued now. A site can't inherit the certificate of a pending parent: it would get HTTPS without any certificate to serve. --- 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 36dbfc9f..99c2fc23 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -1730,10 +1730,7 @@ private function enable_verified_https() { try { $this->dump_docker_compose_yml( [ 'nohttps' => false ] ); - // A disabled site gets HTTPS once it is enabled again. - if ( ! empty( $this->site_data['site_enabled'] ) ) { - \EE\Site\Utils\start_site_containers( $this->site_data['site_fs_path'], [ 'nginx' ] ); - } + \EE\Site\Utils\start_site_containers( $this->site_data['site_fs_path'], [ 'nginx' ] ); } catch ( \Exception $e ) { EE::error( sprintf( 'The certificate for %1$s was issued, but HTTPS could not be enabled: %2$s Fix the issue and run `ee site refresh %1$s`.', $site_url, $e->getMessage() ) ); } @@ -1779,6 +1776,10 @@ protected function check_parent_site_certs( $site_url ) { if ( ! $parent_site->site_ssl_wildcard ) { throw new \Exception( "Cannot inherit from $parent_site_name as site does not have wildcard SSL cert" ); } + + if ( \EE\Site\Utils\is_le_ssl_pending( $parent_site_name, $parent_site->site_ssl ) ) { + throw new \Exception( "Cannot inherit from $parent_site_name as its Let's Encrypt certificate waits for `ee site ssl-verify $parent_site_name`" ); + } } /** @@ -2090,7 +2091,7 @@ public function ssl_verify( $args = [], $assoc_args = [], $www_or_non_www = fals $this->site_data = get_site_info( $args ); } - // A failed LE setup (init_le) clears site_ssl and suggests ssl-verify; point to the command that enables SSL properly. + // A failed LE setup (init_le) clears site_ssl; point to the command that enables SSL properly. if ( empty( $this->site_data['site_ssl'] ) ) { $wildcard_flag = empty( $this->site_data['site_ssl_wildcard'] ) ? '' : ' --wildcard'; EE::error( sprintf( 'SSL is not enabled on %1$s. Enable Let\'s Encrypt SSL with `ee site update %1$s --ssl=le%2$s`.', $this->site_data['site_url'], $wildcard_flag ) ); @@ -2490,6 +2491,7 @@ private function renew_ssl_cert( $args, $force ) { 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'] ) ); + $was_pending = \EE\Site\Utils\is_le_ssl_pending( $this->site_data['site_url'], $this->site_data['site_ssl'] ); $postfix_exists = \EE_DOCKER::service_exists( 'postfix', $this->site_data['site_fs_path'] ); $containers_to_start = $postfix_exists ? [ 'nginx', 'postfix' ] : [ 'nginx' ]; @@ -2507,6 +2509,11 @@ private function reissue_le_certificate( $force ) { } reload_global_nginx_proxy(); + + // A renewal or alias update can issue the first certificate of a pending site, e.g. once a Cloudflare key is set. + if ( $was_pending && ! \EE\Site\Utils\is_le_ssl_pending( $this->site_data['site_url'], $this->site_data['site_ssl'] ) ) { + $this->site_scheme_changed( 'https' ); + } } /** From 200e6fab27b965fff48234b442cabc528a574414 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Sat, 24 Oct 2026 00:17:59 +0530 Subject: [PATCH 09/10] fix(ssl): keep a pending site on HTTP when its renewal fails, and switch its URLs when HTTPS activation fails after issuance A pending site has no previous certificate for a failed `ssl-renew --force` to keep serving, but www_ssl_wrapper() still turned HTTPS on for it, as for any renewal. It now fails closed when no certificate exists. When ssl-verify issued the certificate but the compose file or the nginx start failed, the HTTPS redirect and site_scheme_changed() were skipped. ssl-verify won't see the site as pending again and the suggested refresh doesn't call the hook, so WordPress kept its http URLs. Both now run before the error. --- src/helper/class-ee-site.php | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index 99c2fc23..0a797f9b 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -1673,8 +1673,11 @@ protected function www_ssl_wrapper( $containers_to_start = [], $site_enable = fa $this->init_ssl( $this->site_data['site_url'], $this->site_data['site_fs_path'], $ssl_type, $wildcard, $is_www_or_non_www_pointed, $force, $alias_domains ); // init_le() only warns and clears site_ssl. A renewal keeps serving the old certificate, which its caller restores. - if ( empty( $this->site_data['site_ssl'] ) && ! $renew ) { - if ( ! $this->ssl_failure_handled_by_caller ) { + if ( empty( $this->site_data['site_ssl'] ) && ( ! $renew || \EE\Site\Utils\is_le_ssl_pending( $this->site_data['site_url'], 'le' ) ) ) { + if ( $renew ) { + // A pending site has no certificate to keep serving. + $this->serve_without_https( $containers_to_start ); + } elseif ( ! $this->ssl_failure_handled_by_caller ) { $this->serve_without_https( $containers_to_start ); $wildcard_flag = empty( $this->site_data['site_ssl_wildcard'] ) ? '' : ' --wildcard'; EE::warning( sprintf( 'SSL could not be enabled on %1$s, so it is served over HTTP only. Once the issue is fixed, enable SSL with `ee site update %1$s --ssl=%2$s%3$s`.', $this->site_data['site_url'], $ssl_type, $wildcard_flag ) ); @@ -1728,11 +1731,12 @@ private function enable_verified_https() { $site_url = $this->site_data['site_url']; + $error = ''; try { $this->dump_docker_compose_yml( [ 'nohttps' => false ] ); \EE\Site\Utils\start_site_containers( $this->site_data['site_fs_path'], [ 'nginx' ] ); } catch ( \Exception $e ) { - EE::error( sprintf( 'The certificate for %1$s was issued, but HTTPS could not be enabled: %2$s Fix the issue and run `ee site refresh %1$s`.', $site_url, $e->getMessage() ) ); + $error = $e->getMessage(); } // www_ssl_wrapper() kept the HTTP-only www redirect only when www pointed here (always for wildcard sites). @@ -1740,7 +1744,13 @@ private function enable_verified_https() { \EE\Site\Utils\add_site_redirects( $site_url, true, false ); } + // Also on error: ssl-verify won't see the site as pending again, and the refresh suggested below doesn't call the hook. $this->site_scheme_changed( 'https' ); + + if ( $error ) { + EE::error( sprintf( 'The certificate for %1$s was issued, but HTTPS could not be enabled: %2$s Fix the issue and run `ee site refresh %1$s`.', $site_url, $error ) ); + } + EE::log( sprintf( 'HTTPS is enabled for %s.', $site_url ) ); } From 9208ca553ae89825872f39de64bbe480bc28f8ca Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Sat, 24 Oct 2026 00:27:57 +0530 Subject: [PATCH 10/10] fix(ssl): put a pending site back on HTTP when a renewal fails after issuing its first certificate If the container start fails after www_ssl_wrapper() issued a pending site's first certificate during a renewal or alias update, reissue_le_certificate() restores the certificate files, which removes that certificate, but the compose file already served HTTPS. The rollback now writes the HTTP-only compose file again. --- src/helper/class-ee-site.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index 0a797f9b..bf753983 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -2512,8 +2512,17 @@ private function reissue_le_certificate( $force ) { 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 ) { + $issued = $was_pending && ! \EE\Site\Utils\is_le_ssl_pending( $this->site_data['site_url'], 'le' ); $this->site_data['site_ssl'] = 'le'; \EE\Site\Utils\restore_files( $backup ); + // The restore removed the first certificate of a pending site, but the compose file already serves HTTPS. + if ( $issued ) { + try { + $this->serve_without_https( $containers_to_start ); + } catch ( \Exception $rollback ) { + EE::warning( sprintf( 'Could not put %s back on HTTP: %s', $this->site_data['site_url'], $rollback->getMessage() ) ); + } + } reload_global_nginx_proxy(); throw $e; }