diff --git a/src/helper/SimpleDnsCloudflareSolver.php b/src/helper/SimpleDnsCloudflareSolver.php index 81f71208..40bc0b55 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} */ @@ -116,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 diff --git a/src/helper/Site_Letsencrypt.php b/src/helper/Site_Letsencrypt.php index 4189ca4e..54cf2ffe 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 ) { @@ -334,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; } @@ -356,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; } @@ -389,11 +393,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 +539,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 ); } @@ -561,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; } @@ -573,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 f9dd4ff2..bf753983 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. * @@ -616,10 +621,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 +935,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 +1039,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 ); } @@ -1086,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.' ); @@ -1111,10 +1125,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 +1239,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 ); @@ -1653,7 +1669,30 @@ 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 || \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 ) ); + } + + 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; + } } } @@ -1670,6 +1709,60 @@ protected function www_ssl_wrapper( $containers_to_start = [], $site_enable = fa } } + /** + * 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. + * + * @param array $containers_to_start Containers to start for the site. Empty starts all. + */ + 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() { + + $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 ) { + $error = $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 ); + } + + // 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 ) ); + } + + /** + * 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 ) { + } + /** * Runs the acme le registration and authorization. * @@ -1693,6 +1786,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`" ); + } } /** @@ -1827,21 +1924,22 @@ 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; } $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.' ); - sleep( 10 ); + \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; } @@ -1949,6 +2047,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 ); @@ -1999,7 +2101,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 ) ); @@ -2010,6 +2112,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' ) ); @@ -2030,12 +2135,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; } @@ -2049,6 +2157,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.' ); @@ -2389,6 +2501,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' ]; @@ -2399,13 +2512,27 @@ 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; } 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' ); + } } /** diff --git a/src/helper/site-utils.php b/src/helper/site-utils.php index a4766b19..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. * @@ -1400,6 +1416,279 @@ 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. + * @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 = [], $min_wait = 10 ) { + + 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 ); + 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 ( function_exists( 'pcntl_signal_dispatch' ) ) { + pcntl_signal_dispatch(); + } + if ( microtime( true ) >= $deadline ) { + break; + } + } + $left = $deadline - microtime( true ); + if ( empty( $pending ) || $left <= 0 ) { + break; + } + usleep( (int) ( min( $delay, $left ) * 1000000 ) ); + $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; + } + + $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) 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 ) { + $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. *