13 Commits

Author SHA1 Message Date
Niklas Keller
88629772c4 Refactor challenge solving so it can run in parallel 2016-03-20 15:26:04 +01:00
Niklas Keller
82578e4370 Fix multi-docroot feature 2016-03-20 15:18:57 +01:00
Niklas Keller
063eebf76d Implement multi docroot feature which is already documented, but has never been commited 2016-03-20 15:12:15 +01:00
Niklas Keller
9348c2f4ad Add note about webserver configuration and automation reconfiguration 2016-03-20 15:02:23 +01:00
Niklas Keller
2629d2bfc7 Accept also commas as separator for domains 2016-03-20 15:00:55 +01:00
Niklas Keller
9265ed6464 Further improve the README 2016-03-20 14:52:33 +01:00
Niklas Keller
a9c29ac6de Fix README formatting 2016-03-20 14:51:11 +01:00
Niklas Keller
fe0454ab44 Use tagged dependency of macfja/phar-builder 2016-03-20 14:49:36 +01:00
Niklas Keller
27bcd0d1c2 Improve installation instructions 2016-03-20 14:46:37 +01:00
Niklas Keller
3318b227ab Add migration guide for 0.1.x → 0.2.x 2016-03-20 14:45:21 +01:00
Niklas Keller
6bfa882aa0 Fix PHP 5 compat in CertificateStore 2016-03-15 13:21:44 +01:00
Niklas Keller
19bc2af47d Add certificate keyword 2016-03-13 19:23:41 +01:00
Niklas Keller
61b463ad70 Add keywords to composer.json 2016-03-13 18:26:34 +01:00
5 changed files with 158 additions and 52 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
/build/
/data/
/vendor/
/composer.lock

View File

@@ -8,14 +8,65 @@ It's an alternative for the [official client](https://github.com/letsencrypt/let
> **Warning**: This software is under development. Use at your own risk.
The client has been updated on Mar 12th in a non-backwards compatible manner. Please review the changes or use a new clone.
## Installation
**Requirements**
* PHP 5.5+
* Composer
**Instructions**
```bash
# Clone repository
git clone https://github.com/kelunik/acme-client && cd acme-client
# Checkout latest release
git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
# Install dependencies
composer install --no-dev
```
git clone https://github.com/kelunik/acme-client
cd acme-client
composer install
## Migration from 0.1.x to 0.2.x
```bash
# Start in ./data
cd data
# Move your account key to new location:
mkdir accounts
mv account/key.pem accounts/acme-v01.api.letsencrypt.org.directory.pem
# or accounts/acme-staging.api.letsencrypt.org.directory.pem if it's a staging key
# account should now be empty or contain just a config.json, you can delete the folder then
rm -rf account
# Migrate certificates to new location:
cd certs
mkdir acme-v01.api.letsencrypt.org.directory
# Move all your certificate directories
# Repeat for all directories!
mv example.com acme-v01.api.letsencrypt.org.directory
# or acme-staging.api.letsencrypt.org.directory
# Delete all config.json files which may exist
find -name "config.json" | xargs rm
# Update to current version
git checkout master && git pull
# Check out latest release
git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
# Update dependencies
composer update --no-dev
# Reconfigure your webserver to use the new paths
# and check (and fix) your automation commands.
```
## Usage

View File

@@ -1,10 +1,18 @@
{
"name": "kelunik/acme-client",
"description": "Standalone PHP ACME client.",
"keywords": [
"ACME",
"letsencrypt",
"certificate",
"https",
"encryption",
"ssl",
"tls"
],
"require": {
"amphp/process": "^0.1.1",
"bramus/monolog-colored-line-formatter": "^2",
"ext-posix": "*",
"ext-openssl": "*",
"kelunik/acme": "^0.3",
"kelunik/certificate": "^1",
@@ -33,6 +41,16 @@
]
},
"require-dev": {
"phpunit/phpunit": "^5"
"phpunit/phpunit": "^5",
"macfja/phar-builder": "^0.2.3"
},
"extra": {
"phar-builder": {
"compression": "GZip",
"name": "acme.phar",
"output-dir": "build",
"include": ["bin", "src", "vendor"],
"entry-point": "bin/acme"
}
}
}

View File

