Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4cabf755b | ||
|
|
d78b465739 | ||
|
|
aa8186471c | ||
|
|
888588cf00 | ||
|
|
44f218d8c2 | ||
|
|
af44670353 | ||
|
|
0bd3525938 | ||
|
|
c3f8424785 | ||
|
|
040aebe993 | ||
|
|
8944aee552 | ||
|
|
5b2c47c30f | ||
|
|
ecb46af5c0 | ||
|
|
253d3f476b | ||
|
|
c6d9c2016c | ||
|
|
b4d4da7a51 | ||
|
|
0d61689da1 | ||
|
|
349a12aae6 | ||
|
|
28ae97f135 | ||
|
|
0fc0e45e57 | ||
|
|
9356a060e7 | ||
|
|
b4a722c0a9 | ||
|
|
2f73c15287 | ||
|
|
05a6f6d861 | ||
|
|
d5fdc1a3c0 | ||
|
|
cc76a6f52c | ||
|
|
74b275cf07 | ||
|
|
e9d2a59eca | ||
|
|
b9d79bbbe7 |
@@ -11,12 +11,10 @@ cache:
|
||||
- vendor
|
||||
|
||||
install:
|
||||
- phpenv config-rm xdebug.ini
|
||||
- composer self-update
|
||||
- phpenv config-rm xdebug.ini || true
|
||||
- composer config --global discard-changes true
|
||||
- if [[ ${TRAVIS_PHP_VERSION:0:3} == "5.5" ]]; then composer require --dev --no-update phpunit/phpunit ^4; fi
|
||||
- composer require satooshi/php-coveralls dev-master --dev --no-update
|
||||
- composer update --ignore-platform-reqs
|
||||
- composer update
|
||||
- composer require satooshi/php-coveralls dev-master --dev
|
||||
- composer show --installed
|
||||
|
||||
script:
|
||||
|
||||
6
bin/acme
6
bin/acme
@@ -80,6 +80,11 @@ if (!in_array(PHP_SAPI, ["cli", "phpdbg"], true)) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if (PHP_VERSION_ID < 50600) {
|
||||
$climate->yellow("You're using an older version of PHP which is no longer supported and will not even receive security fixes anymore. Have a look at http://php.net/supported-versions.php and upgrade now!");
|
||||
$climate->br(2);
|
||||
}
|
||||
|
||||
if (count($argv) === 1 || in_array($argv[1], ["-h", "help", "--help"], true)) {
|
||||
$climate->out($logo . $help);
|
||||
exit(0);
|
||||
@@ -130,6 +135,7 @@ try {
|
||||
$injector = new Injector;
|
||||
$injector->share($climate);
|
||||
$injector->share(new AcmeFactory);
|
||||
$injector->share(new Amp\Artax\Client(new Amp\Artax\Cookie\NullCookieJar));
|
||||
|
||||
$command = $injector->make($class);
|
||||
|
||||
|
||||
@@ -22,9 +22,9 @@
|
||||
"symfony/yaml": "^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^5",
|
||||
"fabpot/php-cs-fixer": "^1.9",
|
||||
"macfja/phar-builder": "dev-events-dev-files"
|
||||
"phpunit/phpunit": "^4|^5",
|
||||
"friendsofphp/php-cs-fixer": "^1.9",
|
||||
"macfja/phar-builder": "^0.2.5"
|
||||
},
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
|
||||
755
composer.lock
generated
755
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
|
||||
## Installation using Phar
|
||||
|
||||
This is the preferred installation method for usage on a production system.
|
||||
This is the preferred installation method for usage on a production system. You can download `acme-client.phar` in the [release section](https://github.com/kelunik/acme-client/releases).
|
||||
|
||||
### Requirements
|
||||
|
||||
|
||||
31
doc/usage.md
31
doc/usage.md
@@ -41,8 +41,10 @@ certificates:
|
||||
# Required: paths
|
||||
# Optional: bits, user
|
||||
#
|
||||
# paths: Map of document roots to domains.
|
||||
# /tmp is used here for domains without a real document root.
|
||||
# paths: Map of document roots to domains. Maps each path to one or multiple
|
||||
# domains. If one domain is given, it's automatically converted to an
|
||||
# array. The first domain will be the common name.
|
||||
#
|
||||
# The client will place a file into $path/.well-known/acme-challenge/
|
||||
# to verify ownership to the CA
|
||||
#
|
||||
@@ -53,9 +55,9 @@ certificates:
|
||||
#
|
||||
- bits: 4096
|
||||
paths:
|
||||
/tmp:
|
||||
- docs.example.org
|
||||
- git.example.org
|
||||
/var/www/example:
|
||||
- example.org
|
||||
- www.example.org
|
||||
# You can have multiple certificate with different users and key options.
|
||||
- user: www-data
|
||||
paths:
|
||||
@@ -75,8 +77,25 @@ the script will be quiet to be cron friendly. If an error occurs, the script wil
|
||||
You should execute `acme-client auto` as a daily cron. It's recommended to setup e-mail notifications for all output of
|
||||
that script.
|
||||
|
||||
Create a new script, e.g. in `/usr/local/bin/acme-renew`. The `PATH` might need to be modified to suit your system.
|
||||
|
||||
```bash
|
||||
0 0 * * * acme-client auto; exit=$?; if [[ $exit = 4 ]] || [[ $exit = 5 ]]; then service nginx reload; fi
|
||||
#!/usr/bin/env bash
|
||||
|
||||
export PATH='/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
|
||||
|
||||
acme-client auto
|
||||
|
||||
RC=$?
|
||||
|
||||
if [ $RC = 4 ] || [ $RC = 5 ]; then
|
||||
service nginx reload
|
||||
fi
|
||||
```
|
||||
|
||||
```sh
|
||||
# Cron Job Configuration
|
||||
0 0 * * * /usr/local/bin/acme-renew
|
||||
```
|
||||
|
||||
| Exit Code | Description |
|
||||
|
||||
@@ -37,8 +37,6 @@ class Auto implements Command {
|
||||
* @return \Generator
|
||||
*/
|
||||
private function doExecute(Manager $args) {
|
||||
$server = $args->get("server");
|
||||
$storage = $args->get("storage");
|
||||
$configPath = $args->get("config");
|
||||
|
||||
try {
|
||||
@@ -55,6 +53,30 @@ class Auto implements Command {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($args->defined("server")) {
|
||||
$config["server"] = $args->get("server");
|
||||
} else if (!isset($config["server"]) && $args->exists("server")) {
|
||||
$config["server"] = $args->get("server");
|
||||
}
|
||||
|
||||
if ($args->defined("storage")) {
|
||||
$config["storage"] = $args->get("storage");
|
||||
} else if (!isset($config["storage"]) && $args->exists("storage")) {
|
||||
$config["storage"] = $args->get("storage");
|
||||
}
|
||||
|
||||
if (!isset($config["server"])) {
|
||||
$this->climate->error("Config file ({$configPath}) didn't have a 'server' set nor was it passed as command line argument.");
|
||||
yield new CoroutineResult(self::EXIT_CONFIG_ERROR);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isset($config["storage"])) {
|
||||
$this->climate->error("Config file ({$configPath}) didn't have a 'storage' set nor was it passed as command line argument.");
|
||||
yield new CoroutineResult(self::EXIT_CONFIG_ERROR);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isset($config["email"])) {
|
||||
$this->climate->error("Config file ({$configPath}) didn't have a 'email' set.");
|
||||
yield new CoroutineResult(self::EXIT_CONFIG_ERROR);
|
||||
@@ -67,14 +89,22 @@ class Auto implements Command {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isset($config["challenge-concurrency"]) && !is_numeric($config["challenge-concurrency"])) {
|
||||
$this->climate->error("Config file ({$configPath}) defines an invalid 'challenge-concurrency' value.");
|
||||
yield new CoroutineResult(self::EXIT_CONFIG_ERROR);
|
||||
return;
|
||||
}
|
||||
|
||||
$concurrency = isset($config["challenge-concurrency"]) ? (int) $config["challenge-concurrency"] : null;
|
||||
|
||||
$command = implode(" ", array_map("escapeshellarg", [
|
||||
PHP_BINARY,
|
||||
$GLOBALS["argv"][0],
|
||||
"setup",
|
||||
"--server",
|
||||
$server,
|
||||
$config["server"],
|
||||
"--storage",
|
||||
$storage,
|
||||
$config["storage"],
|
||||
"--email",
|
||||
$config["email"],
|
||||
]));
|
||||
@@ -91,22 +121,16 @@ class Auto implements Command {
|
||||
return;
|
||||
}
|
||||
|
||||
$certificateChunks = array_chunk($config["certificates"], 10, true);
|
||||
|
||||
$errors = [];
|
||||
$values = [];
|
||||
|
||||
foreach ($certificateChunks as $certificateChunk) {
|
||||
$promises = [];
|
||||
|
||||
foreach ($certificateChunk as $certificate) {
|
||||
$promises[] = \Amp\resolve($this->checkAndIssue($certificate, $server, $storage));
|
||||
foreach ($config["certificates"] as $i => $certificate) {
|
||||
try {
|
||||
$result = (yield \Amp\resolve($this->checkAndIssue($certificate, $config["server"], $config["storage"], $concurrency)));
|
||||
$values[$i] = $result;
|
||||
} catch (\Exception $e) {
|
||||
$errors[$i] = $e;
|
||||
}
|
||||
|
||||
list($chunkErrors, $chunkValues) = (yield \Amp\any($promises));
|
||||
|
||||
$errors += $chunkErrors;
|
||||
$values += $chunkValues;
|
||||
}
|
||||
|
||||
$status = [
|
||||
@@ -149,10 +173,11 @@ class Auto implements Command {
|
||||
* @param array $certificate certificate configuration
|
||||
* @param string $server server to use for issuance
|
||||
* @param string $storage storage directory
|
||||
* @param int|null $concurrency concurrent challenges
|
||||
* @return \Generator
|
||||
* @throws AcmeException if something does wrong
|
||||
*/
|
||||
private function checkAndIssue(array $certificate, $server, $storage) {
|
||||
private function checkAndIssue(array $certificate, $server, $storage, $concurrency = null) {
|
||||
$domainPathMap = $this->toDomainPathMap($certificate["paths"]);
|
||||
$domains = array_keys($domainPathMap);
|
||||
$commonName = reset($domains);
|
||||
@@ -167,6 +192,8 @@ class Auto implements Command {
|
||||
$storage,
|
||||
"--name",
|
||||
$commonName,
|
||||
"--names",
|
||||
implode(",", $domains),
|
||||
];
|
||||
|
||||
$command = implode(" ", array_map("escapeshellarg", $args));
|
||||
@@ -206,6 +233,11 @@ class Auto implements Command {
|
||||
$args[] = $certificate["bits"];
|
||||
}
|
||||
|
||||
if ($concurrency) {
|
||||
$args[] = "--challenge-concurrency";
|
||||
$args[] = $concurrency;
|
||||
}
|
||||
|
||||
$command = implode(" ", array_map("escapeshellarg", $args));
|
||||
|
||||
$process = new Process($command);
|
||||
@@ -277,9 +309,15 @@ MESSAGE;
|
||||
}
|
||||
|
||||
public static function getDefinition() {
|
||||
$server = \Kelunik\AcmeClient\getArgumentDescription("server");
|
||||
$storage = \Kelunik\AcmeClient\getArgumentDescription("storage");
|
||||
|
||||
$server["required"] = false;
|
||||
$storage["required"] = false;
|
||||
|
||||
$args = [
|
||||
"server" => \Kelunik\AcmeClient\getArgumentDescription("server"),
|
||||
"storage" => \Kelunik\AcmeClient\getArgumentDescription("storage"),
|
||||
"server" => $server,
|
||||
"storage" => $storage,
|
||||
"config" => [
|
||||
"prefix" => "c",
|
||||
"longPrefix" => "config",
|
||||
|
||||
@@ -45,6 +45,18 @@ class Check implements Command {
|
||||
$this->climate->br();
|
||||
$this->climate->whisper(" Certificate is valid until " . date("d.m.Y", $cert->getValidTo()))->br();
|
||||
|
||||
if ($args->defined("names")) {
|
||||
$names = array_map("trim", explode(",", $args->get("names")));
|
||||
$missingNames = array_diff($names, $cert->getNames());
|
||||
|
||||
if ($missingNames) {
|
||||
$this->climate->comment(" The following names are not covered: " . implode(", ", $missingNames))->br();
|
||||
|
||||
yield new CoroutineResult(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if ($cert->getValidTo() > time() + $args->get("ttl") * 24 * 60 * 60) {
|
||||
yield new CoroutineResult(0);
|
||||
return;
|
||||
@@ -70,6 +82,11 @@ class Check implements Command {
|
||||
"defaultValue" => 30,
|
||||
"castTo" => "int",
|
||||
],
|
||||
"names" => [
|
||||
"longPrefix" => "names",
|
||||
"description" => "Names that must be covered by the certificate identified based on the common name. Names have to be separated by commas.",
|
||||
"required" => false,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -80,14 +80,24 @@ class Issue implements Command {
|
||||
$this->climate->br();
|
||||
|
||||
$acme = $this->acmeFactory->build($server, $keyPair);
|
||||
$promises = [];
|
||||
$errors = [];
|
||||
|
||||
foreach ($domains as $i => $domain) {
|
||||
$promises[] = \Amp\resolve($this->solveChallenge($acme, $keyPair, $domain, $docRoots[$i]));
|
||||
$concurrency = $args->get("challenge-concurrency");
|
||||
|
||||
$domainChunks = array_chunk($domains, \min(20, \max($concurrency, 1)), true);
|
||||
|
||||
foreach ($domainChunks as $domainChunk) {
|
||||
$promises = [];
|
||||
|
||||
foreach ($domainChunk as $i => $domain) {
|
||||
$promises[] = \Amp\resolve($this->solveChallenge($acme, $keyPair, $domain, $docRoots[$i]));
|
||||
}
|
||||
|
||||
list($chunkErrors) = (yield \Amp\any($promises));
|
||||
|
||||
$errors += $chunkErrors;
|
||||
}
|
||||
|
||||
list($errors) = (yield \Amp\any($promises));
|
||||
|
||||
if (!empty($errors)) {
|
||||
foreach ($errors as $error) {
|
||||
$this->climate->error($error->getMessage());
|
||||
@@ -145,7 +155,7 @@ class Issue implements Command {
|
||||
$challengeStore = new ChallengeStore($path);
|
||||
|
||||
try {
|
||||
$challengeStore->put($token, $payload, isset($user) ? $user : null);
|
||||
yield $challengeStore->put($token, $payload, isset($user) ? $user : null);
|
||||
|
||||
yield $acme->verifyHttp01Challenge($domain, $token, $payload);
|
||||
yield $acme->answerChallenge($challenge->uri, $payload);
|
||||
@@ -166,19 +176,33 @@ class Issue implements Command {
|
||||
}
|
||||
|
||||
private function checkDnsRecords($domains) {
|
||||
$promises = [];
|
||||
$errors = [];
|
||||
|
||||
foreach ($domains as $domain) {
|
||||
$promises[$domain] = \Amp\Dns\resolve($domain, [
|
||||
"types" => [Record::A],
|
||||
"hosts" => false,
|
||||
]);
|
||||
$domainChunks = array_chunk($domains, 10, true);
|
||||
|
||||
foreach ($domainChunks as $domainChunk) {
|
||||
$promises = [];
|
||||
|
||||
foreach ($domainChunk as $domain) {
|
||||
$promises[$domain] = \Amp\Dns\resolve($domain, [
|
||||
"types" => [Record::A, Record::AAAA],
|
||||
"hosts" => false,
|
||||
]);
|
||||
}
|
||||
|
||||
list($chunkErrors) = (yield \Amp\any($promises));
|
||||
|
||||
$errors += $chunkErrors;
|
||||
}
|
||||
|
||||
list($errors) = (yield \Amp\any($promises));
|
||||
|
||||
if (!empty($errors)) {
|
||||
throw new AcmeException("Couldn't resolve the following domains to an IPv4 record: " . implode(", ", array_keys($errors)));
|
||||
$failedDomains = implode(", ", array_keys($errors));
|
||||
$reasons = implode("\n\n", array_map(function ($exception) {
|
||||
/** @var \Exception|\Throwable $exception */
|
||||
return get_class($exception) . ": " . $exception->getMessage();
|
||||
}, $errors));
|
||||
|
||||
throw new AcmeException("Couldn't resolve the following domains to an IPv4 nor IPv6 record: {$failedDomains}\n\n{$reasons}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +253,12 @@ class Issue implements Command {
|
||||
"defaultValue" => 2048,
|
||||
"castTo" => "int",
|
||||
],
|
||||
"challenge-concurrency" => [
|
||||
"longPrefix" => "challenge-concurrency",
|
||||
"description" => "Number of challenges to be solved concurrently.",
|
||||
"defaultValue" => 10,
|
||||
"castTo" => "int",
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace Kelunik\AcmeClient\Commands;
|
||||
|
||||
use Amp\CoroutineResult;
|
||||
use Amp\Dns\NoRecordException;
|
||||
use Amp\Dns\Record;
|
||||
use Amp\Dns\ResolutionException;
|
||||
use InvalidArgumentException;
|
||||
@@ -80,8 +81,10 @@ class Setup implements Command {
|
||||
|
||||
try {
|
||||
yield \Amp\Dns\query($host, Record::MX);
|
||||
} catch (ResolutionException $e) {
|
||||
} catch (NoRecordException $e) {
|
||||
throw new AcmeException("No MX record defined for '{$host}'");
|
||||
} catch (ResolutionException $e) {
|
||||
throw new AcmeException("Dns query for an MX record on '{$host}' failed for the following reason: " . $e->getMessage(), null, $e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user