Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44f218d8c2 | ||
|
|
af44670353 | ||
|
|
0bd3525938 | ||
|
|
c3f8424785 | ||
|
|
040aebe993 | ||
|
|
8944aee552 | ||
|
|
5b2c47c30f | ||
|
|
ecb46af5c0 | ||
|
|
253d3f476b | ||
|
|
c6d9c2016c | ||
|
|
b4d4da7a51 | ||
|
|
0d61689da1 | ||
|
|
349a12aae6 | ||
|
|
28ae97f135 | ||
|
|
0fc0e45e57 | ||
|
|
9356a060e7 | ||
|
|
b4a722c0a9 | ||
|
|
2f73c15287 | ||
|
|
05a6f6d861 | ||
|
|
d5fdc1a3c0 | ||
|
|
cc76a6f52c | ||
|
|
74b275cf07 | ||
|
|
e9d2a59eca | ||
|
|
b9d79bbbe7 | ||
|
|
f0c09881ea | ||
|
|
07f9a03702 | ||
|
|
d04e758598 | ||
|
|
029f4c533a | ||
|
|
c02e758a21 | ||
|
|
e1ea62b5e7 | ||
|
|
a090e99a19 | ||
|
|
de3b82da1d | ||
|
|
791b250742 | ||
|
|
9f849691c2 | ||
|
|
c4d15e2e26 | ||
|
|
0722e104d4 | ||
|
|
583318fa0b | ||
|
|
3472bd1b3c | ||
|
|
6d31cec6ad | ||
|
|
8cbb3d02a8 | ||
|
|
d7b71dab24 | ||
|
|
944adf0c06 | ||
|
|
a1d65c1483 | ||
|
|
fb0509ae7e | ||
|
|
866b172c5f | ||
|
|
8d085347b9 | ||
|
|
fc3b7e948f | ||
|
|
2b2daee8bb | ||
|
|
e4b9203537 | ||
|
|
c94d9b4795 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
/build/
|
||||
/data/
|
||||
/info/
|
||||
/vendor/
|
||||
/composer.lock
|
||||
/config.test.yml
|
||||
@@ -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:
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
`kelunik/acme-client` is an ACME client written in PHP. ACME is the protocol that powers the [Let's Encrypt](https://letsencrypt.org) certificate authority.
|
||||
|
||||
## Requirements
|
||||
|
||||
* PHP 5.5+ with OpenSSL
|
||||
* Works on Unix and Windows
|
||||
|
||||
## Documentation
|
||||
|
||||
* [Installation](./doc/installation.md)
|
||||
|
||||
106
bin/acme
106
bin/acme
@@ -2,17 +2,22 @@
|
||||
<?php
|
||||
|
||||
use Auryn\Injector;
|
||||
use Kelunik\AcmeClient\AcmeFactory;
|
||||
use League\CLImate\CLImate;
|
||||
|
||||
$logo = <<<LOGO
|
||||
____ __________ ___ ___
|
||||
/ __ `/ ___/ __ `__ \/ _ \
|
||||
/ /_/ / /__/ / / / / / __/
|
||||
\__,_/\___/_/ /_/ /_/\___/
|
||||
|
||||
LOGO;
|
||||
|
||||
if (!file_exists(__DIR__ . "/../vendor/autoload.php")) {
|
||||
echo $logo;
|
||||
echo <<<HELP
|
||||
|
||||
____ __________ ___ ___
|
||||
/ __ `/ ___/ __ `__ \/ _ \
|
||||
/ /_/ / /__/ / / / / / __/
|
||||
\__,_/\___/_/ /_/ /_/\___/
|
||||
|
||||
You need to install the composer dependencies.
|
||||
You need to install the composer dependencies.
|
||||
|
||||
composer install --no-dev
|
||||
|
||||
@@ -22,14 +27,10 @@ HELP;
|
||||
}
|
||||
|
||||
if (!function_exists("openssl_pkey_get_private")) {
|
||||
echo $logo;
|
||||
echo <<<HELP
|
||||
|
||||
____ __________ ___ ___
|
||||
/ __ `/ ___/ __ `__ \/ _ \
|
||||
/ /_/ / /__/ / / / / / __/
|
||||
\__,_/\___/_/ /_/ /_/\___/
|
||||
|
||||
You need to enable OpenSSL in your php.ini
|
||||
You need to enable OpenSSL in your php.ini
|
||||
|
||||
|
||||
HELP;
|
||||
@@ -39,55 +40,63 @@ HELP;
|
||||
require __DIR__ . "/../vendor/autoload.php";
|
||||
|
||||
$commands = [
|
||||
"setup",
|
||||
"issue",
|
||||
"check",
|
||||
"revoke",
|
||||
"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.",
|
||||
"revoke" => "Revoke a certificate.",
|
||||
"status" => "Show status about local certificates.",
|
||||
"version" => "Print version information.",
|
||||
"help" => "Print this help information.",
|
||||
];
|
||||
|
||||
$binary = \Kelunik\AcmeClient\getBinary();
|
||||
|
||||
$help = implode("\n ", array_map(function ($command) use ($binary) {
|
||||
return "{$binary} {$command}";
|
||||
}, $commands));
|
||||
$help = implode(PHP_EOL, array_map(function ($command) use ($commands) {
|
||||
$help = " <green>{$command}</green>\n";
|
||||
$help .= " └─ {$commands[$command]}\n";
|
||||
return $help;
|
||||
}, array_keys($commands)));
|
||||
|
||||
$help = <<<EOT
|
||||
|
||||
____ __________ ___ ___
|
||||
/ __ `/ ___/ __ `__ \/ _ \
|
||||
/ /_/ / /__/ / / / / / __/
|
||||
\__,_/\___/_/ /_/ /_/\___/
|
||||
<yellow>Usage:</yellow>
|
||||
bin/acme [command] [--args]
|
||||
|
||||
Usage: bin/acme command --args
|
||||
|
||||
Available Commands:
|
||||
{$help}
|
||||
|
||||
Get more help by appending --help to specific commands.
|
||||
<yellow>Options:</yellow>
|
||||
<green>-h, --help</green>
|
||||
└─ Print this help information.
|
||||
|
||||
<yellow>Available commands:</yellow>
|
||||
{$help}
|
||||
Get more help by appending <yellow>--help</yellow> to specific commands.
|
||||
|
||||
EOT;
|
||||
|
||||
$climate = new CLImate;
|
||||
$injector = new Injector;
|
||||
|
||||
if (!in_array(PHP_SAPI, ["cli", "phpdbg"], true)) {
|
||||
$climate->error("Please run this script via CLI!");
|
||||
$climate->error("Please run this script on the command line!");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if (count($argv) === 1 || in_array($argv[1], ["h", "-h", "help", "--help"], true)) {
|
||||
$climate->out($help);
|
||||
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);
|
||||
}
|
||||
|
||||
if (!in_array($argv[1], $commands)) {
|
||||
$climate->br()->error(" Unknown command '{$argv[1]}'. Use --help for a list of available commands.");
|
||||
if (!in_array($argv[1], array_keys($commands))) {
|
||||
$climate->error("Unknown command '{$argv[1]}'. Use --help for a list of available commands.");
|
||||
|
||||
$suggestion = \Kelunik\AcmeClient\suggestCommand($argv[1], $commands);
|
||||
$suggestion = \Kelunik\AcmeClient\suggestCommand($argv[1], array_keys($commands));
|
||||
|
||||
if ($suggestion) {
|
||||
$climate->br()->out(" Did you mean '$suggestion'?");
|
||||
$climate->br()->out(" Did you mean '$suggestion'?");
|
||||
}
|
||||
|
||||
$climate->br();
|
||||
@@ -104,25 +113,28 @@ try {
|
||||
unset($args[1]);
|
||||
|
||||
$climate->arguments->add($definition);
|
||||
$climate->arguments->parse(array_values($args));
|
||||
} catch (Exception $e) {
|
||||
if (count($argv) === 3 && in_array($argv[2], ["h", "-h", "--help", "help"], true)) {
|
||||
|
||||
if (count($argv) === 3 && in_array($argv[2], ["-h", "--help"], true)) {
|
||||
$climate->usage(["{$binary} {$argv[1]}"]);
|
||||
$climate->br();
|
||||
|
||||
exit(0);
|
||||
} else {
|
||||
$climate->usage(["{$binary} {$argv[1]}"]);
|
||||
$climate->br();
|
||||
|
||||
$climate->error($e->getMessage());
|
||||
$climate->br();
|
||||
|
||||
exit(1);
|
||||
$climate->arguments->parse(array_values($args));
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$climate->usage(["{$binary} {$argv[1]}"]);
|
||||
$climate->br();
|
||||
|
||||
$climate->error($e->getMessage());
|
||||
$climate->br();
|
||||
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$injector = new Injector;
|
||||
$injector->share($climate);
|
||||
$injector->share(new AcmeFactory);
|
||||
|
||||
$command = $injector->make($class);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kelunik/acme-client",
|
||||
"description": "Standalone PHP ACME client.",
|
||||
"description": "Let's Encrypt / ACME client written in PHP for the CLI.",
|
||||
"keywords": [
|
||||
"ACME",
|
||||
"letsencrypt",
|
||||
@@ -22,9 +22,9 @@
|
||||
"symfony/yaml": "^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^5",
|
||||
"fabpot/php-cs-fixer": "^1.9",
|
||||
"macfja/phar-builder": "dev-master#a2db582eab26ef7b15144c013408749a79fae361"
|
||||
"phpunit/phpunit": "^4|^5",
|
||||
"friendsofphp/php-cs-fixer": "^1.9",
|
||||
"macfja/phar-builder": "^0.2.5"
|
||||
},
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
@@ -43,13 +43,30 @@
|
||||
"src/functions.php"
|
||||
]
|
||||
},
|
||||
"repositories": [
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "https://github.com/kelunik/pharbuilder"
|
||||
}
|
||||
],
|
||||
"extra": {
|
||||
"phar-builder": {
|
||||
"compression": "GZip",
|
||||
"name": "acme-client.phar",
|
||||
"output-dir": "build",
|
||||
"include": ["src", "vendor/kelunik/acme/res", "vendor/amphp/socket/var"],
|
||||
"entry-point": "bin/acme"
|
||||
"include": ["info", "src", "vendor/kelunik/acme/res", "vendor/amphp/socket/var"],
|
||||
"entry-point": "bin/acme",
|
||||
"events": {
|
||||
"command.package.start": [
|
||||
"mkdir -p info",
|
||||
"git describe --tags > info/build.version",
|
||||
"php -r 'echo time();' > info/build.time"
|
||||
],
|
||||
"command.package.end": [
|
||||
"rm -rf info",
|
||||
"chmod +x build/acme-client.phar"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
3195
composer.lock
generated
Normal file
3195
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
70
doc/advanced-usage.md
Normal file
70
doc/advanced-usage.md
Normal file
@@ -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
|
||||
```
|
||||
147
doc/usage.md
147
doc/usage.md
@@ -1,79 +1,98 @@
|
||||
# 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. 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
|
||||
#
|
||||
# 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:
|
||||
/var/www/example:
|
||||
- example.org
|
||||
- www.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
|
||||
|
||||
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`.
|
||||
|
||||
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.
|
||||
|
||||
You should execute `acme-client auto` as a daily cron. It's recommended to setup e-mail notifications for all output of
|
||||
that script.
|
||||
|
||||
```sh
|
||||
0 0 * * * /usr/local/sbin/acme-client auto; RC=$?; if [ $RC = 4 ] || [ $RC = 5 ]; then /usr/sbin/service nginx reload; fi
|
||||
```
|
||||
acme-client issue -d example.com:www.example.com -p /var/www/example.com
|
||||
```
|
||||
The path to `acme-client` should be modified to suit your system. The full path should be used as the system path may not be set up in your cron environment.
|
||||
|
||||
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
|
||||
| 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. |
|
||||
|
||||
If you specify less paths than domains, the last one will be used for the remaining domains.
|
||||
Exit codes `4` and `5` usually need a server reload, to reload the new certificates. It's already handled in the recommended
|
||||
cron setup.
|
||||
|
||||
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.
|
||||
|
||||
```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
|
||||
```
|
||||
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`.
|
||||
|
||||
16
src/AcmeFactory.php
Normal file
16
src/AcmeFactory.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace Kelunik\AcmeClient;
|
||||
|
||||
use Kelunik\Acme\AcmeClient;
|
||||
use Kelunik\Acme\AcmeService;
|
||||
use Kelunik\Acme\KeyPair;
|
||||
use Webmozart\Assert\Assert;
|
||||
|
||||
class AcmeFactory {
|
||||
public function build($directory, KeyPair $keyPair) {
|
||||
Assert::string($directory);
|
||||
|
||||
return new AcmeService(new AcmeClient($directory, $keyPair));
|
||||
}
|
||||
}
|
||||
330
src/Commands/Auto.php
Normal file
330
src/Commands/Auto.php
Normal file
@@ -0,0 +1,330 @@
|
||||
<?php
|
||||
|
||||
namespace Kelunik\AcmeClient\Commands;
|
||||
|
||||
use Amp\CoroutineResult;
|
||||
use Amp\File\FilesystemException;
|
||||
use Amp\Process;
|
||||
use Kelunik\Acme\AcmeException;
|
||||
use Kelunik\AcmeClient\ConfigException;
|
||||
use League\CLImate\Argument\Manager;
|
||||
use League\CLImate\CLImate;
|
||||
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) {
|
||||
$this->climate = $climate;
|
||||
}
|
||||
|
||||
public function execute(Manager $args) {
|
||||
return \Amp\resolve($this->doExecute($args));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Manager $args
|
||||
* @return \Generator
|
||||
*/
|
||||
private function doExecute(Manager $args) {
|
||||
$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 ($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);
|
||||
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",
|
||||
$config["server"],
|
||||
"--storage",
|
||||
$config["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, $config["server"], $config["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,
|
||||
"--names",
|
||||
implode(",", $domains),
|
||||
];
|
||||
|
||||
$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) {
|
||||
if (is_numeric($path)) {
|
||||
$message = <<<MESSAGE
|
||||
Your configuration has the wrong format. Received a numeric value as path name.
|
||||
|
||||
This is most probably due to your "paths" value not being a map but a list instead.
|
||||
|
||||
If your configuration looks like this:
|
||||
|
||||
certificates:
|
||||
- paths:
|
||||
- /www/a: a.example.org
|
||||
- /www/b: b.example.org
|
||||
|
||||
Rewrite it to the following format for a single certificate:
|
||||
|
||||
certificates:
|
||||
- paths:
|
||||
/www/a: a.example.org
|
||||
/www/b: b.example.org
|
||||
|
||||
Rewrite it to the following format for two separate certificates:
|
||||
|
||||
certificates:
|
||||
- paths:
|
||||
/www/a: a.example.org
|
||||
- paths:
|
||||
/www/b: b.example.org
|
||||
|
||||
Documentation is available at https://github.com/kelunik/acme-client/blob/master/doc/usage.md#configuration
|
||||
|
||||
If this doesn't solve your issue, please reply to the following issue: https://github.com/kelunik/acme-client/issues/30
|
||||
MESSAGE;
|
||||
|
||||
throw new ConfigException($message);
|
||||
}
|
||||
|
||||
$domains = (array) $domains;
|
||||
|
||||
foreach ($domains as $domain) {
|
||||
if (isset($result[$domain])) {
|
||||
throw new ConfigException("Duplicate domain: {$domain}");
|
||||
}
|
||||
|
||||
$result[$domain] = $path;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public static function getDefinition() {
|
||||
$server = \Kelunik\AcmeClient\getArgumentDescription("server");
|
||||
$storage = \Kelunik\AcmeClient\getArgumentDescription("storage");
|
||||
|
||||
$server["required"] = false;
|
||||
$storage["required"] = false;
|
||||
|
||||
$args = [
|
||||
"server" => $server,
|
||||
"storage" => $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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,11 @@ namespace Kelunik\AcmeClient\Commands;
|
||||
use Amp\CoroutineResult;
|
||||
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\AcmeFactory;
|
||||
use Kelunik\AcmeClient\Stores\CertificateStore;
|
||||
use Kelunik\AcmeClient\Stores\ChallengeStore;
|
||||
use Kelunik\AcmeClient\Stores\KeyStore;
|
||||
@@ -21,9 +21,11 @@ use Throwable;
|
||||
|
||||
class Issue implements Command {
|
||||
private $climate;
|
||||
private $acmeFactory;
|
||||
|
||||
public function __construct(CLImate $climate) {
|
||||
public function __construct(CLImate $climate, AcmeFactory $acmeFactory) {
|
||||
$this->climate = $climate;
|
||||
$this->acmeFactory = $acmeFactory;
|
||||
}
|
||||
|
||||
public function execute(Manager $args) {
|
||||
@@ -77,15 +79,23 @@ class Issue implements Command {
|
||||
|
||||
$this->climate->br();
|
||||
|
||||
$acme = new AcmeService(new AcmeClient($server, $keyPair));
|
||||
$promises = [];
|
||||
$acme = $this->acmeFactory->build($server, $keyPair);
|
||||
$errors = [];
|
||||
|
||||
foreach ($domains as $i => $domain) {
|
||||
$promises[] = \Amp\resolve($this->solveChallenge($acme, $keyPair, $domain, $docRoots[$i]));
|
||||
$domainChunks = array_chunk($domains, 10, 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());
|
||||
@@ -143,7 +153,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);
|
||||
@@ -164,19 +174,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,4 +253,4 @@ class Issue implements Command {
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,7 @@ namespace Kelunik\AcmeClient\Commands;
|
||||
|
||||
use Amp\CoroutineResult;
|
||||
use Amp\File\FilesystemException;
|
||||
use Kelunik\Acme\AcmeClient;
|
||||
use Kelunik\Acme\AcmeService;
|
||||
use Kelunik\AcmeClient\AcmeFactory;
|
||||
use Kelunik\AcmeClient\Stores\CertificateStore;
|
||||
use Kelunik\AcmeClient\Stores\KeyStore;
|
||||
use Kelunik\Certificate\Certificate;
|
||||
@@ -14,9 +13,11 @@ use League\CLImate\CLImate;
|
||||
|
||||
class Revoke implements Command {
|
||||
private $climate;
|
||||
private $acmeFactory;
|
||||
|
||||
public function __construct(CLImate $climate) {
|
||||
public function __construct(CLImate $climate, AcmeFactory $acmeFactory) {
|
||||
$this->climate = $climate;
|
||||
$this->acmeFactory = $acmeFactory;
|
||||
}
|
||||
|
||||
public function execute(Manager $args) {
|
||||
@@ -30,7 +31,7 @@ class Revoke implements Command {
|
||||
$keyFile = \Kelunik\AcmeClient\serverToKeyname($server);
|
||||
|
||||
$keyPair = (yield $keyStore->get("accounts/{$keyFile}.pem"));
|
||||
$acme = new AcmeService(new AcmeClient($server, $keyPair), $keyPair);
|
||||
$acme = $this->acmeFactory->build($server, $keyPair);
|
||||
|
||||
$this->climate->br();
|
||||
$this->climate->whisper(" Revoking certificate ...");
|
||||
@@ -57,7 +58,7 @@ class Revoke implements Command {
|
||||
$this->climate->br();
|
||||
$this->climate->info(" Certificate has been revoked.");
|
||||
|
||||
yield (new CertificateStore(\Kelunik\AcmeClient\normalizePath($args->get("storage")). "/certs/" . $keyFile))->delete($args->get("name"));
|
||||
yield (new CertificateStore(\Kelunik\AcmeClient\normalizePath($args->get("storage")) . "/certs/" . $keyFile))->delete($args->get("name"));
|
||||
|
||||
yield new CoroutineResult(0);
|
||||
}
|
||||
|
||||
@@ -3,24 +3,27 @@
|
||||
namespace Kelunik\AcmeClient\Commands;
|
||||
|
||||
use Amp\CoroutineResult;
|
||||
use Amp\Dns\NoRecordException;
|
||||
use Amp\Dns\Record;
|
||||
use Amp\Dns\ResolutionException;
|
||||
use InvalidArgumentException;
|
||||
use Kelunik\Acme\AcmeClient;
|
||||
use Kelunik\Acme\AcmeException;
|
||||
use Kelunik\Acme\AcmeService;
|
||||
use Kelunik\Acme\OpenSSLKeyGenerator;
|
||||
use Kelunik\Acme\Registration;
|
||||
use Kelunik\AcmeClient\AcmeFactory;
|
||||
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;
|
||||
private $acmeFactory;
|
||||
|
||||
public function __construct(CLImate $climate) {
|
||||
public function __construct(CLImate $climate, AcmeFactory $acmeFactory) {
|
||||
$this->climate = $climate;
|
||||
$this->acmeFactory = $acmeFactory;
|
||||
}
|
||||
|
||||
public function execute(Manager $args) {
|
||||
@@ -53,7 +56,7 @@ class Setup implements Command {
|
||||
$this->climate->whisper(" Generated new private key with {$bits} bits.");
|
||||
}
|
||||
|
||||
$acme = new AcmeService(new AcmeClient($server, $keyPair), $keyPair);
|
||||
$acme = $this->acmeFactory->build($server, $keyPair);
|
||||
|
||||
$this->climate->whisper(" Registering with " . substr($server, 8) . " ...");
|
||||
|
||||
@@ -78,15 +81,15 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
public static function getDefinition() {
|
||||
|
||||
|
||||
return [
|
||||
$args = [
|
||||
"server" => \Kelunik\AcmeClient\getArgumentDescription("server"),
|
||||
"storage" => \Kelunik\AcmeClient\getArgumentDescription("storage"),
|
||||
"email" => [
|
||||
@@ -95,5 +98,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;
|
||||
}
|
||||
}
|
||||
80
src/Commands/Status.php
Normal file
80
src/Commands/Status.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace Kelunik\AcmeClient\Commands;
|
||||
|
||||
use Kelunik\AcmeClient\Stores\CertificateStore;
|
||||
use Kelunik\AcmeClient\Stores\KeyStore;
|
||||
use Kelunik\AcmeClient\Stores\KeyStoreException;
|
||||
use Kelunik\Certificate\Certificate;
|
||||
use League\CLImate\Argument\Manager;
|
||||
use League\CLImate\CLImate;
|
||||
|
||||
class Status {
|
||||
private $climate;
|
||||
|
||||
public function __construct(CLImate $climate) {
|
||||
$this->climate = $climate;
|
||||
}
|
||||
|
||||
public function execute(Manager $args) {
|
||||
return \Amp\resolve($this->doExecute($args));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Manager $args
|
||||
* @return \Generator
|
||||
*/
|
||||
private function doExecute(Manager $args) {
|
||||
$server = \Kelunik\AcmeClient\resolveServer($args->get("server"));
|
||||
$keyName = \Kelunik\AcmeClient\serverToKeyname($server);
|
||||
|
||||
$storage = \Kelunik\AcmeClient\normalizePath($args->get("storage"));
|
||||
|
||||
try {
|
||||
$keyStore = new KeyStore($storage);
|
||||
yield $keyStore->get("accounts/{$keyName}.pem");
|
||||
|
||||
$setup = true;
|
||||
} catch (KeyStoreException $e) {
|
||||
$setup = false;
|
||||
}
|
||||
|
||||
$this->climate->br();
|
||||
$this->climate->out(" [" . ($setup ? "<green> ✓ </green>" : "<red> ✗ </red>") . "] " . ($setup ? "Registered on " : "Not yet registered on ") . $server);
|
||||
$this->climate->br();
|
||||
|
||||
if (yield \Amp\File\exists($storage . "/certs/{$keyName}")) {
|
||||
$certificateStore = new CertificateStore($storage . "/certs/{$keyName}");
|
||||
|
||||
$domains = (yield \Amp\File\scandir($storage . "/certs/{$keyName}"));
|
||||
|
||||
foreach ($domains as $domain) {
|
||||
$pem = (yield $certificateStore->get($domain));
|
||||
$cert = new Certificate($pem);
|
||||
|
||||
$symbol = time() > $cert->getValidTo() ? "<red> ✗ </red>" : "<green> ✓ </green>";
|
||||
|
||||
if (time() < $cert->getValidTo() && time() + $args->get("ttl") * 24 * 60 * 60 > $cert->getValidTo()) {
|
||||
$symbol = "<yellow> ⭮ </yellow>";
|
||||
}
|
||||
|
||||
$this->climate->out(" [" . $symbol . "] " . implode(", ", $cert->getNames()));
|
||||
}
|
||||
|
||||
$this->climate->br();
|
||||
}
|
||||
}
|
||||
|
||||
public static function getDefinition() {
|
||||
return [
|
||||
"server" => \Kelunik\AcmeClient\getArgumentDescription("server"),
|
||||
"storage" => \Kelunik\AcmeClient\getArgumentDescription("storage"),
|
||||
"ttl" => [
|
||||
"longPrefix" => "ttl",
|
||||
"description" => "Minimum valid time in days, shows ⭮ if renewal is required.",
|
||||
"defaultValue" => 30,
|
||||
"castTo" => "int",
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
76
src/Commands/Version.php
Normal file
76
src/Commands/Version.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace Kelunik\AcmeClient\Commands;
|
||||
|
||||
use League\CLImate\Argument\Manager;
|
||||
use League\CLImate\CLImate;
|
||||
use RuntimeException;
|
||||
|
||||
class Version implements Command {
|
||||
private $climate;
|
||||
|
||||
public function __construct(CLImate $climate) {
|
||||
$this->climate = $climate;
|
||||
}
|
||||
|
||||
public function execute(Manager $args) {
|
||||
$version = $this->getVersion();
|
||||
|
||||
$buildTime = $this->readFileOr("info/build.time", time());
|
||||
$buildDate = date('M jS Y H:i:s T', (int) trim($buildTime));
|
||||
|
||||
$package = json_decode($this->readFileOr("composer.json", new RuntimeException("No composer.json found.")));
|
||||
|
||||
$this->climate->out("┌ <green>kelunik/acme-client</green> @ <yellow>{$version}</yellow> (built: {$buildDate})");
|
||||
$this->climate->out(($args->defined("deps") ? "│" : "└") . " " . $this->getDescription($package));
|
||||
|
||||
if ($args->defined("deps")) {
|
||||
$lockFile = json_decode($this->readFileOr("composer.lock", new RuntimeException("No composer.lock found.")));
|
||||
$packages = $lockFile->packages;
|
||||
|
||||
for ($i = 0; $i < count($packages); $i++) {
|
||||
$link = $i === count($packages) - 1 ? "└──" : "├──";
|
||||
$this->climate->out("{$link} <green>{$packages[$i]->name}</green> @ <yellow>{$packages[$i]->version}</yellow>");
|
||||
|
||||
$link = $i === count($packages) - 1 ? " " : "│ ";
|
||||
$this->climate->out("{$link} " . $this->getDescription($packages[$i]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function getDescription($package) {
|
||||
return \Kelunik\AcmeClient\ellipsis(isset($package->description) ? $package->description : "");
|
||||
}
|
||||
|
||||
private function getVersion() {
|
||||
if (file_exists(__DIR__ . "/../../.git")) {
|
||||
$version = `git describe --tags`;
|
||||
} else {
|
||||
$version = $this->readFileOr("info/build.version", "-unknown");
|
||||
}
|
||||
|
||||
return substr(trim($version), 1);
|
||||
}
|
||||
|
||||
private function readFileOr($file, $default = "") {
|
||||
if (file_exists(__DIR__ . "/../../" . $file)) {
|
||||
return file_get_contents(__DIR__ . "/../../" . $file);
|
||||
} else {
|
||||
if ($default instanceof \Exception || $default instanceof \Throwable) {
|
||||
throw $default;
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
|
||||
public static function getDefinition() {
|
||||
return [
|
||||
"deps" => [
|
||||
"longPrefix" => "deps",
|
||||
"description" => "Show also the bundled dependency versions.",
|
||||
"noValue" => true,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
5
src/ConfigException.php
Normal file
5
src/ConfigException.php
Normal file
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace Kelunik\AcmeClient;
|
||||
|
||||
class ConfigException extends \Exception { }
|
||||
@@ -68,7 +68,11 @@ class KeyStore {
|
||||
try {
|
||||
// TODO: Replace with async version once available
|
||||
if (!file_exists(dirname($file))) {
|
||||
mkdir(dirname($file), 0755, true);
|
||||
$success = mkdir(dirname($file), 0755, true);
|
||||
|
||||
if (!$success) {
|
||||
throw new KeyStoreException("Could not create key store directory.");
|
||||
}
|
||||
}
|
||||
|
||||
yield \Amp\File\put($file, $keyPair->getPrivate());
|
||||
|
||||
@@ -2,12 +2,21 @@
|
||||
|
||||
namespace Kelunik\AcmeClient;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use Kelunik\Acme\AcmeException;
|
||||
use Phar;
|
||||
use Symfony\Component\Yaml\Exception\ParseException;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
use Webmozart\Assert\Assert;
|
||||
|
||||
/**
|
||||
* Suggests a command based on similarity in a list of available commands.
|
||||
*
|
||||
* @param string $badCommand invalid command
|
||||
* @param array $commands list of available commands
|
||||
* @param int $suggestThreshold similarity threshold
|
||||
* @return string suggestion or empty string if no command is similar enough
|
||||
*/
|
||||
function suggestCommand($badCommand, array $commands, $suggestThreshold = 70) {
|
||||
Assert::string($badCommand, "Bad command must be a string. Got: %s");
|
||||
Assert::integer($suggestThreshold, "Suggest threshold must be an integer. Got: %s");
|
||||
@@ -30,6 +39,13 @@ function suggestCommand($badCommand, array $commands, $suggestThreshold = 70) {
|
||||
return $bestMatchPercentage >= $suggestThreshold ? $bestMatch : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a server to a valid URI. If a valid shortcut is passed, it's resolved to the defined URI. If a URI without
|
||||
* protocol is passed, it will default to HTTPS.
|
||||
*
|
||||
* @param string $uri URI to resolve
|
||||
* @return string resolved URI
|
||||
*/
|
||||
function resolveServer($uri) {
|
||||
Assert::string($uri, "URI must be a string. Got: %s");
|
||||
|
||||
@@ -43,6 +59,10 @@ function resolveServer($uri) {
|
||||
return $shortcuts[$uri];
|
||||
}
|
||||
|
||||
if (strpos($uri, "/") === false) {
|
||||
throw new InvalidArgumentException("Invalid server URI: " . $uri);
|
||||
}
|
||||
|
||||
$protocol = substr($uri, 0, strpos($uri, "://"));
|
||||
|
||||
if (!$protocol || $protocol === $uri) {
|
||||
@@ -52,6 +72,12 @@ function resolveServer($uri) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a directory URI to a valid filename for usage as key file name.
|
||||
*
|
||||
* @param string $server URI to the directory
|
||||
* @return string identifier usable as file name
|
||||
*/
|
||||
function serverToKeyname($server) {
|
||||
$server = substr($server, strpos($server, "://") + 3);
|
||||
|
||||
@@ -62,6 +88,11 @@ function serverToKeyname($server) {
|
||||
return $keyFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the application is currently running as Phar.
|
||||
*
|
||||
* @return bool {@code true} if running as Phar, {@code false} otherwise
|
||||
*/
|
||||
function isPhar() {
|
||||
if (!class_exists("Phar")) {
|
||||
return false;
|
||||
@@ -70,40 +101,69 @@ function isPhar() {
|
||||
return Phar::running(true) !== "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a path. Replaces all backslashes with slashes and removes trailing slashes.
|
||||
*
|
||||
* @param string $path path to normalize
|
||||
* @return string normalized path
|
||||
*/
|
||||
function normalizePath($path) {
|
||||
return rtrim(str_replace("\\", "/", $path), "/");
|
||||
}
|
||||
|
||||
function getArgumentDescription($argument) {
|
||||
$isPhar = \Kelunik\AcmeClient\isPhar();
|
||||
/**
|
||||
* Gets the most appropriate config path to use.
|
||||
*
|
||||
* @return string|null Resolves to the config path or null.
|
||||
*/
|
||||
function getConfigPath() {
|
||||
$paths = isPhar() ? [substr(dirname(Phar::running(true)), strlen("phar://")) . "/acme-client.yml"] : [];
|
||||
|
||||
if (strtoupper(substr(PHP_OS, 0, 3)) !== 'WIN') {
|
||||
if ($home = getenv("HOME")) {
|
||||
$paths[] = $home . "/.acme-client.yml";
|
||||
}
|
||||
|
||||
$paths[] = "/etc/acme-client.yml";
|
||||
}
|
||||
|
||||
do {
|
||||
$path = array_shift($paths);
|
||||
|
||||
if (file_exists($path)) {
|
||||
return $path;
|
||||
}
|
||||
} while (count($paths));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a consistent argument description for CLIMate. Valid arguments are "server" and "storage".
|
||||
*
|
||||
* @param string $argument argument name
|
||||
* @return array CLIMate argument description
|
||||
* @throws AcmeException if the provided acme-client.yml file is invalid
|
||||
* @throws ConfigException if the provided configuration file is invalid
|
||||
*/
|
||||
function getArgumentDescription($argument) {
|
||||
$config = [];
|
||||
|
||||
if ($isPhar) {
|
||||
$configPath = substr(dirname(Phar::running(true)), strlen("phar://")) . "/acme-client.yml";
|
||||
if ($configPath = getConfigPath()) {
|
||||
$configContent = file_get_contents($configPath);
|
||||
|
||||
if (file_exists($configPath)) {
|
||||
$configContent = file_get_contents($configPath);
|
||||
try {
|
||||
$config = Yaml::parse($configContent);
|
||||
|
||||
try {
|
||||
$value = Yaml::parse($configContent);
|
||||
|
||||
if (isset($value["server"]) && is_string($value["server"])) {
|
||||
$config["server"] = $value["server"];
|
||||
unset($value["server"]);
|
||||
}
|
||||
|
||||
if (isset($value["storage"]) && is_string($value["storage"])) {
|
||||
$config["storage"] = $value["storage"];
|
||||
unset($value["storage"]);
|
||||
}
|
||||
|
||||
if (!empty($value)) {
|
||||
throw new AcmeException("Provided YAML file had unknown options: " . implode(", ", array_keys($value)));
|
||||
}
|
||||
} catch (ParseException $e) {
|
||||
throw new AcmeException("Unable to parse the YAML file ({$configPath}): " . $e->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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +184,8 @@ function getArgumentDescription($argument) {
|
||||
return $argument;
|
||||
|
||||
case "storage":
|
||||
$isPhar = isPhar();
|
||||
|
||||
$argument = [
|
||||
"longPrefix" => "storage",
|
||||
"description" => "Storage directory for account keys and certificates.",
|
||||
@@ -140,10 +202,15 @@ function getArgumentDescription($argument) {
|
||||
return $argument;
|
||||
|
||||
default:
|
||||
throw new \InvalidArgumentException("Unknown argument: " . $argument);
|
||||
throw new InvalidArgumentException("Unknown argument: " . $argument);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the binary that currently runs. Can be included in help texts about other commands.
|
||||
*
|
||||
* @return string binary callable, shortened based on PATH and CWD
|
||||
*/
|
||||
function getBinary() {
|
||||
$binary = "bin/acme";
|
||||
|
||||
@@ -169,4 +236,26 @@ function getBinary() {
|
||||
}
|
||||
|
||||
return $binary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cuts a text to a certain length and appends an ellipsis if necessary.
|
||||
*
|
||||
* @param string $text text to shorten
|
||||
* @param int $max maximum length
|
||||
* @param string $append appendix when too long
|
||||
* @return string shortened string
|
||||
*/
|
||||
function ellipsis($text, $max = 70, $append = "…") {
|
||||
if (strlen($text) <= $max) {
|
||||
return $text;
|
||||
}
|
||||
|
||||
$out = substr($text, 0, $max);
|
||||
|
||||
if (strpos($text, " ") === false) {
|
||||
return $out . $append;
|
||||
}
|
||||
|
||||
return preg_replace("/\\w+$/", "", $out) . $append;
|
||||
}
|
||||
Reference in New Issue
Block a user