@@ -2,11 +2,13 @@
namespace Kelunik\AcmeClient\Commands;
use Amp\CombinatorException;
use Amp\Dns\Record;
use Exception;
use Kelunik\Acme\AcmeClient;
use Kelunik\Acme\AcmeException;
use Kelunik\Acme\AcmeService;
use Kelunik\Acme\KeyPair;
use Kelunik\Acme\OpenSSLKeyGenerator;
use Kelunik\AcmeClient\Stores\CertificateStore;
use Kelunik\AcmeClient\Stores\ChallengeStore;
@@ -43,9 +45,25 @@ class Issue implements Command {
}
}
$domains = array_map("trim", explode(":", $args->get("domains")));
$domains = array_map("trim", explode(":", str_replace(",", ":", $args->get("domains"))));
yield \Amp\resolve($this->checkDnsRecords($domains));
$docRoots = explode(":", str_replace("\\", "/", $args->get("path")));
$docRoots = array_map(function($root) {
return rtrim($root, "/");
}, $docRoots);
if (count($domains) < count($docRoots)) {
throw new AcmeException("Specified more document roots than domains.");
}
if (count($domains) > count($docRoots)) {
$docRoots = array_merge(
$docRoots,
array_fill(count($docRoots), count($domains) - count($docRoots), end($docRoots))
);
}
$keyStore = new KeyStore(dirname(dirname(__DIR__)) . "/data");
$server = \Kelunik\AcmeClient\resolveServer($args->get("server"));
@@ -61,51 +79,20 @@ class Issue implements Command {
$acme = new AcmeService(new AcmeClient($server, $keyPair));
foreach ($domains as $domain) {
list($location, $challenges) = (yield $acme->requestChallenges($domain));
$goodChallenges = $this->findSuitableCombination($challenges);
$promises = [];
if (empty($goodChallenges)) {
throw new AcmeException("Couldn't find any combination of challenges which this client can solve!");
foreach ($domains as $i => $domain) {
$promises[] = \Amp\resolve($this->solveChallenge($acme, $keyPair, $domain, $docRoots[$i]));
}
list($errors) = (yield \Amp\any($promises));
if (!empty($errors)) {
foreach ($errors as $error) {
$this->logger->error($error->getMessage());
}
$challenge = $challenges->challenges[reset($goodChallenges)];
$token = $challenge->token;
if (!preg_match("#^[a-zA-Z0-9-_]+$#", $token)) {
throw new AcmeException("Protocol violation: Invalid Token!");
}
$this->logger->debug("Generating payload...");
$payload = $acme->generateHttp01Payload($keyPair, $token);
$this->logger->info("Providing payload at http://{$domain}/.well-known/acme-challenge/{$token}");
$docRoot = rtrim(str_replace("\\", "/", $args->get("path")), "/");
$challengeStore = new ChallengeStore($docRoot);
try {
$challengeStore->put($token, $payload, isset($user) ? $user : null);
yield $acme->verifyHttp01Challenge($domain, $token, $payload);
$this->logger->info("Successfully self-verified challenge.");
yield $acme->answerChallenge($challenge->uri, $payload);
$this->logger->info("Answered challenge... waiting");
yield $acme->pollForChallenge($location);
$this->logger->info("Challenge successful. {$domain} is now authorized.");
yield $challengeStore->delete($token);
} catch (Exception $e) {
// no finally because generators...
yield $challengeStore->delete($token);
throw $e;
} catch (Throwable $e) {
// no finally because generators...
yield $challengeStore->delete($token);
throw $e;
}
throw new AcmeException("Issuance failed, not all challenges could be solved.");
}
$path = "certs/" . $keyFile . "/" . reset($domains) . "/key.pem";
@@ -130,6 +117,53 @@ class Issue implements Command {
$this->logger->info("Successfully issued certificate, see {$path}/" . reset($domains));
}
private function solveChallenge(AcmeService $acme, KeyPair $keyPair, $domain, $path) {
list($location, $challenges) = (yield $acme->requestChallenges($domain));
$goodChallenges = $this->findSuitableCombination($challenges);
if (empty($goodChallenges)) {
throw new AcmeException("Couldn't find any combination of challenges which this client can solve!");
}
$challenge = $challenges->challenges[reset($goodChallenges)];
$token = $challenge->token;
if (!preg_match("#^[a-zA-Z0-9-_]+$#", $token)) {
throw new AcmeException("Protocol violation: Invalid Token!");
}
$this->logger->debug("Generating payload...");
$payload = $acme->generateHttp01Payload($keyPair, $token);
$this->logger->info("Providing payload at http://{$domain}/.well-known/acme-challenge/{$token}");
$challengeStore = new ChallengeStore($path);
try {
$challengeStore->put($token, $payload, isset($user) ? $user : null);
yield $acme->verifyHttp01Challenge($domain, $token, $payload);
$this->logger->info("Successfully self-verified challenge.");
yield $acme->answerChallenge($challenge->uri, $payload);
$this->logger->info("Answered challenge... waiting");
yield $acme->pollForChallenge($location);
$this->logger->info("Challenge successful. {$domain} is now authorized.");
yield $challengeStore->delete($token);
} catch (Exception $e) {
// no finally because generators...
yield $challengeStore->delete($token);
throw $e;
} catch (Throwable $e) {
// no finally because generators...
yield $challengeStore->delete($token);
throw $e;
}
}
private function checkDnsRecords($domains) {
$promises = [];

View File

@@ -2,6 +2,7 @@
namespace Kelunik\AcmeClient\Stores;
use Amp\CoroutineResult;
use Amp\File\FilesystemException;
use InvalidArgumentException;
use Kelunik\Certificate\Certificate;
@@ -26,7 +27,8 @@ class CertificateStore {
Assert::string($name, "Name must be a string. Got: %s");
try {
return yield \Amp\File\get($this->root . "/" . $name . "/cert.pem");
$contents = (yield \Amp\File\get($this->root . "/" . $name . "/cert.pem"));
yield new CoroutineResult($contents);
} catch (FilesystemException $e) {
throw new CertificateStoreException("Failed to load certificate.", 0, $e);
}
@@ -88,4 +90,4 @@ class CertificateStore {
yield \Amp\File\rmdir($this->root . "/" . $name);
}
}
}