This commit is contained in:
2024-08-05 22:57:28 +08:00
commit 0efda6c02a
1779 changed files with 171774 additions and 0 deletions
+62
View File
@@ -0,0 +1,62 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* 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\Exception\Protocol\ChallengeNotSupportedException;
use AcmePhp\Core\Protocol\AuthorizationChallenge;
/**
* A strategy ACME validator.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class ChainValidator implements ValidatorInterface
{
/** @var ValidatorInterface[] */
private $validators;
/**
* @param ValidatorInterface[] $validators
*/
public function __construct(array $validators)
{
$this->validators = $validators;
}
/**
* {@inheritdoc}
*/
public function supports(AuthorizationChallenge $authorizationChallenge, SolverInterface $solver): bool
{
foreach ($this->validators as $validator) {
if ($validator->supports($authorizationChallenge, $solver)) {
return true;
}
}
return false;
}
/**
* {@inheritdoc}
*/
public function isValid(AuthorizationChallenge $authorizationChallenge, SolverInterface $solver): bool
{
foreach ($this->validators as $validator) {
if ($validator->supports($authorizationChallenge, $solver)) {
return $validator->isValid($authorizationChallenge, $solver);
}
}
throw new ChallengeNotSupportedException();
}
}
@@ -0,0 +1,25 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Challenge;
/**
* ACME challenge solver.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
interface ConfigurableServiceInterface
{
/**
* Configure the service with a set of configuration.
*/
public function configure(array $config);
}
@@ -0,0 +1,47 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Challenge\Dns;
use AcmePhp\Core\Http\Base64SafeEncoder;
use AcmePhp\Core\Protocol\AuthorizationChallenge;
/**
* Extract data needed to solve DNS challenges.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class DnsDataExtractor
{
/** @var Base64SafeEncoder */
private $encoder;
public function __construct(Base64SafeEncoder $encoder = null)
{
$this->encoder = $encoder ?: new Base64SafeEncoder();
}
/**
* Retrieves the name of the TXT record to register.
*/
public function getRecordName(AuthorizationChallenge $authorizationChallenge): string
{
return sprintf('_acme-challenge.%s.', $authorizationChallenge->getDomain());
}
/**
* Retrieves the value of the TXT record to register.
*/
public function getRecordValue(AuthorizationChallenge $authorizationChallenge): string
{
return $this->encoder->encode(hash('sha256', $authorizationChallenge->getPayload(), true));
}
}
@@ -0,0 +1,30 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Challenge\Dns;
/**
* Resolves DNS.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
interface DnsResolverInterface
{
/**
* Return whether or not the Resolver is supported.
*/
public static function isSupported(): bool;
/**
* Retrieves the list of TXT entries for the given domain.
*/
public function getTxtEntries(string $domain): array;
}
@@ -0,0 +1,68 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Challenge\Dns;
use AcmePhp\Core\Challenge\SolverInterface;
use AcmePhp\Core\Challenge\ValidatorInterface;
use AcmePhp\Core\Exception\AcmeDnsResolutionException;
use AcmePhp\Core\Protocol\AuthorizationChallenge;
/**
* Validator for DNS challenges.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class DnsValidator implements ValidatorInterface
{
/**
* @var DnsDataExtractor
*/
private $extractor;
/**
* @var DnsResolverInterface
*/
private $dnsResolver;
public function __construct(DnsDataExtractor $extractor = null, DnsResolverInterface $dnsResolver = null)
{
$this->extractor = $extractor ?: new DnsDataExtractor();
$this->dnsResolver = $dnsResolver;
if (!$this->dnsResolver) {
$this->dnsResolver = LibDnsResolver::isSupported() ? new LibDnsResolver() : new SimpleDnsResolver();
}
}
/**
* {@inheritdoc}
*/
public function supports(AuthorizationChallenge $authorizationChallenge, SolverInterface $solver): bool
{
return 'dns-01' === $authorizationChallenge->getType();
}
/**
* {@inheritdoc}
*/
public function isValid(AuthorizationChallenge $authorizationChallenge, SolverInterface $solver): bool
{
$recordName = $this->extractor->getRecordName($authorizationChallenge);
$recordValue = $this->extractor->getRecordValue($authorizationChallenge);
try {
return \in_array($recordValue, $this->dnsResolver->getTxtEntries($recordName), false);
} catch (AcmeDnsResolutionException $e) {
return false;
}
}
}
+152
View File
@@ -0,0 +1,152 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Challenge\Dns;
use AcmePhp\Core\Challenge\ConfigurableServiceInterface;
use AcmePhp\Core\Challenge\MultipleChallengesSolverInterface;
use AcmePhp\Core\Protocol\AuthorizationChallenge;
use GuzzleHttp\Client;
use GuzzleHttp\ClientInterface;
use Psr\Log\LoggerAwareTrait;
use Psr\Log\NullLogger;
use Webmozart\Assert\Assert;
/**
* ACME DNS solver with automate configuration of a Gandi.Net.
*
* @author Alexander Obuhovich <aik.bold@gmail.com>
*/
class GandiSolver implements MultipleChallengesSolverInterface, ConfigurableServiceInterface
{
use LoggerAwareTrait;
/**
* @var DnsDataExtractor
*/
private $extractor;
/**
* @var ClientInterface
*/
private $client;
/**
* @var array
*/
private $cacheZones;
/**
* @var string
*/
private $apiKey;
public function __construct(DnsDataExtractor $extractor = null, ClientInterface $client = null)
{
$this->extractor = $extractor ?: new DnsDataExtractor();
$this->client = $client ?: new Client();
$this->logger = new NullLogger();
}
/**
* Configure the service with a set of configuration.
*/
public function configure(array $config)
{
$this->apiKey = $config['api_key'];
}
/**
* {@inheritdoc}
*/
public function supports(AuthorizationChallenge $authorizationChallenge): bool
{
return 'dns-01' === $authorizationChallenge->getType();
}
/**
* {@inheritdoc}
*/
public function solve(AuthorizationChallenge $authorizationChallenge)
{
return $this->solveAll([$authorizationChallenge]);
}
/**
* {@inheritdoc}
*/
public function solveAll(array $authorizationChallenges)
{
Assert::allIsInstanceOf($authorizationChallenges, AuthorizationChallenge::class);
foreach ($authorizationChallenges as $authorizationChallenge) {
$topLevelDomain = $this->getTopLevelDomain($authorizationChallenge->getDomain());
$recordName = $this->extractor->getRecordName($authorizationChallenge);
$recordValue = $this->extractor->getRecordValue($authorizationChallenge);
$subDomain = \str_replace('.'.$topLevelDomain.'.', '', $recordName);
$this->client->request(
'PUT',
'https://dns.api.gandi.net/api/v5/domains/'.$topLevelDomain.'/records/'.$subDomain.'/TXT',
[
'headers' => [
'X-Api-Key' => $this->apiKey,
],
'json' => [
'rrset_type' => 'TXT',
'rrset_ttl' => 600,
'rrset_name' => $subDomain,
'rrset_values' => [$recordValue],
],
]
);
}
}
/**
* {@inheritdoc}
*/
public function cleanup(AuthorizationChallenge $authorizationChallenge)
{
return $this->cleanupAll([$authorizationChallenge]);
}
/**
* {@inheritdoc}
*/
public function cleanupAll(array $authorizationChallenges)
{
Assert::allIsInstanceOf($authorizationChallenges, AuthorizationChallenge::class);
foreach ($authorizationChallenges as $authorizationChallenge) {
$topLevelDomain = $this->getTopLevelDomain($authorizationChallenge->getDomain());
$recordName = $this->extractor->getRecordName($authorizationChallenge);
$subDomain = \str_replace('.'.$topLevelDomain.'.', '', $recordName);
$this->client->request(
'DELETE',
'https://dns.api.gandi.net/api/v5/domains/'.$topLevelDomain.'/records/'.$subDomain.'/TXT',
[
'headers' => [
'X-Api-Key' => $this->apiKey,
],
]
);
}
}
protected function getTopLevelDomain(string $domain): string
{
return \implode('.', \array_slice(\explode('.', $domain), -2));
}
}
@@ -0,0 +1,200 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Challenge\Dns;
use AcmePhp\Core\Exception\AcmeDnsResolutionException;
use LibDNS\Decoder\Decoder;
use LibDNS\Decoder\DecoderFactory;
use LibDNS\Encoder\Encoder;
use LibDNS\Encoder\EncoderFactory;
use LibDNS\Messages\MessageFactory;
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).
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class LibDnsResolver implements DnsResolverInterface
{
use LoggerAwareTrait;
/**
* @var QuestionFactory
*/
private $questionFactory;
/**
* @var MessageFactory
*/
private $messageFactory;
/**
* @var Encoder
*/
private $encoder;
/**
* @var Decoder
*/
private $decoder;
/**
* @var string
*/
private $nameServer;
public function __construct(
QuestionFactory $questionFactory = null,
MessageFactory $messageFactory = null,
Encoder $encoder = null,
Decoder $decoder = null,
$nameServer = '8.8.8.8'
) {
$this->questionFactory = $questionFactory ?: new QuestionFactory();
$this->messageFactory = $messageFactory ?: new MessageFactory();
$this->encoder = $encoder ?: (new EncoderFactory())->create();
$this->decoder = $decoder ?: (new DecoderFactory())->create();
$this->nameServer = $nameServer;
$this->logger = new NullLogger();
}
/**
* @{@inheritdoc}
*/
public static function isSupported(): bool
{
return class_exists(ResourceTypes::class);
}
/**
* @{@inheritdoc}
*/
public function getTxtEntries($domain): array
{
$domain = rtrim($domain, '.');
$nameServers = $this->getNameServers($domain);
$this->logger->debug('Fetched TXT records for domain', ['nsDomain' => $domain, 'servers' => $nameServers]);
$identicalEntries = [];
foreach ($nameServers as $nameServer) {
$ipNameServer = gethostbynamel($nameServer);
if (empty($ipNameServer)) {
throw new AcmeDnsResolutionException(sprintf('Unable to find domain %s on nameserver %s', $domain, $nameServer));
}
try {
$response = $this->request($domain, ResourceTypes::TXT, $ipNameServer[0]);
} catch (\Exception $e) {
throw new AcmeDnsResolutionException(sprintf('Unable to find domain %s on nameserver %s', $domain, $nameServer));
}
$entries = [];
foreach ($response->getAnswerRecords() as $record) {
foreach ($record->getData() as $recordData) {
$entries[] = (string) $recordData;
}
}
$identicalEntries[json_encode($entries)][] = $nameServer;
}
$this->logger->info('DNS records fetched', ['mapping' => $identicalEntries]);
if (1 !== \count($identicalEntries)) {
throw new AcmeDnsResolutionException('Dns not fully propagated');
}
return json_decode(key($identicalEntries));
}
private function getNameServers($domain)
{
if ('' === $domain) {
return [$this->nameServer];
}
$parentNameServers = $this->getNameServers(implode('.', \array_slice(explode('.', $domain), 1)));
$itemNameServers = [];
$this->logger->debug('Fetched NS in charge of domain', ['nsDomain' => $domain, 'servers' => $parentNameServers]);
foreach ($parentNameServers as $parentNameServer) {
$ipNameServer = gethostbynamel($parentNameServer);
if (empty($ipNameServer)) {
continue;
}
try {
$response = $this->request(
$domain,
ResourceTypes::NS,
$ipNameServer[0]
);
} catch (\Exception $e) {
// ignore errors
continue;
}
foreach ($response->getAnswerRecords() as $record) {
try {
$field = $record->getData()->getFieldByName('nsdname');
$itemNameServers[] = $field->getValue();
} catch (\OutOfBoundsException $e) {
}
}
foreach ($response->getAuthorityRecords() as $record) {
try {
$field = $record->getData()->getFieldByName('nsdname');
$itemNameServers[] = $field->getValue();
} catch (\OutOfBoundsException $e) {
}
}
}
$itemNameServers = array_unique($itemNameServers);
if (empty($itemNameServers)) {
return $parentNameServers;
}
return $itemNameServers;
}
private function request($domain, $type, $nameServer)
{
$question = $this->questionFactory->create($type);
$question->setName($domain);
// Create request message
$request = $this->messageFactory->create(MessageTypes::QUERY);
$request->getQuestionRecords()->add($question);
$request->isRecursionDesired(true);
// Send request
$socket = stream_socket_client(sprintf('udp://'.$nameServer.':53'));
stream_socket_sendto($socket, $this->encoder->encode($request));
$r = [$socket];
$w = $e = [];
if (!stream_select($r, $w, $e, 3)) {
throw new AcmeDnsResolutionException(sprintf('Timeout reached when requesting ServerName %s', $nameServer));
}
// Decode response message
try {
$response = $this->decoder->decode(fread($socket, 1 << 20));
} catch (\Exception $e) {
throw new AcmeDnsResolutionException('Failed to decode server response', $e);
}
if (0 !== $response->getResponseCode()) {
throw new AcmeDnsResolutionException(sprintf('ServerName respond with error code "%d"', $response->getResponseCode()));
}
return $response;
}
}
@@ -0,0 +1,295 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Challenge\Dns;
use AcmePhp\Core\Challenge\MultipleChallengesSolverInterface;
use AcmePhp\Core\Exception\Protocol\ChallengeFailedException;
use AcmePhp\Core\Protocol\AuthorizationChallenge;
use Aws\Route53\Route53Client;
use Psr\Log\LoggerAwareTrait;
use Psr\Log\NullLogger;
use Webmozart\Assert\Assert;
/**
* ACME DNS solver with automate configuration of a AWS route53.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class Route53Solver implements MultipleChallengesSolverInterface
{
use LoggerAwareTrait;
/**
* @var DnsDataExtractor
*/
private $extractor;
/**
* @var Route53Client
*/
private $client;
/**
* @var array
*/
private $cacheZones;
public function __construct(DnsDataExtractor $extractor = null, Route53Client $client = null)
{
$this->extractor = $extractor ?: new DnsDataExtractor();
$this->client = $client ?: new Route53Client([]);
$this->logger = new NullLogger();
}
/**
* {@inheritdoc}
*/
public function supports(AuthorizationChallenge $authorizationChallenge): bool
{
return 'dns-01' === $authorizationChallenge->getType();
}
/**
* {@inheritdoc}
*/
public function solve(AuthorizationChallenge $authorizationChallenge)
{
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) {
$challengeValues = array_unique(array_map([$this->extractor, 'getRecordValue'], $authorizationChallengesForRecordName));
$recordIndex = $this->getPreviousRecordIndex($zone['Id'], $recordName);
if (0 === \count(array_diff($challengeValues, array_keys($recordIndex)))) {
$this->logger->debug('Record already defined', ['recordName' => $recordName]);
continue;
}
foreach ($challengeValues as $recordValue) {
$recordIndex[$recordValue] = time();
}
$changesPerZone[$zone['Id']][] = $this->getSaveRecordQuery($recordName, $recordIndex);
}
}
$records = [];
foreach ($changesPerZone as $zoneId => $changes) {
$this->logger->info('Updating route 53 DNS', ['zone' => $zoneId]);
$records[$zoneId] = $this->client->changeResourceRecordSets(
[
'ChangeBatch' => [
'Changes' => $changes,
],
'HostedZoneId' => $zoneId,
]
);
}
foreach ($records as $zoneId => $record) {
$this->logger->info('Waiting for Route 53 changes', ['zone' => $zoneId]);
$this->client->waitUntil('ResourceRecordSetsChanged', ['Id' => $record['ChangeInfo']['Id']]);
}
}
/**
* {@inheritdoc}
*/
public function cleanup(AuthorizationChallenge $authorizationChallenge)
{
return $this->cleanupAll([$authorizationChallenge]);
}
/**
* {@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) {
$challengeValues = array_unique(array_map([$this->extractor, 'getRecordValue'], $authorizationChallengesForRecordName));
$recordIndex = $this->getPreviousRecordIndex($zone['Id'], $recordName);
foreach ($challengeValues as $recordValue) {
unset($recordIndex[$recordValue]);
}
$changesPerZone[$zone['Id']][] = $this->getSaveRecordQuery($recordName, $recordIndex);
}
}
foreach ($changesPerZone as $zoneId => $changes) {
$this->logger->info('Updating route 53 DNS', ['zone' => $zoneId]);
$this->client->changeResourceRecordSets(
[
'ChangeBatch' => [
'Changes' => $changes,
],
'HostedZoneId' => $zoneId,
]
);
}
}
private function getPreviousRecordIndex($zoneId, $recordName)
{
$previousRecordSets = $this->client->listResourceRecordSets([
'HostedZoneId' => $zoneId,
'StartRecordName' => $recordName,
'StartRecordType' => 'TXT',
]);
$recordSets = array_filter(
$previousRecordSets['ResourceRecordSets'],
function ($recordSet) use ($recordName) {
return $recordSet['Name'] === $recordName && 'TXT' === $recordSet['Type'];
}
);
$recordIndex = [];
foreach ($recordSets as $previousRecordSet) {
$previousTxt = array_map(function ($resourceRecord) {
return stripslashes(trim($resourceRecord['Value'], '"'));
}, $previousRecordSet['ResourceRecords']);
// Search the special Index
foreach ($previousTxt as $index => $recordValue) {
if (null !== $previousIndex = json_decode($recordValue, true)) {
$recordIndex = $previousIndex;
unset($previousTxt[$index]);
break;
}
}
// Set default value
foreach ($previousTxt as $recordValue) {
if (!isset($recordIndex[$recordValue])) {
$recordIndex[$recordValue] = time();
}
}
}
return $recordIndex;
}
private function getSaveRecordQuery($recordName, array $recordIndex)
{
//remove old indexes
$limitTime = time() - 86400;
foreach ($recordIndex as $recordValue => $time) {
if ($time < $limitTime) {
unset($recordIndex[$recordValue]);
}
}
$recordValues = array_keys($recordIndex);
$recordValues[] = json_encode($recordIndex);
return [
'Action' => 'UPSERT',
'ResourceRecordSet' => [
'Name' => $recordName,
'ResourceRecords' => array_map(function ($recordValue) {
return [
'Value' => sprintf('"%s"', addslashes($recordValue)),
];
}, $recordValues),
'TTL' => 5,
'Type' => 'TXT',
],
];
}
/**
* @param AuthorizationChallenge[] $authorizationChallenges
*
* @return AuthorizationChallenge[][]
*/
private function groupAuthorizationChallengesPerDomain(array $authorizationChallenges)
{
$groups = [];
foreach ($authorizationChallenges as $authorizationChallenge) {
$groups[$authorizationChallenge->getDomain()][] = $authorizationChallenge;
}
return $groups;
}
/**
* @param AuthorizationChallenge[] $authorizationChallenges
*
* @return AuthorizationChallenge[][]
*/
private function groupAuthorizationChallengesPerRecordName(array $authorizationChallenges)
{
$groups = [];
foreach ($authorizationChallenges as $authorizationChallenge) {
$groups[$this->extractor->getRecordName($authorizationChallenge)][] = $authorizationChallenge;
}
return $groups;
}
private function getZone($domain)
{
$domainParts = explode('.', $domain);
$domains = array_reverse(array_map(
function ($index) use ($domainParts) {
return implode('.', \array_slice($domainParts, \count($domainParts) - $index));
},
range(0, \count($domainParts))
));
$zones = $this->getZones();
foreach ($domains as $cursorDomain) {
if (isset($zones[$cursorDomain.'.'])) {
return $zones[$cursorDomain.'.'];
}
}
throw new ChallengeFailedException(sprintf('Unable to find a zone for the domain "%s"', $domain));
}
private function getZones()
{
if (null !== $this->cacheZones) {
return $this->cacheZones;
}
$zones = [];
$args = [];
do {
$resp = $this->client->listHostedZones($args);
$zones = array_merge($zones, $resp['HostedZones']);
$args = ['Marker' => $resp['NextMarker']];
} while ($resp['IsTruncated']);
$this->cacheZones = array_column($zones, null, 'Name');
return $this->cacheZones;
}
}
@@ -0,0 +1,43 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Challenge\Dns;
/**
* Resolves DNS through dns_get_record.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class SimpleDnsResolver implements DnsResolverInterface
{
/**
* @{@inheritdoc}
*/
public static function isSupported(): bool
{
return \function_exists('dns_get_record');
}
/**
* @{@inheritdoc}
*/
public function getTxtEntries($domain): array
{
$entries = [];
foreach (dns_get_record($domain, DNS_TXT) as $record) {
$entries = array_merge($entries, $record['entries']);
}
sort($entries);
return array_unique($entries);
}
}
@@ -0,0 +1,100 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Challenge\Dns;
use AcmePhp\Core\Challenge\SolverInterface;
use AcmePhp\Core\Protocol\AuthorizationChallenge;
use Symfony\Component\Console\Output\NullOutput;
use Symfony\Component\Console\Output\OutputInterface;
/**
* ACME DNS solver with manual intervention.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class SimpleDnsSolver implements SolverInterface
{
/**
* @var DnsDataExtractor
*/
private $extractor;
/**
* @var OutputInterface
*/
protected $output;
/**
* @param DnsDataExtractor $extractor
* @param OutputInterface $output
*/
public function __construct(DnsDataExtractor $extractor = null, OutputInterface $output = null)
{
$this->extractor = $extractor ?: new DnsDataExtractor();
$this->output = $output ?: new NullOutput();
}
/**
* {@inheritdoc}
*/
public function supports(AuthorizationChallenge $authorizationChallenge): bool
{
return 'dns-01' === $authorizationChallenge->getType();
}
/**
* {@inheritdoc}
*/
public function solve(AuthorizationChallenge $authorizationChallenge)
{
$recordName = $this->extractor->getRecordName($authorizationChallenge);
$recordValue = $this->extractor->getRecordValue($authorizationChallenge);
$this->output->writeln(
sprintf(
<<<'EOF'
Add the following TXT record to your DNS zone
Domain: %s
TXT value: %s
<comment>Wait for the propagation before moving to the next step</comment>
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);
$this->output->writeln(
sprintf(
<<<'EOF'
You can now cleanup your DNS by removing the domain <comment>_acme-challenge.%s.</comment>
EOF
,
$recordName
)
);
}
}
@@ -0,0 +1,90 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Challenge\Http;
use AcmePhp\Core\Challenge\ConfigurableServiceInterface;
use AcmePhp\Core\Challenge\SolverInterface;
use AcmePhp\Core\Filesystem\Adapter\NullAdapter;
use AcmePhp\Core\Filesystem\FilesystemFactoryInterface;
use AcmePhp\Core\Filesystem\FilesystemInterface;
use AcmePhp\Core\Protocol\AuthorizationChallenge;
use Psr\Container\ContainerInterface;
use Symfony\Component\DependencyInjection\ServiceLocator;
use Webmozart\Assert\Assert;
/**
* ACME HTTP solver through ftp upload.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class FilesystemSolver implements SolverInterface, ConfigurableServiceInterface
{
/**
* @var ContainerInterface
*/
private $filesystemFactoryLocator;
/**
* @var FilesystemInterface
*/
private $filesystem;
/**
* @var HttpDataExtractor
*/
private $extractor;
public function __construct(ContainerInterface $filesystemFactoryLocator = null, HttpDataExtractor $extractor = null)
{
$this->filesystemFactoryLocator = $filesystemFactoryLocator ?: new ServiceLocator([]);
$this->extractor = $extractor ?: new HttpDataExtractor();
$this->filesystem = new NullAdapter();
}
public function configure(array $config)
{
Assert::keyExists($config, 'adapter', 'configure::$config expected an array with the key %s.');
/** @var FilesystemFactoryInterface $factory */
$factory = $this->filesystemFactoryLocator->get($config['adapter']);
$this->filesystem = $factory->create($config);
}
/**
* {@inheritdoc}
*/
public function supports(AuthorizationChallenge $authorizationChallenge): bool
{
return 'http-01' === $authorizationChallenge->getType();
}
/**
* {@inheritdoc}
*/
public function solve(AuthorizationChallenge $authorizationChallenge)
{
$checkPath = $this->extractor->getCheckPath($authorizationChallenge);
$checkContent = $this->extractor->getCheckContent($authorizationChallenge);
$this->filesystem->write($checkPath, $checkContent);
}
/**
* {@inheritdoc}
*/
public function cleanup(AuthorizationChallenge $authorizationChallenge)
{
$checkPath = $this->extractor->getCheckPath($authorizationChallenge);
$this->filesystem->delete($checkPath);
}
}
@@ -0,0 +1,53 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Challenge\Http;
use AcmePhp\Core\Protocol\AuthorizationChallenge;
/**
* Extract data needed to solve HTTP challenges.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class HttpDataExtractor
{
/**
* Retrieves the absolute URL called by the CA.
*/
public function getCheckUrl(AuthorizationChallenge $authorizationChallenge): string
{
return sprintf(
'http://%s%s',
$authorizationChallenge->getDomain(),
$this->getCheckPath($authorizationChallenge)
);
}
/**
* Retrieves the absolute path called by the CA.
*/
public function getCheckPath(AuthorizationChallenge $authorizationChallenge): string
{
return sprintf(
'/.well-known/acme-challenge/%s',
$authorizationChallenge->getToken()
);
}
/**
* Retrieves the content that should be returned in the response.
*/
public function getCheckContent(AuthorizationChallenge $authorizationChallenge): string
{
return $authorizationChallenge->getPayload();
}
}
@@ -0,0 +1,65 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Challenge\Http;
use AcmePhp\Core\Challenge\SolverInterface;
use AcmePhp\Core\Challenge\ValidatorInterface;
use AcmePhp\Core\Protocol\AuthorizationChallenge;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
/**
* Validator for HTTP challenges.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class HttpValidator implements ValidatorInterface
{
/**
* @var HttpDataExtractor
*/
private $extractor;
/**
* @var Client
*/
private $client;
public function __construct(HttpDataExtractor $extractor = null, Client $client = null)
{
$this->extractor = $extractor ?: new HttpDataExtractor();
$this->client = $client ?: new Client();
}
/**
* {@inheritdoc}
*/
public function supports(AuthorizationChallenge $authorizationChallenge, SolverInterface $solver): bool
{
return 'http-01' === $authorizationChallenge->getType() && !$solver instanceof MockServerHttpSolver;
}
/**
* {@inheritdoc}
*/
public function isValid(AuthorizationChallenge $authorizationChallenge, SolverInterface $solver): bool
{
$checkUrl = $this->extractor->getCheckUrl($authorizationChallenge);
$checkContent = $this->extractor->getCheckContent($authorizationChallenge);
try {
return $checkContent === trim($this->client->get($checkUrl, ['verify' => false])->getBody()->getContents());
} catch (ClientException $e) {
return false;
}
}
}
@@ -0,0 +1,40 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Challenge\Http;
use AcmePhp\Core\Challenge\SolverInterface;
use AcmePhp\Core\Challenge\ValidatorInterface;
use AcmePhp\Core\Protocol\AuthorizationChallenge;
/**
* Validator for pebble-challtestsrv.
*
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class MockHttpValidator implements ValidatorInterface
{
/**
* {@inheritdoc}
*/
public function supports(AuthorizationChallenge $authorizationChallenge, SolverInterface $solver): bool
{
return 'http-01' === $authorizationChallenge->getType() && $solver instanceof MockServerHttpSolver;
}
/**
* {@inheritdoc}
*/
public function isValid(AuthorizationChallenge $authorizationChallenge, SolverInterface $solver): bool
{
return true;
}
}
@@ -0,0 +1,58 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Challenge\Http;
use AcmePhp\Core\Challenge\SolverInterface;
use AcmePhp\Core\Protocol\AuthorizationChallenge;
use GuzzleHttp\Client;
use GuzzleHttp\RequestOptions;
/**
* ACME HTTP solver talking to pebble-challtestsrv.
*
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class MockServerHttpSolver implements SolverInterface
{
/**
* {@inheritdoc}
*/
public function supports(AuthorizationChallenge $authorizationChallenge): bool
{
return 'http-01' === $authorizationChallenge->getType();
}
/**
* {@inheritdoc}
*/
public function solve(AuthorizationChallenge $authorizationChallenge)
{
(new Client())->post('http://localhost:8055/add-http01', [
RequestOptions::JSON => [
'token' => $authorizationChallenge->getToken(),
'content' => $authorizationChallenge->getPayload(),
],
]);
}
/**
* {@inheritdoc}
*/
public function cleanup(AuthorizationChallenge $authorizationChallenge)
{
(new Client())->post('http://localhost:8055/del-http01', [
RequestOptions::JSON => [
'token' => $authorizationChallenge->getToken(),
],
]);
}
}
@@ -0,0 +1,96 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Challenge\Http;
use AcmePhp\Core\Challenge\SolverInterface;
use AcmePhp\Core\Protocol\AuthorizationChallenge;
use Symfony\Component\Console\Output\NullOutput;
use Symfony\Component\Console\Output\OutputInterface;
/**
* ACME HTTP solver with manual intervention.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class SimpleHttpSolver implements SolverInterface
{
/**
* @var HttpDataExtractor
*/
private $extractor;
/**
* @var OutputInterface
*/
private $output;
public function __construct(HttpDataExtractor $extractor = null, OutputInterface $output = null)
{
$this->extractor = $extractor ?: new HttpDataExtractor();
$this->output = $output ?: new NullOutput();
}
/**
* {@inheritdoc}
*/
public function supports(AuthorizationChallenge $authorizationChallenge): bool
{
return 'http-01' === $authorizationChallenge->getType();
}
/**
* {@inheritdoc}
*/
public function solve(AuthorizationChallenge $authorizationChallenge)
{
$checkUrl = $this->extractor->getCheckUrl($authorizationChallenge);
$checkContent = $this->extractor->getCheckContent($authorizationChallenge);
$this->output->writeln(
sprintf(
<<<'EOF'
Create a text file accessible on URL %s
containing the following content:
%s
Check in your browser that the URL %s returns
the authorization token above.
EOF
,
$checkUrl,
$checkContent,
$checkContent
)
);
}
/**
* {@inheritdoc}
*/
public function cleanup(AuthorizationChallenge $authorizationChallenge)
{
$checkUrl = $this->extractor->getCheckUrl($authorizationChallenge);
$this->output->writeln(
sprintf(
<<<'EOF'
You can now safely remove the challenge's file at %s
EOF
,
$checkUrl
)
);
}
}
@@ -0,0 +1,36 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* 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é <jeremy@derusse.com>
*/
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);
}
+39
View File
@@ -0,0 +1,39 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* 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.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
interface SolverInterface
{
/**
* Determines whether or not the solver supports a given Challenge.
*
* @return bool The solver supports the given challenge's type
*/
public function supports(AuthorizationChallenge $authorizationChallenge): bool;
/**
* Solve the given authorization challenge.
*/
public function solve(AuthorizationChallenge $authorizationChallenge);
/**
* Cleanup the environments after a successful challenge.
*/
public function cleanup(AuthorizationChallenge $authorizationChallenge);
}
@@ -0,0 +1,36 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* 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 pre validator.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
interface ValidatorInterface
{
/**
* Determines whether or not the validator supports a given Challenge.
*
* @return bool The validator supports the given challenge's type
*/
public function supports(AuthorizationChallenge $authorizationChallenge, SolverInterface $solver): bool;
/**
* Internally validate the challenge by performing the same kind of test than the CA.
*
* @return bool The challenge is valid
*/
public function isValid(AuthorizationChallenge $authorizationChallenge, SolverInterface $solver): bool;
}
@@ -0,0 +1,60 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* 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 validator who implement a retry strategy till the decorated validator successfully validate the
* challenge or the timeout is reached.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class WaitingValidator implements ValidatorInterface
{
/** @var ValidatorInterface */
private $validator;
/** @var int */
private $timeout;
public function __construct(ValidatorInterface $validator, int $timeout = 180)
{
$this->validator = $validator;
$this->timeout = $timeout;
}
/**
* {@inheritdoc}
*/
public function supports(AuthorizationChallenge $authorizationChallenge, SolverInterface $solver): bool
{
return $this->validator->supports($authorizationChallenge, $solver);
}
/**
* {@inheritdoc}
*/
public function isValid(AuthorizationChallenge $authorizationChallenge, SolverInterface $solver): bool
{
$limitEndTime = time() + $this->timeout;
do {
if ($this->validator->isValid($authorizationChallenge, $solver)) {
return true;
}
sleep(3);
} while ($limitEndTime > time());
return false;
}
}