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.
*