diff --git a/src/helper/Site_Letsencrypt.php b/src/helper/Site_Letsencrypt.php index 7285f4fd..6e2c131d 100644 --- a/src/helper/Site_Letsencrypt.php +++ b/src/helper/Site_Letsencrypt.php @@ -19,14 +19,17 @@ 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; 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; @@ -70,6 +73,65 @@ private function getResourceAccount() return $this->account; } + /** + * 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. 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. + * + * @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 ( 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( + $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 = [ @@ -97,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; @@ -668,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 ) { @@ -699,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. @@ -749,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 cd9f01de..eab6869d 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 ) { @@ -388,21 +391,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.' ); @@ -627,8 +617,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 ) { @@ -657,23 +648,39 @@ 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 ] ); + // 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.' ); + // 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 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 { @@ -682,9 +689,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' ] ); @@ -696,6 +700,19 @@ 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 ); + } + } + + // 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 ); @@ -791,6 +808,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 +824,39 @@ 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 ); - } - - $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 ); - + // 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'] ) { - $this->fs->dumpFile( $proxy_vhost_location_subdom, $proxy_vhost_content ); + $location_files[] = $proxy_vhost_location_subdom; } - foreach ( $alias_domains as $ad ) { + $location_files[] = EE_ROOT_DIR . '/services/nginx-proxy/vhost.d/' . $ad . '_location'; + } + + $written_files = $force ? $location_files : array_merge( [ $proxy_conf_location ], $location_files ); + $backup = \EE\Site\Utils\backup_files( $written_files ); + + try { + if ( ! $force ) { + $this->fs->dumpFile( $proxy_conf_location, $proxy_conf_content ); + } - $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 ); + } + } 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; - $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 +866,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 ); + // 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 ); + } + } 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(); - EE::exec( 'docker restart ' . EE_PROXY_TYPE ); + $failed = true; } else { - $this->fs->remove( $proxy_conf_location ); - $this->fs->remove( $proxy_vhost_location ); - $this->fs->remove( $proxy_vhost_location_subdom ); - \EE\Site\Utils\reload_global_nginx_proxy(); + // 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(); @@ -978,11 +1019,16 @@ 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. + // 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.' ); } - if ( $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 && 'self' !== $ssl && $this->site_data->site_ssl_wildcard && ! $wildcard && $needs_wildcard ) { EE::error( 'Update from wildcard SSL to normal SSL is not supported yet.' ); } @@ -998,10 +1044,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 || 'self' === $ssl ) ? 1 : 0; + } $site = $this->site_data; $array_data = ( array ) $this->site_data; @@ -1012,9 +1080,26 @@ protected function update_ssl( $assoc_args ) { // 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' ] ); + 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 ) { + // 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'] ] : [] ); + $rerun = '--ssl=' . $ssl . ( $wildcard ? ' --wildcard' : '' ); + if ( 'custom' === $ssl ) { + $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 ) ); + } } else { $this->disable_ssl(); } @@ -1038,14 +1123,54 @@ protected function update_ssl( $assoc_args ) { /** * 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']; $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 ), $keep_files ); + } + + /** + * 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. + * + * @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; + } + + // 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, '' ] ) ); } /** @@ -2033,7 +2158,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(); @@ -2046,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 ) { @@ -2148,11 +2274,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.' ); } @@ -2207,9 +2347,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 files from `get_site_ssl_file_paths()` 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 eaa512ca..3515b11c 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; @@ -793,11 +792,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 +803,161 @@ 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. + * + * @throws \Exception When an existing file can't be read. + */ +function backup_files( array $paths ) { + + $backup = []; + foreach ( array_unique( $paths ) as $path ) { + $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; +} + +/** + * 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 ); + } + } +} + +/** + * 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. + * + * @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. + * @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 = [], array $keep = [] ) { + + $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"; + } + } + + $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; + } + + \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. */ @@ -1105,7 +1256,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.