Merge pull request #5 from afosto/feature/dns

DNS support added
Big thanks to @lordelph for working on this
This commit is contained in:
Peter Bakker
2020-03-19 20:03:18 +01:00
committed by GitHub
5 changed files with 199 additions and 27 deletions

View File

@@ -72,21 +72,23 @@ $order = $client->createOrder(['example.org', 'www.example.org']);
In the example above the primary domain is followed by a secondary domain(s). Make sure that for each domain you are
able to prove ownership. As a result the certificate will be valid for all provided domains.
### Prove ownership
Before you can obtain a certificate for a given domain you need to prove that you own the given domain(s). In this
example we will show you how to do this for http-01 validation (where serve specific content at a specific url on the
domain, like: `example.org/.well-known/acme-challenge/*`).
Obtain the authorizations for order. For each domain supplied in the create order request an authorization is returned.
Before you can obtain a certificate for a given domain you need to prove that you own the given domain(s).
We request the authorizations to prove ownership. Obtain the authorizations for order. For each domain supplied in the
create order request an authorization is returned.
```php
$authorizations = $client->authorize($order);
```
You now have an array of `Authorization` objects. These have the challenges you can use (both `DNS` and `HTTP`) to
provide proof of ownership.
provide proof of ownership.
#### HTTP validation
HTTP validation (where serve specific content at a specific url on the domain, like:
`example.org/.well-known/acme-challenge/*`) is done as follows:
Use the following example to get the HTTP validation going. First obtain the challenges, the next step is to make the
challenges accessible from
@@ -97,26 +99,62 @@ foreach ($authorizations as $authorization) {
}
```
Now that the challenges are in place and accessible through `example.org/.well-known/acme-challenge/*` we can request
validation.
#### DNS validation
You can also use DNS validation - to do this, you will need access to an API of your DNS
provider to create TXT records for the target domains.
```php
foreach ($authorizations as $authorization) {
$txtRecord = $authorization->getTxtRecord();
//To get the name of the TXT record call:
$txtRecord->getName();
//To get the value of the TXT record call:
$txtRecord->getValue();
}
```
### Self test
After exposing the challenges (made accessible through HTTP or DNS) we can perform a self test just to
be sure it works. For a DNS test call:
```php
$client->selfTest($authorization, Client::VALIDATON_DNS);
```
For a HTTP challenge test call:
```php
$client->selfTest($authorization, Client::VALIDATION_HTTP);
```
### Request validation
Next step is to request validation of ownership. For each authorization (domain) we ask LetsEncrypt to verify the
challenge.
For HTTP validation:
```php
foreach ($authorizations as $authorization) {
if ($client->selfTest($authorization, Client::VALIDATION_HTTP)) {
$client->validate($authorization->getHttpChallenge(), 15);
}
$client->validate($authorization->getHttpChallenge(), 15);
}
```
For DNS validation:
```php
foreach ($authorizations as $authorization) {
$client->validate($authorization->getDnsChallenge(), 15);
}
```
The code above will first perform a self test and, if successful, will do 15 attempts to ask LetsEncrypt to validate the challenge (with 1 second intervals) and
retrieve an updated status (it might take Lets Encrypt a few seconds to validate the challenge).
### Get the certificate
Now to know if we can request a certificate for the order, test if the order is ready as follows:

View File

@@ -55,6 +55,11 @@ class Client
*/
const VALIDATION_HTTP = 'http-01';
/**
* DNS validation
*/
const VALIDATION_DNS = 'dns-01';
/**
* @var string
*/
@@ -262,6 +267,8 @@ class Client
{
if ($type == self::VALIDATION_HTTP) {
return $this->selfHttpTest($authorization, $maxAttempts);
} elseif ($type == self::VALIDATION_DNS) {
return $this->selfDNSTest($authorization, $maxAttempts);
}
}
@@ -289,7 +296,9 @@ class Client
$this->signPayloadKid(null, $challenge->getAuthorizationURL())
);
$data = json_decode((string)$response->getBody(), true);
sleep(ceil(15 / $maxAttempts));
if ($maxAttempts > 1) {
sleep(ceil(15 / $maxAttempts));
}
$maxAttempts--;
} while ($maxAttempts > 0 && $data['status'] != 'valid');
@@ -389,21 +398,17 @@ class Client
*/
protected function selfHttpTest(Authorization $authorization, $maxAttempts)
{
$file = $authorization->getFile();
$authorization->getDomain();
do {
$maxAttempts--;
try {
$response = $this->getSelfTestClient()->request(
'GET',
'http://' . $authorization->getDomain() . '/.well-known/acme-challenge/' . $file->getFilename()
'http://' . $authorization->getDomain() . '/.well-known/acme-challenge/' .
$authorization->getFile()->getFilename()
);
$contents = (string)$response->getBody();
if ($contents == $file->getContents()) {
{
return true;
}
if ($contents == $authorization->getFile()->getContents()) {
return true;
}
} catch (RequestException $e) {
}
@@ -412,6 +417,56 @@ class Client
return false;
}
/**
* Self DNS test client that uses Cloudflare's DNS API
* @param Authorization $authorization
* @param $maxAttempts
* @return bool
*/
protected function selfDNSTest(Authorization $authorization, $maxAttempts)
{
do {
$response = $this->getSelfTestDNSClient()->get(
'/dns-query',
[
'query' => [
'name' => $authorization->getTxtRecord()->getName(),
'type' => 'TXT'
]
]
);
$data = json_decode((string)$response->getBody(), true);
if (isset($data['Answer'])) {
foreach ($data['Answer'] as $result) {
if (trim($result['data'], "\"") == $authorization->getTxtRecord()->getValue()) {
return true;
}
}
}
if ($maxAttempts > 1) {
sleep(ceil(45 / $maxAttempts));
}
$maxAttempts--;
} while ($maxAttempts > 0);
return false;
}
/**
* Return the preconfigured client to call Cloudflare's DNS API
* @return HttpClient
*/
protected function getSelfTestDNSClient()
{
return new HttpClient([
'base_uri' => 'https://cloudflare-dns.com',
'connect_timeout' => 10,
'headers' => [
'Accept' => 'application/dns-json',
],
]);
}
/**
* Initialize the client
*/

View File

@@ -3,6 +3,7 @@
namespace Afosto\Acme\Data;
use Afosto\Acme\Client;
use Afosto\Acme\Helper;
class Authorization
{
@@ -93,6 +94,20 @@ class Authorization
return false;
}
/**
* @return Challenge|bool
*/
public function getDnsChallenge()
{
foreach ($this->getChallenges() as $challenge) {
if ($challenge->getType() == Client::VALIDATION_DNS) {
return $challenge;
}
}
return false;
}
/**
* Return File object for the given challenge
* @return File|bool
@@ -105,4 +120,22 @@ class Authorization
}
return false;
}
/**
* Returns the DNS record object
*
* @param Challenge $challenge
* @return Record|bool
*/
public function getTxtRecord()
{
$challenge = $this->getDnsChallenge();
if ($challenge !== false) {
$hash = hash('sha256', $challenge->getToken() . '.' . $this->digest, true);
$value = Helper::toSafeString($hash);
return new Record('_acme-challenge.' . $this->getDomain(), $value);
}
return false;
}
}

46
src/Data/Record.php Normal file
View File

@@ -0,0 +1,46 @@
<?php
namespace Afosto\Acme\Data;
class Record
{
/**
* @var string
*/
protected $name;
/**
* @var string
*/
protected $value;
/**
* Record constructor.
* @param string $name
* @param string $value
*/
public function __construct(string $name, string $value)
{
$this->name = $name;
$this->value = $value;
}
/**
* Return the DNS TXT record name for validation
* @return string
*/
public function getName(): string
{
return $this->name;
}
/**
* Return the record value for DNS validation
* @return string
*/
public function getValue(): string
{
return $this->value;
}
}

View File

@@ -92,11 +92,11 @@ class Helper
file_put_contents($fn, implode("\n", $config));
$csr = openssl_csr_new([
'countryName' => 'NL',
'commonName' => $primaryDomain,
'commonName' => $primaryDomain,
], $key, [
'config' => $fn,
'config' => $fn,
'req_extensions' => 'SAN',
'digest_alg' => 'sha512',
'digest_alg' => 'sha512',
]);
unlink($fn);