mirror of
https://github.com/anikeen-com/yaac.git
synced 2026-03-13 13:46:10 +00:00
Merge pull request #4 from afosto/feature/improved-validation
Improved http validation with exponential backoff
This commit is contained in:
24
README.md
24
README.md
@@ -1,6 +1,6 @@
|
||||
# yaac - Yet another ACME client
|
||||
|
||||
Written in PHP, this client aims to be a decoupled LetsEncrypt client, based on ACME V2.
|
||||
Written in PHP, this client aims to be a simplified and decoupled LetsEncrypt client, based on ACME V2.
|
||||
|
||||
## Decoupled from a filesystem or webserver
|
||||
|
||||
@@ -9,7 +9,7 @@ data (the certificate and private key).
|
||||
|
||||
## Why
|
||||
|
||||
Why whould I need this package? At Afosto we run our software in a multi tenant setup, as any other SaaS would do, and
|
||||
Why whould I need this package? At Afosto we run our software in a multi-tenant setup, as any other SaaS would do, and
|
||||
therefore we cannot make use of the many clients that are already out there.
|
||||
|
||||
Almost all clients are coupled to a type of webserver or a fixed (set of) domain(s). This package can be extremely
|
||||
@@ -92,9 +92,7 @@ Use the following example to get the HTTP validation going. First obtain the cha
|
||||
challenges accessible from
|
||||
```php
|
||||
foreach ($authorizations as $authorization) {
|
||||
$challenge = $authorization->getHttpChallenge();
|
||||
|
||||
$file = $authorization->getFile($challenge);
|
||||
$file = $authorization->getFile();
|
||||
file_put_contents($file->getFilename(), $file->getContents());
|
||||
}
|
||||
```
|
||||
@@ -109,16 +107,19 @@ challenge.
|
||||
|
||||
```php
|
||||
foreach ($authorizations as $authorization) {
|
||||
$ok = $client->validate($authorization->getHttpChallenge(), 15);
|
||||
if ($client->selfTest($authorization, Client::VALIDATION_HTTP)) {
|
||||
$client->validate($authorization->getHttpChallenge(), 15);
|
||||
}
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
The method above will perform 15 attempts to ask LetsEncrypt to validate the challenge (with 1 second intervals) and
|
||||
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 validation was successful, test if the order is ready as follows:
|
||||
Now to know if we can request a certificate for the order, test if the order is ready as follows:
|
||||
|
||||
```php
|
||||
if ($client->isReady($order)) {
|
||||
@@ -137,4 +138,9 @@ We now have the certificate, to store it on the filesystem:
|
||||
//Store the certificate and private key where you need it
|
||||
file_put_contents('certificate.cert', $certificate->getCertificate());
|
||||
file_put_contents('private.key', $certificate->getPrivateKey());
|
||||
```
|
||||
```
|
||||
|
||||
### Who is using it?
|
||||
|
||||
Are you using this package, would love to know. Please send a PR to enlist your project or company.
|
||||
- [Afosto SaaS BV](https://afosto.com)
|
||||
127
src/Client.php
127
src/Client.php
@@ -9,8 +9,8 @@ use Afosto\Acme\Data\Challenge;
|
||||
use Afosto\Acme\Data\Order;
|
||||
use GuzzleHttp\Client as HttpClient;
|
||||
use GuzzleHttp\Exception\ClientException;
|
||||
use GuzzleHttp\Exception\RequestException;
|
||||
use League\Flysystem\Filesystem;
|
||||
use LEClient\LEFunctions;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
class Client
|
||||
@@ -119,12 +119,6 @@ class Client
|
||||
public function __construct($config = [])
|
||||
{
|
||||
$this->config = $config;
|
||||
$this->httpClient = new HttpClient([
|
||||
'base_uri' => (
|
||||
($this->getOption('mode', self::MODE_LIVE) == self::MODE_LIVE) ?
|
||||
self::DIRECTORY_LIVE : self::DIRECTORY_STAGING),
|
||||
]);
|
||||
|
||||
if ($this->getOption('fs', false)) {
|
||||
$this->filesystem = $this->getOption('fs');
|
||||
} else {
|
||||
@@ -138,7 +132,6 @@ class Client
|
||||
$this->init();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get an existing order by ID
|
||||
*
|
||||
@@ -160,7 +153,7 @@ class Client
|
||||
|
||||
return new Order(
|
||||
$domains,
|
||||
$response->getHeaderLine('location'),
|
||||
$url,
|
||||
$data['status'],
|
||||
$data['expires'],
|
||||
$data['identifiers'],
|
||||
@@ -196,7 +189,7 @@ class Client
|
||||
foreach ($domains as $domain) {
|
||||
$identifiers[] =
|
||||
[
|
||||
'type' => 'dns',
|
||||
'type' => 'dns',
|
||||
'value' => $domain,
|
||||
];
|
||||
}
|
||||
@@ -258,6 +251,20 @@ class Client
|
||||
return $authorizations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a self-test for the authorization
|
||||
* @param Authorization $authorization
|
||||
* @param string $type
|
||||
* @param int $maxAttempts
|
||||
* @return bool
|
||||
*/
|
||||
public function selfTest(Authorization $authorization, $type = self::VALIDATION_HTTP, $maxAttempts = 15): bool
|
||||
{
|
||||
if ($type == self::VALIDATION_HTTP) {
|
||||
return $this->selfHttpTest($authorization, $maxAttempts);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a challenge
|
||||
*
|
||||
@@ -277,14 +284,14 @@ class Client
|
||||
|
||||
$data = [];
|
||||
do {
|
||||
$maxAttempts--;
|
||||
$response = $this->request(
|
||||
$challenge->getAuthorizationURL(),
|
||||
$this->signPayloadKid(null, $challenge->getAuthorizationURL())
|
||||
);
|
||||
$data = json_decode((string)$response->getBody(), true);
|
||||
sleep(1);
|
||||
} while ($maxAttempts > 0 && $data['status'] == 'pending');
|
||||
sleep(ceil(15 / $maxAttempts));
|
||||
$maxAttempts--;
|
||||
} while ($maxAttempts > 0 && $data['status'] != 'valid');
|
||||
|
||||
return (isset($data['status']) && $data['status'] == 'valid');
|
||||
}
|
||||
@@ -344,13 +351,74 @@ class Client
|
||||
return new Account($data['contact'], $date, ($data['status'] == 'valid'), $data['initialIp'], $accountURL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ACME api configured Guzzle Client
|
||||
* @return HttpClient
|
||||
*/
|
||||
protected function getHttpClient()
|
||||
{
|
||||
if ($this->httpClient === null) {
|
||||
$this->httpClient = new HttpClient([
|
||||
'base_uri' => (
|
||||
($this->getOption('mode', self::MODE_LIVE) == self::MODE_LIVE) ?
|
||||
self::DIRECTORY_LIVE : self::DIRECTORY_STAGING),
|
||||
]);
|
||||
}
|
||||
return $this->httpClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Guzzle Client configured for self test
|
||||
* @return HttpClient
|
||||
*/
|
||||
protected function getSelfTestClient()
|
||||
{
|
||||
return new HttpClient([
|
||||
'verify' => false,
|
||||
'timeout' => 10,
|
||||
'connect_timeout' => 3,
|
||||
'allow_redirects' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Self HTTP test
|
||||
* @param Authorization $authorization
|
||||
* @param $maxAttempts
|
||||
* @return bool
|
||||
*/
|
||||
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()
|
||||
);
|
||||
$contents = (string)$response->getBody();
|
||||
if ($contents == $file->getContents()) {
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (RequestException $e) {
|
||||
}
|
||||
} while ($maxAttempts > 0);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the client
|
||||
*/
|
||||
protected function init()
|
||||
{
|
||||
//Load the directories from the LE api
|
||||
$response = $this->httpClient->get('/directory');
|
||||
$response = $this->getHttpClient()->get('/directory');
|
||||
$result = \GuzzleHttp\json_decode((string)$response->getBody(), true);
|
||||
$this->directories = $result;
|
||||
|
||||
@@ -388,7 +456,7 @@ class Client
|
||||
$this->getUrl(self::DIRECTORY_NEW_ACCOUNT),
|
||||
$this->signPayloadJWK(
|
||||
[
|
||||
'contact' => [
|
||||
'contact' => [
|
||||
'mailto:' . $this->getOption('username'),
|
||||
],
|
||||
'termsOfServiceAgreed' => true,
|
||||
@@ -415,6 +483,7 @@ class Client
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the Flysystem filesystem
|
||||
* @return Filesystem
|
||||
*/
|
||||
protected function getFilesystem(): Filesystem
|
||||
@@ -465,8 +534,8 @@ class Client
|
||||
protected function request($url, $payload = [], $method = 'POST'): ResponseInterface
|
||||
{
|
||||
try {
|
||||
$response = $this->httpClient->request($method, $url, [
|
||||
'json' => $payload,
|
||||
$response = $this->getHttpClient()->request($method, $url, [
|
||||
'json' => $payload,
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/jose+json',
|
||||
]
|
||||
@@ -526,9 +595,9 @@ class Client
|
||||
protected function getJWKHeader(): array
|
||||
{
|
||||
return [
|
||||
'e' => Helper::toSafeString(Helper::getKeyDetails($this->getAccountKey())['rsa']['e']),
|
||||
'e' => Helper::toSafeString(Helper::getKeyDetails($this->getAccountKey())['rsa']['e']),
|
||||
'kty' => 'RSA',
|
||||
'n' => Helper::toSafeString(Helper::getKeyDetails($this->getAccountKey())['rsa']['n']),
|
||||
'n' => Helper::toSafeString(Helper::getKeyDetails($this->getAccountKey())['rsa']['n']),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -543,14 +612,14 @@ class Client
|
||||
{
|
||||
//Require a nonce to be available
|
||||
if ($this->nonce === null) {
|
||||
$response = $this->httpClient->head($this->directories[self::DIRECTORY_NEW_NONCE]);
|
||||
$response = $this->getHttpClient()->head($this->directories[self::DIRECTORY_NEW_NONCE]);
|
||||
$this->nonce = $response->getHeaderLine('replay-nonce');
|
||||
}
|
||||
return [
|
||||
'alg' => 'RS256',
|
||||
'jwk' => $this->getJWKHeader(),
|
||||
'alg' => 'RS256',
|
||||
'jwk' => $this->getJWKHeader(),
|
||||
'nonce' => $this->nonce,
|
||||
'url' => $url
|
||||
'url' => $url
|
||||
];
|
||||
}
|
||||
|
||||
@@ -563,14 +632,14 @@ class Client
|
||||
*/
|
||||
protected function getKID($url): array
|
||||
{
|
||||
$response = $this->httpClient->head($this->directories[self::DIRECTORY_NEW_NONCE]);
|
||||
$response = $this->getHttpClient()->head($this->directories[self::DIRECTORY_NEW_NONCE]);
|
||||
$nonce = $response->getHeaderLine('replay-nonce');
|
||||
|
||||
return [
|
||||
"alg" => "RS256",
|
||||
"kid" => $this->account->getAccountURL(),
|
||||
"alg" => "RS256",
|
||||
"kid" => $this->account->getAccountURL(),
|
||||
"nonce" => $nonce,
|
||||
"url" => $url
|
||||
"url" => $url
|
||||
];
|
||||
}
|
||||
|
||||
@@ -596,7 +665,7 @@ class Client
|
||||
|
||||
return [
|
||||
'protected' => $protected,
|
||||
'payload' => $payload,
|
||||
'payload' => $payload,
|
||||
'signature' => Helper::toSafeString($signature),
|
||||
];
|
||||
}
|
||||
@@ -622,7 +691,7 @@ class Client
|
||||
|
||||
return [
|
||||
'protected' => $protected,
|
||||
'payload' => $payload,
|
||||
'payload' => $payload,
|
||||
'signature' => Helper::toSafeString($signature),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -31,6 +31,14 @@ class Account
|
||||
protected $accountURL;
|
||||
|
||||
|
||||
/**
|
||||
* Account constructor.
|
||||
* @param array $contact
|
||||
* @param \DateTime $createdAt
|
||||
* @param bool $isValid
|
||||
* @param string $initialIp
|
||||
* @param string $accountURL
|
||||
*/
|
||||
public function __construct(
|
||||
array $contact,
|
||||
\DateTime $createdAt,
|
||||
@@ -45,23 +53,35 @@ class Account
|
||||
$this->accountURL = $accountURL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the account ID
|
||||
* @return string
|
||||
*/
|
||||
public function getId(): string
|
||||
{
|
||||
return substr($this->accountURL, strrpos($this->accountURL, '/') + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return create date for the account
|
||||
* @return \DateTime
|
||||
*/
|
||||
public function getCreatedAt(): \DateTime
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Return the URL for the account
|
||||
* @return string
|
||||
*/
|
||||
public function getAccountURL(): string
|
||||
{
|
||||
return $this->accountURL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return contact data
|
||||
* @return array
|
||||
*/
|
||||
public function getContact(): array
|
||||
@@ -70,6 +90,7 @@ class Account
|
||||
}
|
||||
|
||||
/**
|
||||
* Return initial IP
|
||||
* @return string
|
||||
*/
|
||||
public function getInitialIp(): string
|
||||
@@ -78,6 +99,7 @@ class Account
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns validation status
|
||||
* @return bool
|
||||
*/
|
||||
public function isValid(): bool
|
||||
|
||||
@@ -27,6 +27,13 @@ class Authorization
|
||||
*/
|
||||
protected $digest;
|
||||
|
||||
/**
|
||||
* Authorization constructor.
|
||||
* @param string $domain
|
||||
* @param string $expires
|
||||
* @param string $digest
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function __construct(string $domain, string $expires, string $digest)
|
||||
{
|
||||
$this->domain = $domain;
|
||||
@@ -34,13 +41,18 @@ class Authorization
|
||||
$this->digest = $digest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a challenge to the authorization
|
||||
* @param Challenge $challenge
|
||||
*/
|
||||
public function addChallenge(Challenge $challenge)
|
||||
{
|
||||
$this->challenges[] = $challenge;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
* Return the domain that is being authorized
|
||||
* @return string
|
||||
*/
|
||||
public function getDomain(): string
|
||||
{
|
||||
@@ -49,6 +61,7 @@ class Authorization
|
||||
|
||||
|
||||
/**
|
||||
* Return the expiry of the authorization
|
||||
* @return \DateTime
|
||||
*/
|
||||
public function getExpires(): \DateTime
|
||||
@@ -57,6 +70,7 @@ class Authorization
|
||||
}
|
||||
|
||||
/**
|
||||
* Return array of challenges
|
||||
* @return Challenge[]
|
||||
*/
|
||||
public function getChallenges(): array
|
||||
@@ -65,6 +79,7 @@ class Authorization
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the HTTP challenge
|
||||
* @return Challenge|bool
|
||||
*/
|
||||
public function getHttpChallenge()
|
||||
@@ -79,14 +94,14 @@ class Authorization
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Challenge $challenge
|
||||
* Return File object for the given challenge
|
||||
* @return File|bool
|
||||
*/
|
||||
public function getFile(Challenge $challenge)
|
||||
public function getFile()
|
||||
{
|
||||
if ($challenge->getType() == Client::VALIDATION_HTTP) {
|
||||
$file = new File($challenge->getToken(), $challenge->getToken() . '.' . $this->digest);
|
||||
return $file;
|
||||
$challenge = $this->getHttpChallenge();
|
||||
if ($challenge !== false) {
|
||||
return new File($challenge->getToken(), $challenge->getToken() . '.' . $this->digest);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ class Certificate
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the certificate signing request
|
||||
* @return string
|
||||
*/
|
||||
public function getCsr(): string
|
||||
@@ -51,6 +52,7 @@ class Certificate
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the expiry date of the current certificate
|
||||
* @return \DateTime
|
||||
*/
|
||||
public function getExpiryDate(): \DateTime
|
||||
@@ -59,6 +61,7 @@ class Certificate
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the certificate as a multi line string
|
||||
* @return string
|
||||
*/
|
||||
public function getCertificate(): string
|
||||
@@ -67,6 +70,7 @@ class Certificate
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the private key as a multi line string
|
||||
* @return string
|
||||
*/
|
||||
public function getPrivateKey(): string
|
||||
|
||||
@@ -48,6 +48,7 @@ class Challenge
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URL for the challenge
|
||||
* @return string
|
||||
*/
|
||||
public function getUrl(): string
|
||||
@@ -56,6 +57,7 @@ class Challenge
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns challenge type (DNS or HTTP)
|
||||
* @return string
|
||||
*/
|
||||
public function getType(): string
|
||||
@@ -64,6 +66,7 @@ class Challenge
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the token
|
||||
* @return string
|
||||
*/
|
||||
public function getToken(): string
|
||||
@@ -71,11 +74,19 @@ class Challenge
|
||||
return $this->token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the status
|
||||
* @return string
|
||||
*/
|
||||
public function getStatus(): string
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns authorization URL
|
||||
* @return string
|
||||
*/
|
||||
public function getAuthorizationURL(): string
|
||||
{
|
||||
return $this->authorizationURL;
|
||||
|
||||
@@ -15,7 +15,11 @@ class File
|
||||
*/
|
||||
protected $contents;
|
||||
|
||||
|
||||
/**
|
||||
* File constructor.
|
||||
* @param string $filename
|
||||
* @param string $contents
|
||||
*/
|
||||
public function __construct(string $filename, string $contents)
|
||||
{
|
||||
$this->contents = $contents;
|
||||
@@ -23,6 +27,7 @@ class File
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the filename for HTTP validation
|
||||
* @return string
|
||||
*/
|
||||
public function getFilename(): string
|
||||
@@ -31,6 +36,7 @@ class File
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the file contents for HTTP validation
|
||||
* @return string
|
||||
*/
|
||||
public function getContents(): string
|
||||
|
||||
@@ -41,7 +41,17 @@ class Order
|
||||
*/
|
||||
protected $domains;
|
||||
|
||||
|
||||
/**
|
||||
* Order constructor.
|
||||
* @param array $domains
|
||||
* @param string $url
|
||||
* @param string $status
|
||||
* @param string $expiresAt
|
||||
* @param array $identifiers
|
||||
* @param array $authorizations
|
||||
* @param string $finalizeURL
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function __construct(
|
||||
array $domains,
|
||||
string $url,
|
||||
@@ -51,6 +61,10 @@ class Order
|
||||
array $authorizations,
|
||||
string $finalizeURL
|
||||
) {
|
||||
//Handle the microtime date format
|
||||
if (strpos($expiresAt, '.') !== false) {
|
||||
$expiresAt = substr($expiresAt, 0, strpos($expiresAt, '.')) . 'Z';
|
||||
}
|
||||
$this->domains = $domains;
|
||||
$this->url = $url;
|
||||
$this->status = $status;
|
||||
@@ -60,41 +74,74 @@ class Order
|
||||
$this->finalizeURL = $finalizeURL;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns the order number
|
||||
* @return string
|
||||
*/
|
||||
public function getId(): string
|
||||
{
|
||||
return substr($this->url, strrpos($this->url, '/') + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the order URL
|
||||
* @return string
|
||||
*/
|
||||
public function getURL(): string
|
||||
{
|
||||
return $this->url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return set of authorizations for the order
|
||||
* @return Authorization[]
|
||||
*/
|
||||
public function getAuthorizationURLs(): array
|
||||
{
|
||||
return $this->authorizations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns order status
|
||||
* @return string
|
||||
*/
|
||||
public function getStatus(): string
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns expires at
|
||||
* @return \DateTime
|
||||
*/
|
||||
public function getExpiresAt(): \DateTime
|
||||
{
|
||||
return $this->expiresAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returs domains as identifiers
|
||||
* @return array
|
||||
*/
|
||||
public function getIdentifiers(): array
|
||||
{
|
||||
return $this->identifiers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns url
|
||||
* @return string
|
||||
*/
|
||||
public function getFinalizeURL(): string
|
||||
{
|
||||
return $this->finalizeURL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns domains for the order
|
||||
* @return array
|
||||
*/
|
||||
public function getDomains(): array
|
||||
{
|
||||
return $this->domains;
|
||||
|
||||
@@ -6,6 +6,11 @@ use Afosto\Acme\Data\Authorization;
|
||||
use GuzzleHttp\Client as HttpClient;
|
||||
use GuzzleHttp\Exception\ClientException;
|
||||
|
||||
/**
|
||||
* Class Helper
|
||||
* This class contains helper methods for certificate handling
|
||||
* @package Afosto\Acme
|
||||
*/
|
||||
class Helper
|
||||
{
|
||||
|
||||
|
||||
Reference in New Issue
Block a user