From 3472bd1b3c3684c06286d6ff36ab05cd89aee672 Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Mon, 28 Mar 2016 15:40:08 +0200 Subject: [PATCH 1/9] Basic working 'auto' command --- bin/acme | 1 + src/Commands/Auto.php | 212 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 src/Commands/Auto.php diff --git a/bin/acme b/bin/acme index d56c3ba..090b22f 100755 --- a/bin/acme +++ b/bin/acme @@ -40,6 +40,7 @@ HELP; require __DIR__ . "/../vendor/autoload.php"; $commands = [ + "auto" => "Setup, issue and renew based on a single configuration file.", "setup" => "Setup and register account.", "issue" => "Issue a new certificate.", "check" => "Check if a certificate is still valid long enough.", diff --git a/src/Commands/Auto.php b/src/Commands/Auto.php new file mode 100644 index 0000000..c69d82e --- /dev/null +++ b/src/Commands/Auto.php @@ -0,0 +1,212 @@ +climate = $climate; + } + + public function execute(Manager $args) { + return \Amp\resolve($this->doExecute($args)); + } + + /** + * @param Manager $args + * @return \Generator + */ + private function doExecute(Manager $args) { + $server = $args->get("server"); + $storage = $args->get("storage"); + $configPath = $args->get("config"); + + try { + $config = Yaml::parse( + yield \Amp\File\get($configPath) + ); + } catch (FilesystemException $e) { + $this->climate->error("Config file ({$configPath}) not found."); + yield new CoroutineResult(1); + return; + } + + if (!isset($config["email"])) { + $this->climate->error("Config file ({$configPath}) didn't have a 'email' set."); + yield new CoroutineResult(2); + return; + } + + if (!isset($config["certificates"])) { + $this->climate->error("Config file ({$configPath}) didn't have a 'certificates' section."); + yield new CoroutineResult(2); + return; + } + + if (isset($config["storage"])) { + $storage = $config["storage"]; + } + + if (isset($config["server"])) { + $server = $config["server"]; + } + + $command = implode(" ", array_map("escapeshellarg", [ + PHP_BINARY, + $GLOBALS["argv"][0], + "setup", + "--server", + $server, + "--storage", + $storage, + "--email", + $config["email"], + ])); + + $process = new Process($command); + $result = (yield $process->exec()); + + if ($result->exit !== 0) { + $this->climate->error("Registration failed ({$result->exit})"); + $this->climate->error($command); + yield new CoroutineResult(3); + return; + } + + $promises = []; + + foreach ($config["certificates"] as $certificate) { + $promises[] = \Amp\resolve($this->checkAndIssue($certificate, $server, $storage)); + } + + list($errors) = (yield \Amp\any($promises)); + + if (!empty($errors)) { + foreach ($errors as $i => $error) { + $certificate = $config["certificates"][$i]; + $this->climate->error("Issuance for the following domains failed: " . implode(", ", array_keys($this->toDomainPathMap((array) $certificate->paths)))); + $this->climate->error("Reason: {$error}"); + } + + yield new CoroutineResult(3); + return; + } + } + + /** + * @param array $certificate certificate configuration + * @param string $server server to use for issuance + * @param string $storage storage directory + * @return \Generator + * @throws AcmeException if something does wrong + */ + private function checkAndIssue(array $certificate, $server, $storage) { + $domainPathMap = $this->toDomainPathMap((array) $certificate["paths"]); + $commonName = reset(array_keys($domainPathMap)); + + $args = [ + PHP_BINARY, + $GLOBALS["argv"][0], + "check", + "--server", + $server, + "--storage", + $storage, + "--name", + $commonName, + ]; + + $command = implode(" ", array_map("escapeshellarg", $args)); + + $process = new Process($command); + $result = (yield $process->exec()); + + if ($result->exit === 0) { + // No need for renewal + return; + } + + if ($result->exit === 1) { + // Renew certificate + + $args = [ + PHP_BINARY, + $GLOBALS["argv"][0], + "issue", + "--server", + $server, + "--storage", + $storage, + "-d", + implode(",", array_keys($domainPathMap)), + "-p", + implode(PATH_SEPARATOR, array_values($domainPathMap)), + ]; + + if (isset($certificate["user"])) { + $args[] = "--user"; + $args[] = $certificate["user"]; + } + + if (isset($certificate["bits"])) { + $args[] = "--bits"; + $args[] = $certificate["bits"]; + } + + $command = implode(" ", array_map("escapeshellarg", $args)); + + $process = new Process($command); + $result = (yield $process->exec()); + + if ($result->exit !== 0) { + throw new AcmeException("Unexpected exit code ({$result->exit}) for '{$command}'."); + } + + return; + } + + throw new AcmeException("Unexpected exit code ({$result->exit}) for '{$command}'."); + } + + private function toDomainPathMap(array $paths) { + $result = []; + + foreach ($paths as $pathDomainMap) { + foreach ($pathDomainMap as $path => $domains) { + $domains = (array) $domains; + + foreach ($domains as $domain) { + if (isset($result[$domain])) { + throw new \LogicException("Duplicate domain: {$domain}"); + } + + $result[$domain] = $path; + } + } + } + + return $result; + } + + public static function getDefinition() { + return [ + "server" => \Kelunik\AcmeClient\getArgumentDescription("server"), + "storage" => \Kelunik\AcmeClient\getArgumentDescription("storage"), + "config" => [ + "prefix" => "c", + "longPrefix" => "config", + "description" => "Configuration file to read.", + "required" => true, + ], + ]; + } +} \ No newline at end of file From 583318fa0be8e55248b22fd24de52e435abf48d3 Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Fri, 3 Jun 2016 19:38:47 +0200 Subject: [PATCH 2/9] Scan for config in /etc and ~, improve automation command --- src/Commands/Auto.php | 74 ++++++++++++++++++++++++----------------- src/Commands/Setup.php | 2 -- src/ConfigException.php | 5 +++ src/functions.php | 66 +++++++++++++++++++++++------------- 4 files changed, 90 insertions(+), 57 deletions(-) create mode 100644 src/ConfigException.php diff --git a/src/Commands/Auto.php b/src/Commands/Auto.php index c69d82e..7bfd5b1 100644 --- a/src/Commands/Auto.php +++ b/src/Commands/Auto.php @@ -8,6 +8,7 @@ use Amp\Process; use Kelunik\Acme\AcmeException; use League\CLImate\Argument\Manager; use League\CLImate\CLImate; +use Symfony\Component\Yaml\Exception\ParseException; use Symfony\Component\Yaml\Yaml; class Auto implements Command { @@ -38,6 +39,10 @@ class Auto implements Command { $this->climate->error("Config file ({$configPath}) not found."); yield new CoroutineResult(1); return; + } catch (ParseException $e) { + $this->climate->error("Config file ({$configPath}) had an invalid format and couldn't be parsed."); + yield new CoroutineResult(1); + return; } if (!isset($config["email"])) { @@ -46,20 +51,12 @@ class Auto implements Command { return; } - if (!isset($config["certificates"])) { - $this->climate->error("Config file ({$configPath}) didn't have a 'certificates' section."); + if (!isset($config["certificates"]) || !is_array($config["certificates"])) { + $this->climate->error("Config file ({$configPath}) didn't have a 'certificates' section that's an array."); yield new CoroutineResult(2); return; } - if (isset($config["storage"])) { - $storage = $config["storage"]; - } - - if (isset($config["server"])) { - $server = $config["server"]; - } - $command = implode(" ", array_map("escapeshellarg", [ PHP_BINARY, $GLOBALS["argv"][0], @@ -73,27 +70,36 @@ class Auto implements Command { ])); $process = new Process($command); - $result = (yield $process->exec()); + $result = (yield $process->exec(Process::BUFFER_ALL)); if ($result->exit !== 0) { $this->climate->error("Registration failed ({$result->exit})"); $this->climate->error($command); + $this->climate->br()->out($result->out); + $this->climate->br()->error($result->err); yield new CoroutineResult(3); return; } - $promises = []; + $certificateChunks = array_chunk($config["certificates"], 10); - foreach ($config["certificates"] as $certificate) { - $promises[] = \Amp\resolve($this->checkAndIssue($certificate, $server, $storage)); + $errors = []; + + foreach ($certificateChunks as $chunk) { + $promises = []; + + foreach ($chunk as $certificate) { + $promises[] = \Amp\resolve($this->checkAndIssue($certificate, $server, $storage)); + } + + list($errors) = (yield \Amp\any($promises)); + $errors = array_merge($errors, $errors); } - list($errors) = (yield \Amp\any($promises)); - if (!empty($errors)) { foreach ($errors as $i => $error) { $certificate = $config["certificates"][$i]; - $this->climate->error("Issuance for the following domains failed: " . implode(", ", array_keys($this->toDomainPathMap((array) $certificate->paths)))); + $this->climate->error("Issuance for the following domains failed: " . implode(", ", array_keys($this->toDomainPathMap($certificate["paths"])))); $this->climate->error("Reason: {$error}"); } @@ -110,7 +116,7 @@ class Auto implements Command { * @throws AcmeException if something does wrong */ private function checkAndIssue(array $certificate, $server, $storage) { - $domainPathMap = $this->toDomainPathMap((array) $certificate["paths"]); + $domainPathMap = $this->toDomainPathMap($certificate["paths"]); $commonName = reset(array_keys($domainPathMap)); $args = [ @@ -137,7 +143,6 @@ class Auto implements Command { if ($result->exit === 1) { // Renew certificate - $args = [ PHP_BINARY, $GLOBALS["argv"][0], @@ -146,9 +151,9 @@ class Auto implements Command { $server, "--storage", $storage, - "-d", + "--domains", implode(",", array_keys($domainPathMap)), - "-p", + "--path", implode(PATH_SEPARATOR, array_values($domainPathMap)), ]; @@ -180,17 +185,15 @@ class Auto implements Command { private function toDomainPathMap(array $paths) { $result = []; - foreach ($paths as $pathDomainMap) { - foreach ($pathDomainMap as $path => $domains) { - $domains = (array) $domains; + foreach ($paths as $path => $domains) { + $domains = (array) $domains; - foreach ($domains as $domain) { - if (isset($result[$domain])) { - throw new \LogicException("Duplicate domain: {$domain}"); - } - - $result[$domain] = $path; + foreach ($domains as $domain) { + if (isset($result[$domain])) { + throw new \LogicException("Duplicate domain: {$domain}"); } + + $result[$domain] = $path; } } @@ -198,7 +201,7 @@ class Auto implements Command { } public static function getDefinition() { - return [ + $args = [ "server" => \Kelunik\AcmeClient\getArgumentDescription("server"), "storage" => \Kelunik\AcmeClient\getArgumentDescription("storage"), "config" => [ @@ -208,5 +211,14 @@ class Auto implements Command { "required" => true, ], ]; + + $configPath = \Kelunik\AcmeClient\getConfigPath(); + + if ($configPath) { + $args["config"]["required"] = false; + $args["config"]["defaultValue"] = $configPath; + } + + return $args; } } \ No newline at end of file diff --git a/src/Commands/Setup.php b/src/Commands/Setup.php index 99c1d8e..082260e 100644 --- a/src/Commands/Setup.php +++ b/src/Commands/Setup.php @@ -85,8 +85,6 @@ class Setup implements Command { } public static function getDefinition() { - - return [ "server" => \Kelunik\AcmeClient\getArgumentDescription("server"), "storage" => \Kelunik\AcmeClient\getArgumentDescription("storage"), diff --git a/src/ConfigException.php b/src/ConfigException.php new file mode 100644 index 0000000..e7b9308 --- /dev/null +++ b/src/ConfigException.php @@ -0,0 +1,5 @@ +getMessage()); + if (isset($config["server"]) && !is_string($config["server"])) { + throw new ConfigException("'server' set, but not a string."); } + + if (isset($config["storage"]) && !is_string($config["storage"])) { + throw new ConfigException("'storage' set, but not a string."); + } + } catch (ParseException $e) { + throw new AcmeException("Unable to parse the configuration ({$configPath}): " . $e->getMessage()); } } @@ -168,6 +184,8 @@ function getArgumentDescription($argument) { return $argument; case "storage": + $isPhar = isPhar(); + $argument = [ "longPrefix" => "storage", "description" => "Storage directory for account keys and certificates.", From 0722e104d4e83db679704016a8a101771f4bd7c3 Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Fri, 3 Jun 2016 23:46:38 +0200 Subject: [PATCH 3/9] Update deps, use email in setup from config if present --- composer.lock | 54 +++++++++++++++++++++--------------------- src/Commands/Auto.php | 4 ++-- src/Commands/Setup.php | 16 ++++++++++++- 3 files changed, 44 insertions(+), 30 deletions(-) diff --git a/composer.lock b/composer.lock index ccc17f0..49229b7 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,8 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "hash": "c27b6e29c15798ac2cf219d7993e5771", - "content-hash": "a0b266ee9280981bcacbfac39d0969eb", + "hash": "f645228f022f95d362e3cfa543321bd7", + "content-hash": "af2e73ef42c235311d53fcf4eb5aa5ab", "packages": [ { "name": "amphp/amp", @@ -1577,16 +1577,16 @@ }, { "name": "phpunit/php-code-coverage", - "version": "3.3.3", + "version": "4.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "44cd8e3930e431658d1a5de7d282d5cb37837fd5" + "reference": "900370c81280cc0d942ffbc5912d80464eaee7e9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/44cd8e3930e431658d1a5de7d282d5cb37837fd5", - "reference": "44cd8e3930e431658d1a5de7d282d5cb37837fd5", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/900370c81280cc0d942ffbc5912d80464eaee7e9", + "reference": "900370c81280cc0d942ffbc5912d80464eaee7e9", "shasum": "" }, "require": { @@ -1600,7 +1600,7 @@ }, "require-dev": { "ext-xdebug": ">=2.1.4", - "phpunit/phpunit": "~5" + "phpunit/phpunit": "^5.4" }, "suggest": { "ext-dom": "*", @@ -1610,7 +1610,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.3.x-dev" + "dev-master": "4.0.x-dev" } }, "autoload": { @@ -1636,7 +1636,7 @@ "testing", "xunit" ], - "time": "2016-05-27 16:24:29" + "time": "2016-06-03 05:03:56" }, { "name": "phpunit/php-file-iterator", @@ -1821,16 +1821,16 @@ }, { "name": "phpunit/phpunit", - "version": "5.3.4", + "version": "5.4.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "00dd95ffb48805503817ced06399017df315fe5c" + "reference": "f5726a0262e5f74f8e9cf03128798b64160c441d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/00dd95ffb48805503817ced06399017df315fe5c", - "reference": "00dd95ffb48805503817ced06399017df315fe5c", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/f5726a0262e5f74f8e9cf03128798b64160c441d", + "reference": "f5726a0262e5f74f8e9cf03128798b64160c441d", "shasum": "" }, "require": { @@ -1842,11 +1842,11 @@ "myclabs/deep-copy": "~1.3", "php": "^5.6 || ^7.0", "phpspec/prophecy": "^1.3.1", - "phpunit/php-code-coverage": "^3.3.0", + "phpunit/php-code-coverage": "^4.0", "phpunit/php-file-iterator": "~1.4", "phpunit/php-text-template": "~1.2", "phpunit/php-timer": "^1.0.6", - "phpunit/phpunit-mock-objects": "^3.1", + "phpunit/phpunit-mock-objects": "^3.2", "sebastian/comparator": "~1.1", "sebastian/diff": "~1.2", "sebastian/environment": "~1.3", @@ -1866,7 +1866,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "5.3.x-dev" + "dev-master": "5.4.x-dev" } }, "autoload": { @@ -1892,30 +1892,30 @@ "testing", "xunit" ], - "time": "2016-05-11 13:28:45" + "time": "2016-06-03 09:59:50" }, { "name": "phpunit/phpunit-mock-objects", - "version": "3.1.3", + "version": "3.2.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit-mock-objects.git", - "reference": "151c96874bff6fe61a25039df60e776613a61489" + "reference": "314f8c44019b4dfece2571b98938574e6342be59" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit-mock-objects/zipball/151c96874bff6fe61a25039df60e776613a61489", - "reference": "151c96874bff6fe61a25039df60e776613a61489", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit-mock-objects/zipball/314f8c44019b4dfece2571b98938574e6342be59", + "reference": "314f8c44019b4dfece2571b98938574e6342be59", "shasum": "" }, "require": { "doctrine/instantiator": "^1.0.2", - "php": ">=5.6", - "phpunit/php-text-template": "~1.2", - "sebastian/exporter": "~1.2" + "php": "^5.6 || ^7.0", + "phpunit/php-text-template": "^1.2", + "sebastian/exporter": "^1.2" }, "require-dev": { - "phpunit/phpunit": "~5" + "phpunit/phpunit": "^5.4" }, "suggest": { "ext-soap": "*" @@ -1923,7 +1923,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.1.x-dev" + "dev-master": "3.2.x-dev" } }, "autoload": { @@ -1948,7 +1948,7 @@ "mock", "xunit" ], - "time": "2016-04-20 14:39:26" + "time": "2016-06-03 05:01:30" }, { "name": "rych/bytesize", diff --git a/src/Commands/Auto.php b/src/Commands/Auto.php index 7bfd5b1..f9005a9 100644 --- a/src/Commands/Auto.php +++ b/src/Commands/Auto.php @@ -85,10 +85,10 @@ class Auto implements Command { $errors = []; - foreach ($certificateChunks as $chunk) { + foreach ($certificateChunks as $certificateChunk) { $promises = []; - foreach ($chunk as $certificate) { + foreach ($certificateChunk as $certificate) { $promises[] = \Amp\resolve($this->checkAndIssue($certificate, $server, $storage)); } diff --git a/src/Commands/Setup.php b/src/Commands/Setup.php index 082260e..b6bfb8e 100644 --- a/src/Commands/Setup.php +++ b/src/Commands/Setup.php @@ -14,6 +14,7 @@ use Kelunik\AcmeClient\Stores\KeyStore; use Kelunik\AcmeClient\Stores\KeyStoreException; use League\CLImate\Argument\Manager; use League\CLImate\CLImate; +use Symfony\Component\Yaml\Yaml; class Setup implements Command { private $climate; @@ -85,7 +86,7 @@ class Setup implements Command { } public static function getDefinition() { - return [ + $args = [ "server" => \Kelunik\AcmeClient\getArgumentDescription("server"), "storage" => \Kelunik\AcmeClient\getArgumentDescription("storage"), "email" => [ @@ -94,5 +95,18 @@ class Setup implements Command { "required" => true, ], ]; + + $configPath = \Kelunik\AcmeClient\getConfigPath(); + + if ($configPath) { + $config = Yaml::parse(file_get_contents($configPath)); + + if (isset($config["email"]) && is_string($config["email"])) { + $args["email"]["required"] = false; + $args["email"]["defaultValue"] = $config["email"]; + } + } + + return $args; } } \ No newline at end of file From c4d15e2e26d499b1960c43a1312c34a93b10dbd4 Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Sat, 4 Jun 2016 19:25:46 +0200 Subject: [PATCH 4/9] Better exit codes and error messages for auto command --- src/Commands/Auto.php | 52 +++++++++++++++++++++++++++++++------------ 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/src/Commands/Auto.php b/src/Commands/Auto.php index f9005a9..e465a9b 100644 --- a/src/Commands/Auto.php +++ b/src/Commands/Auto.php @@ -12,6 +12,15 @@ use Symfony\Component\Yaml\Exception\ParseException; use Symfony\Component\Yaml\Yaml; class Auto implements Command { + const EXIT_CONFIG_ERROR = 1; + const EXIT_SETUP_ERROR = 2; + const EXIT_ISSUANCE_ERROR = 3; + const EXIT_ISSUANCE_PARTIAL = 4; + const EXIT_ISSUANCE_OK = 5; + + const STATUS_NO_CHANGE = 0; + const STATUS_RENEWED = 1; + private $climate; public function __construct(CLImate $climate) { @@ -37,23 +46,23 @@ class Auto implements Command { ); } catch (FilesystemException $e) { $this->climate->error("Config file ({$configPath}) not found."); - yield new CoroutineResult(1); + yield new CoroutineResult(self::EXIT_CONFIG_ERROR); return; } catch (ParseException $e) { $this->climate->error("Config file ({$configPath}) had an invalid format and couldn't be parsed."); - yield new CoroutineResult(1); + 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(2); + yield new CoroutineResult(self::EXIT_CONFIG_ERROR); return; } if (!isset($config["certificates"]) || !is_array($config["certificates"])) { $this->climate->error("Config file ({$configPath}) didn't have a 'certificates' section that's an array."); - yield new CoroutineResult(2); + yield new CoroutineResult(self::EXIT_CONFIG_ERROR); return; } @@ -77,13 +86,14 @@ class Auto implements Command { $this->climate->error($command); $this->climate->br()->out($result->out); $this->climate->br()->error($result->err); - yield new CoroutineResult(3); + yield new CoroutineResult(self::EXIT_SETUP_ERROR); return; } - $certificateChunks = array_chunk($config["certificates"], 10); + $certificateChunks = array_chunk($config["certificates"], 10, true); $errors = []; + $values = []; foreach ($certificateChunks as $certificateChunk) { $promises = []; @@ -92,18 +102,30 @@ class Auto implements Command { $promises[] = \Amp\resolve($this->checkAndIssue($certificate, $server, $storage)); } - list($errors) = (yield \Amp\any($promises)); - $errors = array_merge($errors, $errors); + list($chunkErrors, $chunkValues) = (yield \Amp\any($promises)); + + $errors += $chunkErrors; + $values += $chunkValues; } - if (!empty($errors)) { + $status = [ + "no_change" => count(array_filter($values, function($value) { return $value === self::STATUS_NO_CHANGE; })), + "renewed" => count(array_filter($values, function($value) { return $value === self::STATUS_RENEWED; })), + "failure" => count($errors), + ]; + + if ($status["failure"] > 0) { foreach ($errors as $i => $error) { $certificate = $config["certificates"][$i]; $this->climate->error("Issuance for the following domains failed: " . implode(", ", array_keys($this->toDomainPathMap($certificate["paths"])))); $this->climate->error("Reason: {$error}"); } - yield new CoroutineResult(3); + $exitCode = $status["renewed"] > 0 + ? self::EXIT_ISSUANCE_PARTIAL + : self::EXIT_ISSUANCE_ERROR; + + yield new CoroutineResult($exitCode); return; } } @@ -134,10 +156,11 @@ class Auto implements Command { $command = implode(" ", array_map("escapeshellarg", $args)); $process = new Process($command); - $result = (yield $process->exec()); + $result = (yield $process->exec(Process::BUFFER_ALL)); if ($result->exit === 0) { // No need for renewal + yield new CoroutineResult(self::STATUS_NO_CHANGE); return; } @@ -170,16 +193,17 @@ class Auto implements Command { $command = implode(" ", array_map("escapeshellarg", $args)); $process = new Process($command); - $result = (yield $process->exec()); + $result = (yield $process->exec(Process::BUFFER_ALL)); if ($result->exit !== 0) { - throw new AcmeException("Unexpected exit code ({$result->exit}) for '{$command}'."); + throw new AcmeException("Unexpected exit code ({$result->exit}) for '{$command}'." . PHP_EOL . $result->out . PHP_EOL . PHP_EOL . $result->err); } + yield new CoroutineResult(self::STATUS_RENEWED); return; } - throw new AcmeException("Unexpected exit code ({$result->exit}) for '{$command}'."); + throw new AcmeException("Unexpected exit code ({$result->exit}) for '{$command}'." . PHP_EOL . $result->out . PHP_EOL . PHP_EOL . $result->err); } private function toDomainPathMap(array $paths) { From 9f849691c2341e06ded6405ee3cd45d358cefb56 Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Sat, 4 Jun 2016 19:51:02 +0200 Subject: [PATCH 5/9] Fix config load path --- composer.lock | 13 ++++++++----- src/functions.php | 2 +- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/composer.lock b/composer.lock index 49229b7..d7c8dfd 100644 --- a/composer.lock +++ b/composer.lock @@ -1896,16 +1896,16 @@ }, { "name": "phpunit/phpunit-mock-objects", - "version": "3.2.0", + "version": "3.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit-mock-objects.git", - "reference": "314f8c44019b4dfece2571b98938574e6342be59" + "reference": "0dc8fd8e87e0366c22b6c25d1f43c4e2e66847b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit-mock-objects/zipball/314f8c44019b4dfece2571b98938574e6342be59", - "reference": "314f8c44019b4dfece2571b98938574e6342be59", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit-mock-objects/zipball/0dc8fd8e87e0366c22b6c25d1f43c4e2e66847b3", + "reference": "0dc8fd8e87e0366c22b6c25d1f43c4e2e66847b3", "shasum": "" }, "require": { @@ -1914,6 +1914,9 @@ "phpunit/php-text-template": "^1.2", "sebastian/exporter": "^1.2" }, + "conflict": { + "phpunit/phpunit": "<5.4.0" + }, "require-dev": { "phpunit/phpunit": "^5.4" }, @@ -1948,7 +1951,7 @@ "mock", "xunit" ], - "time": "2016-06-03 05:01:30" + "time": "2016-06-04 05:52:19" }, { "name": "rych/bytesize", diff --git a/src/functions.php b/src/functions.php index 0dc310f..b0c6c74 100644 --- a/src/functions.php +++ b/src/functions.php @@ -117,7 +117,7 @@ function normalizePath($path) { * @return string|null Resolves to the config path or null. */ function getConfigPath() { - $paths = isPhar() ? [substr(dirname(Phar::running(true)), strlen("phar://")) . "acme-client.yml"] : []; + $paths = isPhar() ? [substr(dirname(Phar::running(true)), strlen("phar://")) . "/acme-client.yml"] : []; if (strtoupper(substr(PHP_OS, 0, 3)) !== 'WIN') { if ($home = getenv("HOME")) { From 791b250742a0547189dd2a6bd3465a4f7e922cb9 Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Sat, 4 Jun 2016 20:41:48 +0200 Subject: [PATCH 6/9] Show renewals in auto command, fix reference notice, correct exit code for renewed certs --- src/Commands/Auto.php | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/Commands/Auto.php b/src/Commands/Auto.php index e465a9b..233b840 100644 --- a/src/Commands/Auto.php +++ b/src/Commands/Auto.php @@ -114,6 +114,15 @@ class Auto implements Command { "failure" => count($errors), ]; + if ($status["renewed"] > 0) { + foreach ($values as $i => $value) { + if ($value === self::STATUS_RENEWED) { + $certificate = $config["certificates"][$i]; + $this->climate->info("Certificate for " . implode(", ", array_keys($this->toDomainPathMap($certificate["paths"]))) . " successfully renewed."); + } + } + } + if ($status["failure"] > 0) { foreach ($errors as $i => $error) { $certificate = $config["certificates"][$i]; @@ -128,6 +137,11 @@ class Auto implements Command { yield new CoroutineResult($exitCode); return; } + + if ($status["renewed"] > 0) { + yield new CoroutineResult(self::EXIT_ISSUANCE_OK); + return; + } } /** @@ -139,7 +153,8 @@ class Auto implements Command { */ private function checkAndIssue(array $certificate, $server, $storage) { $domainPathMap = $this->toDomainPathMap($certificate["paths"]); - $commonName = reset(array_keys($domainPathMap)); + $domains = array_keys($domainPathMap); + $commonName = reset($domains); $args = [ PHP_BINARY, @@ -175,7 +190,7 @@ class Auto implements Command { "--storage", $storage, "--domains", - implode(",", array_keys($domainPathMap)), + implode(",", $domains), "--path", implode(PATH_SEPARATOR, array_values($domainPathMap)), ]; From de3b82da1d82a61a8f713e3526a5e153e553a864 Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Sat, 4 Jun 2016 20:46:57 +0200 Subject: [PATCH 7/9] Fix output variables for external process outputs --- src/Commands/Auto.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Commands/Auto.php b/src/Commands/Auto.php index 233b840..0efbc5d 100644 --- a/src/Commands/Auto.php +++ b/src/Commands/Auto.php @@ -84,8 +84,8 @@ class Auto implements Command { if ($result->exit !== 0) { $this->climate->error("Registration failed ({$result->exit})"); $this->climate->error($command); - $this->climate->br()->out($result->out); - $this->climate->br()->error($result->err); + $this->climate->br()->out($result->stdout); + $this->climate->br()->error($result->stderr); yield new CoroutineResult(self::EXIT_SETUP_ERROR); return; } @@ -211,14 +211,14 @@ class Auto implements Command { $result = (yield $process->exec(Process::BUFFER_ALL)); if ($result->exit !== 0) { - throw new AcmeException("Unexpected exit code ({$result->exit}) for '{$command}'." . PHP_EOL . $result->out . PHP_EOL . PHP_EOL . $result->err); + throw new AcmeException("Unexpected exit code ({$result->exit}) for '{$command}'." . PHP_EOL . $result->stdout . PHP_EOL . PHP_EOL . $result->stderr); } yield new CoroutineResult(self::STATUS_RENEWED); return; } - throw new AcmeException("Unexpected exit code ({$result->exit}) for '{$command}'." . PHP_EOL . $result->out . PHP_EOL . PHP_EOL . $result->err); + throw new AcmeException("Unexpected exit code ({$result->exit}) for '{$command}'." . PHP_EOL . $result->stdout . PHP_EOL . PHP_EOL . $result->stderr); } private function toDomainPathMap(array $paths) { From a090e99a19e6a459e828f10d18eb12c13b1dd524 Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Sat, 4 Jun 2016 21:47:08 +0200 Subject: [PATCH 8/9] Update documentation for new auto command --- doc/advanced-usage.md | 70 +++++++++++++++++++++ doc/usage.md | 140 +++++++++++++++++++++++------------------- 2 files changed, 147 insertions(+), 63 deletions(-) create mode 100644 doc/advanced-usage.md diff --git a/doc/advanced-usage.md b/doc/advanced-usage.md new file mode 100644 index 0000000..9ffd311 --- /dev/null +++ b/doc/advanced-usage.md @@ -0,0 +1,70 @@ +## Advanced Usage + +Please read the document about [basic usage](./usage.md) first. + +## Register an Account + +``` +acme-client setup --email me@example.com +``` + +After a successful registration you're able to issue certificates. +This client assumes you have a HTTP server setup and running. +You must have a document root setup in order to use this client. + +## Issue a Certificate + +``` +acme-client issue -d example.com:www.example.com -p /var/www/example.com +``` + +You can separate multiple domains (`-d`) with `,`, `:` or `;`. You can separate multiple document roots (`-p`) with your system's path separator: + * Colon (`:`) for Unix + * Semicolon (`;`) for Windows + +If you specify less paths than domains, the last one will be used for the remaining domains. + +Please note that Let's Encrypt has rate limits. Currently it's five certificates per domain per seven days. If you combine multiple subdomains in a single certificate, they count as just one certificate. If you just want to test things out, you can use their staging server, which has way higher rate limits by appending `--s letsencrypt:staging`. + +## Revoke a Certificate + +To revoke a certificate, you need a valid account key, just like for issuance. + +``` +acme-client revoke --name example.com +``` + +`--name` is the common name of the certificate that you want to revoke. + +## Renew a Certificate + +For renewal, there's the `acme-client check` subcommand. +It exists with a non-zero exit code, if the certificate is going to expire soon. +Default check time is 30 days, but you can use `--ttl` to customize it. + +You may use this as daily cron: + +``` +acme-client check --name example.com || acme-client issue ... +``` + +You can also use a more advanced script to automatically reload the server as well. For this example we assume you're using Nginx. +Something similar should work for Apache. But usually you shouldn't need any script, see [basic usage](./usage.md). + +```bash +#!/usr/bin/env bash + +acme-client check --name example.com --ttl 30 + +if [ $? -eq 1 ]; then + acme-client issue -d example.com:www.example.com -p /var/www + + if [ $? -eq 0 ]; then + nginx -t -q + + if [ $? -eq 0 ]; then + nginx -s reload + fi + fi +fi +``` diff --git a/doc/usage.md b/doc/usage.md index abb8b61..28a9207 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -1,79 +1,93 @@ -# Usage +# Basic Usage -**The client stores all data in `./data` if you're using the Composer installation method, otherwise in the directory you configured. Be sure to backup this folder regularly. It contains your account keys, domain keys and certificates.** +The client stores your account keys, domain keys and certificates in a single directory. If you're using the PHAR, +you usually configure the storage in the configuration file. If you're using it with Composer, all data is stored in `./data`. -Before you can issue certificates, you have to register an account first and read and understand the terms of service of the ACME CA you're using. -For the Let's Encrypt certificate authority, there's a [subscriber agreement](https://letsencrypt.org/repository/) you have to accept. +**Be sure to backup that directory regularly.** -By using this client you agree to any agreement and any further updates by continued usage. -You're responsible to react to updates and stop the automation if you no longer agree with the terms of service. +Before you can issue certificates, you have to register an account. You have to read and understand the terms of service +of the certificate authority you're using. For the Let's Encrypt certificate authority, there's a +[subscriber agreement](https://letsencrypt.org/repository/) you have to accept. -These usage instructions assume you have installed the client globally as a Phar. If you are using the Phar, but don't have it globally, replace `acme-client` with the location to your Phar. +By using this client you agree to any agreement and any further updates by continued usage. You're responsible to react +to updates and stop the automation if you no longer agree with the terms of service. -If you're using the client with Composer, replace `acme-client` with `bin/acme`. You have to specify the server with `-s` / `--server`, because there's currently no config file support for this installation method. +These usage instructions assume you have installed the client globally as a PHAR. If you are using the PHAR, +but don't have it globally, replace `acme-client` with the location to your PHAR or add that path to your `$PATH` variable. -## Register an Account +## Configuration -``` -acme-client setup --email me@example.com +The client can be configured using a (global) configuration file. The client takes the first available of +`./acme-client.yml` (if running as PHAR), `$HOME/.acme-client.yml`, `/etc/acme-client.yml` (if not on Windows). + +The configuration file has the following format: + +```yml +# Storage directory for certificates and keys. +storage: /etc/acme + +# Server to use. URL to the ACME directory. "letsencrypt" and "letsencrypt:staging" are valid shortcuts. +server: letsencrypt + +# E-mail to use for the setup. This e-mail will receive expiration notices from Let's Encrypt. +email: me@example.com + +# List of certificates to issue. +certificates: + # For each certificate, there are a few options. + # + # Required: paths + # Optional: bits, user + # + # paths: Map of document roots to domains. + # /tmp is used here for domains without a real document root. + # The client will place a file into $path/.well-known/acme-challenge/ to verify ownership to the CA + # + # bits: Number of bits for the domain private key + # + # user: User running the web server. Challenge files are world readable, but some servers might require to be owner + # of files they serve. + # + - bits: 4096 + paths: + /tmp: + - docs.example.org + - git.example.org + # You can have multiple certificate with different users and key options. + - user: www-data + paths: + /var/www: example.org ``` -After a successful registration you're able to issue certificates. -This client assumes you have a HTTP server setup and running. -You must have a document root setup in order to use this client. +All configuration keys are optional and can be passed as arguments directly (except for `certificates` when using `acme-client auto`). -## Issue a Certificate +## Certificate Issuance -``` -acme-client issue -d example.com:www.example.com -p /var/www/example.com -``` +You can use `acme-client auto` to issue certificates and renew them if necessary. It uses the configuration file to +determine the certificates to request. It will store certificates in the configured storage in a sub directory called `./certs`. -You can separate multiple domains (`-d`) with `,`, `:` or `;`. You can separate multiple document roots (`-p`) with your system's path separator: - * Colon (`:`) for Unix - * Semicolon (`;`) for Windows +If everything has been successful, you'll see a message for each issued certificate. If nothing has to be renewed, +the script will be quiet to be cron friendly. If an error occurs, the script will dump all available information. -If you specify less paths than domains, the last one will be used for the remaining domains. - -Please note that Let's Encrypt has rate limits. Currently it's five certificates per domain per seven days. If you combine multiple subdomains in a single certificate, they count as just one certificate. If you just want to test things out, you can use their staging server, which has way higher rate limits by appending `--s letsencrypt:staging`. - -## Revoke a Certificate - -To revoke a certificate, you need a valid account key, just like for issuance. - -``` -acme-client revoke --name example.com -``` - -`--name` is the common name of the certificate that you want to revoke. - -## Renewing a Certificate - -For renewal, there's the `acme-client check` subcommand. -It exists with a non-zero exit code, if the certificate is going to expire soon. -Default check time is 30 days, but you can use `--ttl` to customize it. - -You may use this as daily cron: - -``` -acme-client check --name example.com || acme-client issue ... -``` - -You can also use a more advanced script to automatically reload the server as well. For this example we assume you're using Nginx. Something similar should work for Apache. +You should execute `acme-client auto` as a daily cron. It's recommended to setup e-mail notifications for all output of +that script. ```bash -#!/usr/bin/env bash - -acme-client check --name example.com --ttl 30 - -if [ $? -eq 1 ]; then - acme-client issue -d example.com:www.example.com -p /var/www - - if [ $? -eq 0 ]; then - nginx -t -q - - if [ $? -eq 0 ]; then - nginx -s reload - fi - fi -fi +0 0 * * * acme-client auto; exit=$?; if [[ $exit = 4 ]] || [[ $exit = 5 ]]; then service nginx reload; fi ``` + +| Exit Code | Description | +|-----------|-------------| +| 0 | Nothing to do, all certificates still valid. | +| 1 | Config file invalid. | +| 2 | Issue during account setup. | +| 3 | Error during issuance. | +| 4 | Error during issuance, but some certificates could be renewed. | +| 5 | Everything fine, new certificates have been issued. | + +Exit codes `4` and `5` usually need a server reload, to reload the new certificates. It's already handled in the recommended +cron setup. + +If you want a more fine grained control or revoke certificates, you can have a look at the ´ +[advanced usage](./advanced-usage.md) document. The client allows to handle setup / issuance / revocation and other commands +separately from `acme-client auto`. \ No newline at end of file From e1ea62b5e79c3b286c92630ce1a94918d9ab81df Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Tue, 7 Jun 2016 09:42:52 +0200 Subject: [PATCH 9/9] Doc typo and format fixes --- doc/usage.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/doc/usage.md b/doc/usage.md index 28a9207..674281c 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -26,10 +26,12 @@ The configuration file has the following format: # Storage directory for certificates and keys. storage: /etc/acme -# Server to use. URL to the ACME directory. "letsencrypt" and "letsencrypt:staging" are valid shortcuts. +# Server to use. URL to the ACME directory. +# "letsencrypt" and "letsencrypt:staging" are valid shortcuts. server: letsencrypt -# E-mail to use for the setup. This e-mail will receive expiration notices from Let's Encrypt. +# E-mail to use for the setup. +# This e-mail will receive expiration notices from Let's Encrypt. email: me@example.com # List of certificates to issue. @@ -41,12 +43,13 @@ certificates: # # paths: Map of document roots to domains. # /tmp is used here for domains without a real document root. - # The client will place a file into $path/.well-known/acme-challenge/ to verify ownership to the CA + # The client will place a file into $path/.well-known/acme-challenge/ + # to verify ownership to the CA # # bits: Number of bits for the domain private key # - # user: User running the web server. Challenge files are world readable, but some servers might require to be owner - # of files they serve. + # user: User running the web server. Challenge files are world readable, + # but some servers might require to be owner of files they serve. # - bits: 4096 paths: @@ -88,6 +91,5 @@ that script. Exit codes `4` and `5` usually need a server reload, to reload the new certificates. It's already handled in the recommended cron setup. -If you want a more fine grained control or revoke certificates, you can have a look at the ´ -[advanced usage](./advanced-usage.md) document. The client allows to handle setup / issuance / revocation and other commands -separately from `acme-client auto`. \ No newline at end of file +If you want a more fine grained control or revoke certificates, you can have a look at the [advanced usage](./advanced-usage.md) document. The client allows to handle setup / issuance / revocation and other commands +separately from `acme-client auto`.