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/composer.lock b/composer.lock index ccc17f0..d7c8dfd 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,33 @@ "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.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit-mock-objects.git", - "reference": "151c96874bff6fe61a25039df60e776613a61489" + "reference": "0dc8fd8e87e0366c22b6c25d1f43c4e2e66847b3" }, "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/0dc8fd8e87e0366c22b6c25d1f43c4e2e66847b3", + "reference": "0dc8fd8e87e0366c22b6c25d1f43c4e2e66847b3", "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" + }, + "conflict": { + "phpunit/phpunit": "<5.4.0" }, "require-dev": { - "phpunit/phpunit": "~5" + "phpunit/phpunit": "^5.4" }, "suggest": { "ext-soap": "*" @@ -1923,7 +1926,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.1.x-dev" + "dev-master": "3.2.x-dev" } }, "autoload": { @@ -1948,7 +1951,7 @@ "mock", "xunit" ], - "time": "2016-04-20 14:39:26" + "time": "2016-06-04 05:52:19" }, { "name": "rych/bytesize", 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..674281c 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -1,79 +1,95 @@ -# 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`. diff --git a/src/Commands/Auto.php b/src/Commands/Auto.php new file mode 100644 index 0000000..0efbc5d --- /dev/null +++ b/src/Commands/Auto.php @@ -0,0 +1,263 @@ +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(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(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); + 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(self::EXIT_CONFIG_ERROR); + return; + } + + $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(Process::BUFFER_ALL)); + + if ($result->exit !== 0) { + $this->climate->error("Registration failed ({$result->exit})"); + $this->climate->error($command); + $this->climate->br()->out($result->stdout); + $this->climate->br()->error($result->stderr); + yield new CoroutineResult(self::EXIT_SETUP_ERROR); + 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)); + } + + list($chunkErrors, $chunkValues) = (yield \Amp\any($promises)); + + $errors += $chunkErrors; + $values += $chunkValues; + } + + $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["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]; + $this->climate->error("Issuance for the following domains failed: " . implode(", ", array_keys($this->toDomainPathMap($certificate["paths"])))); + $this->climate->error("Reason: {$error}"); + } + + $exitCode = $status["renewed"] > 0 + ? self::EXIT_ISSUANCE_PARTIAL + : self::EXIT_ISSUANCE_ERROR; + + yield new CoroutineResult($exitCode); + return; + } + + if ($status["renewed"] > 0) { + yield new CoroutineResult(self::EXIT_ISSUANCE_OK); + 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($certificate["paths"]); + $domains = array_keys($domainPathMap); + $commonName = reset($domains); + + $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(Process::BUFFER_ALL)); + + if ($result->exit === 0) { + // No need for renewal + yield new CoroutineResult(self::STATUS_NO_CHANGE); + return; + } + + if ($result->exit === 1) { + // Renew certificate + $args = [ + PHP_BINARY, + $GLOBALS["argv"][0], + "issue", + "--server", + $server, + "--storage", + $storage, + "--domains", + implode(",", $domains), + "--path", + 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(Process::BUFFER_ALL)); + + if ($result->exit !== 0) { + 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->stdout . PHP_EOL . PHP_EOL . $result->stderr); + } + + private function toDomainPathMap(array $paths) { + $result = []; + + 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; + } + } + + return $result; + } + + public static function getDefinition() { + $args = [ + "server" => \Kelunik\AcmeClient\getArgumentDescription("server"), + "storage" => \Kelunik\AcmeClient\getArgumentDescription("storage"), + "config" => [ + "prefix" => "c", + "longPrefix" => "config", + "description" => "Configuration file to read.", + "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..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,9 +86,7 @@ class Setup implements Command { } public static function getDefinition() { - - - return [ + $args = [ "server" => \Kelunik\AcmeClient\getArgumentDescription("server"), "storage" => \Kelunik\AcmeClient\getArgumentDescription("storage"), "email" => [ @@ -96,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 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.",