From 8ddb2f15753ec41657f8c23f5a9b4839ceba6ab8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Deruss=C3=A9?= Date: Fri, 23 Mar 2018 09:12:02 +0100 Subject: [PATCH] Optimize challenge solving by solving several challenges at once --- AcmeClient.php | 70 +++--- AcmeClientV2Interface.php | 10 + Challenge/Dns/LibDnsResolver.php | 33 +-- Challenge/Dns/Route53Solver.php | 206 +++++++++++------- .../MultipleChallengesSolverInterface.php | 36 +++ Challenge/WaitingValidator.php | 2 +- 6 files changed, 241 insertions(+), 116 deletions(-) create mode 100644 Challenge/MultipleChallengesSolverInterface.php diff --git a/AcmeClient.php b/AcmeClient.php index 444fd9b..8e72068 100644 --- a/AcmeClient.php +++ b/AcmeClient.php @@ -151,25 +151,27 @@ function ($domain) { } $orderEndpoint = $this->getHttpClient()->getLastLocation(); - $base64encoder = $this->getHttpClient()->getBase64Encoder(); foreach ($response['authorizations'] as $authorizationEndpoint) { $authorizationsResponse = $this->getHttpClient()->unsignedRequest('GET', $authorizationEndpoint, null, true); $domain = (empty($authorizationsResponse['wildcard']) ? '' : '*.').$authorizationsResponse['identifier']['value']; foreach ($authorizationsResponse['challenges'] as $challenge) { - $authorizationsChallenges[$domain][] = new AuthorizationChallenge( - $authorizationsResponse['identifier']['value'], - $challenge['status'], - $challenge['type'], - $challenge['url'], - $challenge['token'], - $challenge['token'].'.'.$base64encoder->encode($this->getHttpClient()->getJWKThumbprint()) - ); + $authorizationsChallenges[$domain][] = $this->createAuthorizationChallenge($authorizationsResponse['identifier']['value'], $challenge); } } return new CertificateOrder($authorizationsChallenges, $orderEndpoint); } + /** + * {@inheritdoc} + */ + public function reloadAuthorization(AuthorizationChallenge $challenge) + { + $response = (array) $this->getHttpClient()->unsignedRequest('GET', $challenge->getUrl()); + + return $this->createAuthorizationChallenge($challenge->getDomain(), $response); + } + /** * {@inheritdoc} */ @@ -252,6 +254,24 @@ public function finalizeOrder(CertificateOrder $order, CertificateRequest $csr, return new CertificateResponse($csr, $certificatesChain); } + /** + * Find a resource URL. + * + * @param string $resource + * + * @return string + */ + public function getResourceUrl($resource) + { + if (!$this->directory) { + $this->directory = new ResourcesDirectory( + $this->getHttpClient()->unsignedRequest('GET', $this->directoryUrl, null, true) + ); + } + + return $this->directory->getResourceUrl($resource); + } + /** * Request a resource (URL is found using ACME server directory). * @@ -275,24 +295,6 @@ protected function requestResource($method, $resource, array $payload, $returnJs ); } - /** - * Find a resource URL. - * - * @param string $resource - * - * @return string - */ - public function getResourceUrl($resource) - { - if (!$this->directory) { - $this->directory = new ResourcesDirectory( - $this->getHttpClient()->unsignedRequest('GET', $this->directoryUrl, null, true) - ); - } - - return $this->directory->getResourceUrl($resource); - } - /** * Retrieve the resource account. * @@ -311,4 +313,18 @@ private function getResourceAccount() return $this->account; } + + private function createAuthorizationChallenge($domain, array $response) + { + $base64encoder = $this->getHttpClient()->getBase64Encoder(); + + return new AuthorizationChallenge( + $domain, + $response['status'], + $response['type'], + $response['url'], + $response['token'], + $response['token'].'.'.$base64encoder->encode($this->getHttpClient()->getJWKThumbprint()) + ); + } } diff --git a/AcmeClientV2Interface.php b/AcmeClientV2Interface.php index a65a918..c1f4f75 100644 --- a/AcmeClientV2Interface.php +++ b/AcmeClientV2Interface.php @@ -16,6 +16,7 @@ use AcmePhp\Core\Exception\Protocol\CertificateRequestFailedException; use AcmePhp\Core\Exception\Protocol\CertificateRequestTimedOutException; use AcmePhp\Core\Exception\Protocol\ChallengeNotSupportedException; +use AcmePhp\Core\Protocol\AuthorizationChallenge; use AcmePhp\Core\Protocol\CertificateOrder; use AcmePhp\Ssl\CertificateRequest; use AcmePhp\Ssl\CertificateResponse; @@ -68,4 +69,13 @@ public function requestOrder(array $domains); * @return CertificateResponse the certificate data to save it somewhere you want */ public function finalizeOrder(CertificateOrder $order, CertificateRequest $csr, $timeout = 180); + + /** + * Request the current status of an authorization challenge. + * + * @param AuthorizationChallenge $challenge The challenge to request + * + * @return AuthorizationChallenge A new instance of the challenge + */ + public function reloadAuthorization(AuthorizationChallenge $challenge); } diff --git a/Challenge/Dns/LibDnsResolver.php b/Challenge/Dns/LibDnsResolver.php index 2932004..2da922d 100644 --- a/Challenge/Dns/LibDnsResolver.php +++ b/Challenge/Dns/LibDnsResolver.php @@ -20,6 +20,8 @@ use LibDNS\Messages\MessageTypes; use LibDNS\Records\QuestionFactory; use LibDNS\Records\ResourceTypes; +use Psr\Log\LoggerAwareTrait; +use Psr\Log\NullLogger; /** * Resolves DNS with LibDNS (pass over internal DNS cache and check several nameservers). @@ -28,6 +30,8 @@ */ class LibDnsResolver implements DnsResolverInterface { + use LoggerAwareTrait; + /** * @var QuestionFactory */ @@ -65,6 +69,8 @@ public function __construct( $this->encoder = null === $encoder ? (new EncoderFactory())->create() : $encoder; $this->decoder = null === $decoder ? (new DecoderFactory())->create() : $decoder; $this->nameServer = $nameServer; + + $this->logger = new NullLogger(); } /** @@ -80,30 +86,31 @@ public static function isSupported() */ public function getTxtEntries($domain) { + $nsDomain = implode('.', array_slice(explode('.', rtrim($domain, '.')), -2)); $nameServers = $this->request( - implode('.', array_slice(explode('.', rtrim($domain, '.')), -2)), + $nsDomain, ResourceTypes::NS, $this->nameServer ); + + $this->logger->debug('Fetched NS in charge of domain', ['nsDomain' => $nsDomain, 'servers' => $nameServers]); if (empty($nameServers)) { - throw new AcmeDnsResolutionException( - sprintf('Unable to find domain %s on nameserver %s', $domain, $this->nameServer) - ); + throw new AcmeDnsResolutionException(sprintf('Unable to find domain %s on nameserver %s', $domain, $this->nameServer)); } - $entries = null; + + $identicalEntries = []; foreach ($nameServers as $nameServer) { $ip = gethostbyname($nameServer); $serverEntries = $this->request($domain, ResourceTypes::TXT, $ip); - if (null === $entries) { - $entries = $serverEntries; - } elseif ($entries !== $serverEntries) { - throw new AcmeDnsResolutionException( - sprintf('Dns not fully propagated into nameserver %s', $nameServer) - ); - } + $identicalEntries[json_encode($serverEntries)][] = $nameServer; + } + + $this->logger->info('DNS records fetched', ['mapping' => $identicalEntries]); + if (1 !== count($identicalEntries)) { + throw new AcmeDnsResolutionException('Dns not fully propagated'); } - return $entries; + return json_decode(key($identicalEntries)); } private function request($domain, $type, $nameServer) diff --git a/Challenge/Dns/Route53Solver.php b/Challenge/Dns/Route53Solver.php index 9570176..ec18b07 100644 --- a/Challenge/Dns/Route53Solver.php +++ b/Challenge/Dns/Route53Solver.php @@ -11,17 +11,18 @@ namespace AcmePhp\Core\Challenge\Dns; -use AcmePhp\Core\Challenge\SolverInterface; +use AcmePhp\Core\Challenge\MultipleChallengesSolverInterface; use AcmePhp\Core\Exception\Protocol\ChallengeFailedException; use AcmePhp\Core\Protocol\AuthorizationChallenge; use Aws\Route53\Route53Client; +use Webmozart\Assert\Assert; /** * ACME DNS solver with automate configuration of a AWS route53. * * @author Jérémy Derussé */ -class Route53Solver implements SolverInterface +class Route53Solver implements MultipleChallengesSolverInterface { /** * @var DnsDataExtractor @@ -33,11 +34,6 @@ class Route53Solver implements SolverInterface */ private $client; - /** - * @var array - */ - private $pendingChanges = []; - /** * @param DnsDataExtractor $extractor * @param Route53Client $client @@ -63,34 +59,51 @@ public function supports(AuthorizationChallenge $authorizationChallenge) */ public function solve(AuthorizationChallenge $authorizationChallenge) { - $recordName = $this->extractor->getRecordName($authorizationChallenge); - $recordValue = $this->extractor->getRecordValue($authorizationChallenge); - - $zone = $this->getZone($authorizationChallenge->getDomain()); - - $this->changeResourceRecordSets( - $recordName, - [ - 'ChangeBatch' => [ - 'Changes' => [ - [ - 'Action' => 'UPSERT', - 'ResourceRecordSet' => [ - 'Name' => $recordName, - 'ResourceRecords' => [ - [ - 'Value' => sprintf('"%s"', $recordValue), - ], - ], - 'TTL' => 5, - 'Type' => 'TXT', - ], - ], + return $this->solveAll([$authorizationChallenge]); + } + + /** + * {@inheritdoc} + */ + public function solveAll(array $authorizationChallenges) + { + Assert::allIsInstanceOf($authorizationChallenges, AuthorizationChallenge::class); + + $changesPerZone = []; + $authorizationChallengesPerDomain = $this->groupAuthorizationChallengesPerDomain($authorizationChallenges); + foreach ($authorizationChallengesPerDomain as $domain => $authorizationChallengesForDomain) { + $zone = $this->getZone($authorizationChallengesForDomain[0]->getDomain()); + + $authorizationChallengesPerRecordName = $this->groupAuthorizationChallengesPerRecordName($authorizationChallengesForDomain); + foreach ($authorizationChallengesPerRecordName as $recordName => $authorizationChallengesForRecordName) { + $recordValues = array_unique(array_map([$this->extractor, 'getRecordValue'], $authorizationChallengesForRecordName)); + + $changesPerZone[$zone['Id']][] = [ + 'Action' => 'UPSERT', + 'ResourceRecordSet' => [ + 'Name' => $recordName, + 'ResourceRecords' => array_map(function ($recordValue) { + return [ + 'Value' => sprintf('"%s"', $recordValue), + ]; + }, $recordValues), + 'TTL' => 5, + 'Type' => 'TXT', ], - ], - 'HostedZoneId' => $zone['Id'], - ] - ); + ]; + } + } + + foreach ($changesPerZone as $zoneId => $changes) { + $this->changeResourceRecordSets( + [ + 'ChangeBatch' => [ + 'Changes' => $changes, + ], + 'HostedZoneId' => $zoneId, + ] + ); + } } /** @@ -98,60 +111,103 @@ public function solve(AuthorizationChallenge $authorizationChallenge) */ public function cleanup(AuthorizationChallenge $authorizationChallenge) { - $recordName = $this->extractor->getRecordName($authorizationChallenge); - $zone = $this->getZone($authorizationChallenge->getDomain()); - $recordSets = $this->client->listResourceRecordSets( - [ - 'HostedZoneId' => $zone['Id'], - 'StartRecordName' => $recordName, - 'StartRecordType' => 'TXT', - ] - ); + return $this->cleanupAll([$authorizationChallenge]); + } - $recordSets = array_filter( - $recordSets['ResourceRecordSets'], - function ($recordSet) use ($recordName) { - return $recordSet['Name'] === $recordName && 'TXT' === $recordSet['Type']; + /** + * {@inheritdoc} + */ + public function cleanupAll(array $authorizationChallenges) + { + Assert::allIsInstanceOf($authorizationChallenges, AuthorizationChallenge::class); + + $changesPerZone = []; + $authorizationChallengesPerDomain = $this->groupAuthorizationChallengesPerDomain($authorizationChallenges); + foreach ($authorizationChallengesPerDomain as $domain => $authorizationChallengesForDomain) { + $zone = $this->getZone($authorizationChallengesForDomain[0]->getDomain()); + + $authorizationChallengesPerRecordName = $this->groupAuthorizationChallengesPerRecordName($authorizationChallengesForDomain); + foreach ($authorizationChallengesPerRecordName as $recordName => $authorizationChallengesForRecordName) { + $recordSets = $this->client->listResourceRecordSets( + [ + 'HostedZoneId' => $zone['Id'], + 'StartRecordName' => $recordName, + 'StartRecordType' => 'TXT', + ] + ); + + $recordSets = array_filter( + $recordSets['ResourceRecordSets'], + function ($recordSet) use ($recordName) { + return $recordSet['Name'] === $recordName && 'TXT' === $recordSet['Type']; + } + ); + + if (!$recordSets) { + return; + } + + if (!isset($changesPerZone[$zone['Id']])) { + $changesPerZone[$zone['Id']] = []; + } + $changesPerZone[$zone['Id']] = array_merge($changesPerZone[$zone['Id']], array_map( + function ($recordSet) { + return [ + 'Action' => 'DELETE', + 'ResourceRecordSet' => $recordSet, + ]; + }, + $recordSets + )); } - ); - - if (!$recordSets) { - return; } - $this->changeResourceRecordSets( - $recordName, - [ - 'ChangeBatch' => [ - 'Changes' => array_map( - function ($recordSet) { - return [ - 'Action' => 'DELETE', - 'ResourceRecordSet' => $recordSet, - ]; - }, - $recordSets - ), - ], - 'HostedZoneId' => $zone['Id'], - ] - ); + foreach ($changesPerZone as $zoneId => $changes) { + $this->changeResourceRecordSets( + [ + 'ChangeBatch' => [ + 'Changes' => $changes, + ], + 'HostedZoneId' => $zoneId, + ] + ); + } } - public function wait($recordName) + /** + * @param AuthorizationChallenge[] $authorizationChallenges + * + * @return AuthorizationChallenge[][] + */ + private function groupAuthorizationChallengesPerDomain(array $authorizationChallenges) { - if (isset($this->pendingChanges[$recordName])) { - $this->client->waitUntil('ResourceRecordSetsChanged', ['Id' => $this->pendingChanges[$recordName]]); - unset($this->pendingChanges[$recordName]); + $groups = []; + foreach ($authorizationChallenges as $authorizationChallenge) { + $groups[$authorizationChallenge->getDomain()][] = $authorizationChallenge; } + + return $groups; } - private function changeResourceRecordSets($recordName, array $payload) + /** + * @param AuthorizationChallenge[] $authorizationChallenges + * + * @return AuthorizationChallenge[][] + */ + private function groupAuthorizationChallengesPerRecordName(array $authorizationChallenges) { - $this->wait($recordName); + $groups = []; + foreach ($authorizationChallenges as $authorizationChallenge) { + $groups[$this->extractor->getRecordName($authorizationChallenge)][] = $authorizationChallenge; + } + + return $groups; + } + private function changeResourceRecordSets(array $payload) + { $record = $this->client->changeResourceRecordSets($payload); - $this->pendingChanges[$recordName] = $record['ChangeInfo']['Id']; + $this->client->waitUntil('ResourceRecordSetsChanged', ['Id' => $record['ChangeInfo']['Id']]); } private function getZone($domain) diff --git a/Challenge/MultipleChallengesSolverInterface.php b/Challenge/MultipleChallengesSolverInterface.php new file mode 100644 index 0000000..f29f640 --- /dev/null +++ b/Challenge/MultipleChallengesSolverInterface.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AcmePhp\Core\Challenge; + +use AcmePhp\Core\Protocol\AuthorizationChallenge; + +/** + * ACME challenge solver able to solve several challenges at once. + * + * @author Jérémy Derussé + */ +interface MultipleChallengesSolverInterface extends SolverInterface +{ + /** + * Solve the given list of authorization challenge. + * + * @param AuthorizationChallenge[] $authorizationChallenges + */ + public function solveAll(array $authorizationChallenges); + + /** + * Cleanup the environments after all challenges. + * + * @param AuthorizationChallenge[] $authorizationChallenges + */ + public function cleanupAll(array $authorizationChallenges); +} diff --git a/Challenge/WaitingValidator.php b/Challenge/WaitingValidator.php index 86e7232..c1dd2e8 100644 --- a/Challenge/WaitingValidator.php +++ b/Challenge/WaitingValidator.php @@ -60,7 +60,7 @@ public function isValid(AuthorizationChallenge $authorizationChallenge) if ($this->validator->isValid($authorizationChallenge)) { return true; } - sleep(1); + sleep(3); } while ($limitEndTime > time()); return false;