diff --git a/composer.json b/composer.json index cbe79ce7..e3d9172c 100644 --- a/composer.json +++ b/composer.json @@ -39,9 +39,10 @@ "require": { "acmephp/core": "1.0.0", "ext-openssl": "*", - "guzzlehttp/guzzle": "^6.0", - "league/flysystem": "^1.0.19", - "symfony/serializer": "^3.0", - "webmozart/assert": "^1.0" + "guzzlehttp/guzzle": "6.3.3", + "league/flysystem": "1.0.49", + "symfony/serializer": "3.4.20", + "webmozart/assert": "1.3.0", + "cloudflare/sdk": "1.1.1" } } diff --git a/src/helper/SimpleDnsCloudflareSolver.php b/src/helper/SimpleDnsCloudflareSolver.php new file mode 100644 index 00000000..f46fb9de --- /dev/null +++ b/src/helper/SimpleDnsCloudflareSolver.php @@ -0,0 +1,192 @@ +extractor = null === $extractor ? new DnsDataExtractor() : $extractor; + $this->output = null === $output ? new NullOutput() : $output; + $key = new \Cloudflare\API\Auth\APIKey( get_config_value( 'le-mail' ), get_config_value( 'cloudflare-api-key' ) ); + $adapter = new \Cloudflare\API\Adapter\Guzzle( $key ); + $this->dns = new \Cloudflare\API\Endpoints\DNS( $adapter ); + $this->zones = new \Cloudflare\API\Endpoints\Zones( $adapter ); + + + } + + /** + * {@inheritdoc} + */ + public function supports( AuthorizationChallenge $authorizationChallenge ) { + return 'dns-01' === $authorizationChallenge->getType(); + } + + /** + * {@inheritdoc} + */ + public function solve( AuthorizationChallenge $authorizationChallenge ) { + $recordName = $this->extractor->getRecordName( $authorizationChallenge ); + $recordValue = $this->extractor->getRecordValue( $authorizationChallenge ); + + $zone_guess = $this->get_zone_name( $authorizationChallenge->getDomain() ); + $manual = empty( $zone_guess ) ? true : false; + + if ( ! $manual ) { + $zoneID = $this->zones->getZoneID( $zone_guess ); + + if ( $this->dns->addRecord( $zoneID, "TXT", $recordName, $recordValue, 0, false ) === true ) { + EE::log( "Created DNS record: $recordName with value $recordValue." . PHP_EOL ); + } else { + $manual = true; + } + } + + if ( $manual ) { + + EE::log( "Couldn't add dns record using cloudlfare API. Re-check the config values of `le-mail` and `cloudflare-api-key`." ); + + $this->output->writeln( + sprintf( + <<<'EOF' + Add the following TXT record to your DNS zone + Domain: %s + TXT value: %s + + Wait for the propagation before moving to the next step + Tips: Use the following command to check the propagation + + host -t TXT %s +EOF + , + $recordName, + $recordValue, + $recordName + ) + ); + } + } + + /** + * {@inheritdoc} + */ + public function cleanup( AuthorizationChallenge $authorizationChallenge ) { + + $recordName = $this->extractor->getRecordName( $authorizationChallenge ); + $zone = $this->get_zone_name( $authorizationChallenge->getDomain() ); + $zoneID = $this->zones->getZoneID( $zone ); + $record_ids = $this->get_record_id( $authorizationChallenge->getDomain(), $recordName, 'TXT' ); + + foreach ( $record_ids as $record_id ) { + if ( $this->dns->deleteRecord( $zoneID, $record_id ) ) { + EE::log( "Cleaned up DNS record: _acme-challenge.$recordName" ); + } else { + $this->output->writeln( + sprintf( + <<<'EOF' + You can now cleanup your DNS by removing the domain _acme-challenge.%s. +EOF + , + $recordName + ) + ); + } + } + } + + /** + * Function to get zone name of clouflare account from the given domain name. + * Guessing zone name using the method in: + * https://github.com/certbot/certbot/blob/f90561012241171ed8e0dd9996c703c384357eba/certbot/plugins/dns_common.py#L31 + * https://github.com/certbot/certbot/blob/f90561012241171ed8e0dd9996c703c384357eba/certbot-dns-cloudflare/certbot_dns_cloudflare/dns_cloudflare.py#L131 + * + * @param string $domain domain name. + * + * @return string found zone name. + */ + private function get_zone_name( $domain ) { + + $zone_guess = ''; + $possible_zones = []; + $zone_list = []; + + foreach ( $this->zones->listZones()->result as $zone ) { + $zone_list[] = $zone->name; + } + + $guesses = explode( '.', $domain ); + do { + $possible_zones[] = implode( '.', $guesses ); + array_shift( $guesses ); + } while ( ! empty( $guesses ) ); + + foreach ( $possible_zones as $possible_zone ) { + if ( in_array( $possible_zone, $zone_list, true ) ) { + $zone_guess = $possible_zone; + + return $zone_guess; + } + } + + return $zone_guess; + } + + /** + * Function to get record id of cloudflare for a give record name and type. + * + * @param string $domain + * @param $record_name + * @param $record_type + * + * @return array of found record ids. + * @throws \Cloudflare\API\Endpoints\EndpointException + */ + private function get_record_id( $domain, $record_name, $record_type ) { + + $zone = $this->get_zone_name( $domain ); + $zoneID = $this->zones->getZoneID( $zone ); + $record_id = []; + $record_name = rtrim( $record_name, '.' ); + + foreach ( $this->dns->listRecords( $zoneID )->result as $record ) { + if ( ( $record->name === $record_name ) && ( $record->type === $record_type ) ) { + $record_id[] = $record->id; + } + } + + return $record_id; + } +} diff --git a/src/helper/Site_Letsencrypt.php b/src/helper/Site_Letsencrypt.php index 90489c93..568b8904 100644 --- a/src/helper/Site_Letsencrypt.php +++ b/src/helper/Site_Letsencrypt.php @@ -9,6 +9,7 @@ use AcmePhp\Core\Challenge\ChainValidator; use AcmePhp\Core\Challenge\Dns\DnsValidator; use AcmePhp\Core\Challenge\Dns\SimpleDnsSolver; +use AcmePhp\Core\Challenge\Dns\SimpleDnsCloudflareSolver; use AcmePhp\Core\Challenge\Http\HttpValidator; use AcmePhp\Core\Challenge\Http\SimpleHttpSolver; use AcmePhp\Core\Challenge\WaitingValidator; @@ -33,6 +34,7 @@ use Symfony\Component\Serializer\Normalizer\GetSetMethodNormalizer; use Symfony\Component\Serializer\Serializer; use function EE\Site\Utils\reload_global_nginx_proxy; +use function EE\Utils\get_config_value; class Site_Letsencrypt { @@ -139,8 +141,12 @@ public function register( $email ) { */ public function authorize( Array $domains, $wildcard = false, $preferred_challenge = '' ) { $is_solver_dns = ( $wildcard || 'dns' === $preferred_challenge ) ? true : false; - $solver = $is_solver_dns ? new SimpleDnsSolver( null, new ConsoleOutput() ) : new SimpleHttpSolver(); - $solverName = $is_solver_dns ? 'dns-01' : 'http-01'; + if ( $is_solver_dns ) { + $solver = empty ( get_config_value( 'cloudflare-api-key' ) ) ? new SimpleDnsSolver( null, new ConsoleOutput() ) : new SimpleDnsCloudflareSolver( null, new ConsoleOutput() ); + } else { + $solver = new SimpleHttpSolver(); + } + $solverName = $is_solver_dns ? 'dns-01' : 'http-01'; try { $order = $this->client->requestOrder( $domains ); } catch ( \Exception $e ) { @@ -199,7 +205,11 @@ public function authorize( Array $domains, $wildcard = false, $preferred_challen public function check( Array $domains, $wildcard = false, $preferred_challenge = '' ) { $is_solver_dns = ( $wildcard || 'dns' === $preferred_challenge ) ? true : false; \EE::debug( ( 'Starting check with solver ' ) . ( $is_solver_dns ? 'dns' : 'http' ) ); - $solver = $is_solver_dns ? new SimpleDnsSolver( null, new ConsoleOutput() ) : new SimpleHttpSolver(); + if ( $is_solver_dns ) { + $solver = empty ( get_config_value( 'cloudflare-api-key' ) ) ? new SimpleDnsSolver( null, new ConsoleOutput() ) : new SimpleDnsCloudflareSolver( null, new ConsoleOutput() ); + } else { + $solver = new SimpleHttpSolver(); + } $validator = new ChainValidator( [ new WaitingValidator( new HttpValidator() ), @@ -254,7 +264,7 @@ public function check( Array $domains, $wildcard = false, $preferred_challenge = $site_name = isset( $domains[1] ) ? $domains[1] : $domains[0]; $site_name = str_replace( '*.', '', $site_name ); - + \EE::log( "Re-run `ee site ssl $site_name` after fixing the issue." ); throw $e; } diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index 16a76848..efacfe2c 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -9,6 +9,7 @@ use function EE\Utils\download; use function EE\Utils\extract_zip; use function EE\Utils\get_flag_value; +use function EE\Utils\get_config_value; use function EE\Utils\delem_log; use function EE\Site\Utils\auto_site_name; use function EE\Site\Utils\get_site_info; @@ -791,7 +792,7 @@ protected function init_ssl( $site_url, $site_fs_path, $ssl_type, $wildcard = fa * @param bool $www_or_non_www Allow LetsEncrypt on www or non-www subdomain. */ protected function init_le( $site_url, $site_fs_path, $wildcard = false, $www_or_non_www ) { - $preferred_challenge = \EE\Utils\get_config_value( 'preferred_ssl_challenge', '' ); + $preferred_challenge = get_config_value( 'preferred_ssl_challenge', '' ); $is_solver_dns = ( $wildcard || 'dns' === $preferred_challenge ) ? true : false; \EE::debug( 'Wildcard in init_le: ' . ( bool ) $wildcard ); @@ -811,9 +812,14 @@ protected function init_le( $site_url, $site_fs_path, $wildcard = false, $www_or if ( ! $client->authorize( $domains, $wildcard, $preferred_challenge ) ) { return; } - if ( $is_solver_dns ) { + $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 ' . $site_url . '` once the DNS changes have propagated to complete the certification generation and installation.', null ); } else { + if ( ! $api_key_absent && $is_solver_dns ) { + EE::log( 'Waiting for DNS entry propagation.' ); + sleep( 10 ); + } $this->ssl( [], [], $www_or_non_www ); } } @@ -828,7 +834,7 @@ protected function init_le( $site_url, $site_fs_path, $wildcard = false, $www_or * @return array */ private function get_cert_domains( string $site_url, $wildcard, $www_or_non_www = false ): array { - $preferred_challenge = \EE\Utils\get_config_value( 'preferred_ssl_challenge', '' ); + $preferred_challenge = get_config_value( 'preferred_ssl_challenge', '' ); $is_solver_dns = ( $wildcard || 'dns' === $preferred_challenge ) ? true : false; $domains = [ $site_url ]; @@ -906,7 +912,8 @@ public function ssl( $args = [], $assoc_args = [], $www_or_non_www = false ) { EE::log( 'Starting SSL verification.' ); // This checks if this method was called internally by ee or by user - $called_by_ee = isset( $this->site_data['site_url'] ); + $called_by_ee = ! empty( $this->site_data['site_url'] ); + $api_key_absent = empty( get_config_value( 'cloudflare-api-key' ) ); if ( ! $called_by_ee ) { $this->site_data = get_site_info( $args ); @@ -920,15 +927,20 @@ public function ssl( $args = [], $assoc_args = [], $www_or_non_www = false ) { $domains = $this->get_cert_domains( $this->site_data['site_url'], $this->site_data['site_ssl_wildcard'], $www_or_non_www ); $client = new Site_Letsencrypt(); - $preferred_challenge = \EE\Utils\get_config_value( 'preferred_ssl_challenge', '' ); + $preferred_challenge = get_config_value( 'preferred_ssl_challenge', '' ); try { $client->check( $domains, $this->site_data['site_ssl_wildcard'], $preferred_challenge ); } catch ( \Exception $e ) { - if ( $called_by_ee ) { + if ( $called_by_ee && $api_key_absent ) { throw $e; } - EE::error( 'Failed to verify SSL: ' . $e->getMessage() ); + $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: ' . $e->getMessage(); + EE::warning( $warning ); + EE::warning( sprintf( 'Check logs and retry `ee site ssl %s` once the issue is resolved.', $this->site_data['site_url'] ) ); return; } @@ -942,7 +954,7 @@ public function ssl( $args = [], $assoc_args = [], $www_or_non_www = false ) { reload_global_nginx_proxy(); - EE::log( 'SSL verification completed.' ); + EE::success( 'SSL verification completed.' ); } /** @@ -1018,7 +1030,7 @@ public function publish( $args, $assoc_args ) { if ( ! empty( $token ) ) { EE::exec( "$ngrok authtoken $token" ); } - $config_80_port = \EE\Utils\get_config_value( 'proxy_80_port', 80 ); + $config_80_port = get_config_value( 'proxy_80_port', 80 ); if ( ! $refresh ) { EE::log( "Publishing site: {$this->site_data->site_url} online." ); }