75 Commits

Author SHA1 Message Date
Niklas Keller
44f218d8c2 Update dependencies 2017-01-05 02:20:47 +01:00
Niklas Keller
af44670353 Improve error message for failed domain DNS 2017-01-02 00:07:23 +01:00
Niklas Keller
0bd3525938 Use 'composer update' instead of 'composer install'
We need another PHPUnit version for PHP 5.5.
2016-12-15 10:53:03 +01:00
Niklas Keller
c3f8424785 Fix tests 2016-12-15 10:35:24 +01:00
Niklas Keller
040aebe993 Improve error message on timed out MX query
Any error, not only NoRecordExceptions, resulted in a MX record not found
error message. The previous message is now only shown if there's really no
record. Otherwise a more generic message is shown now.

Fixes #43.
2016-12-14 19:21:41 +01:00
Niklas Keller
8944aee552 Fall back to global config for server and storage if possible 2016-10-22 12:21:09 +02:00
Niklas Keller
5b2c47c30f Args::exists → Args::defined 2016-10-22 12:15:07 +02:00
Niklas Keller
ecb46af5c0 Pass right variables to checkAndIssue 2016-10-22 12:07:10 +02:00
Niklas Keller
253d3f476b Renew if not all names are covered
Renew a certificate if not all names are covered by the current certificate yet.
Adds a new `--names` option to `check` that makes `check` fail if not all names are covered.
Resolves #34.
2016-10-22 11:41:34 +02:00
Niklas Keller
c6d9c2016c Make 'server' and 'storage' optional for 'auto'
Resolves #35. Takes the value from the global config file as default argument.
If it doesn't exist, but something in the config file exists, it takes that.
If a command line argument is provided, it always takes precedence.
2016-10-22 11:27:52 +02:00
Niklas Keller
b4d4da7a51 Update dependencies 2016-10-22 11:27:25 +02:00
Niklas Keller
0d61689da1 Wait for challenge to be written to disk before continuing
This also makes exceptions visible now. This commit resolves #37.
2016-10-22 11:10:01 +02:00
Niklas Keller
349a12aae6 Merge pull request #41 from spikyjt/patch-1
Correct brackets
2016-10-22 11:01:32 +02:00
JT
28ae97f135 Correct brackets
I'm sure I did this properly the first time. Must have lost concentration!
2016-10-22 09:55:36 +01:00
Niklas Keller
0fc0e45e57 Merge pull request #40 from spikyjt/patch-1
POSIX compliant cron example
2016-10-21 14:15:12 +02:00
JT
9356a060e7 POSIX compliant cron example
Changed the cron auto example to be POSIX compliant and use full paths.
Changed `exit` variable to `RC` (commonly used in system scripts for "return code") as `exit` is a shell builtin.
Added note about setting the full path as $PATH may not be set.
2016-10-21 11:56:29 +01:00
Niklas Keller
b4a722c0a9 Update dependencies
Also update php-cs from fabpot to friendsofphp.
2016-08-04 16:13:04 +02:00
Niklas Keller
2f73c15287 Update dependencies 2016-08-04 16:10:17 +02:00
Niklas Keller
05a6f6d861 Merge pull request #36 from izzlazz/master
Add support for IPv6-only host names
2016-07-29 09:09:15 +02:00
René Højbjerg Larsen
d5fdc1a3c0 Add support for IPv6-only host names 2016-07-27 22:28:42 +02:00
Niklas Keller
cc76a6f52c Warn if PHP 5.5 is used as it's EOL 2016-07-11 09:19:18 +02:00
Niklas Keller
74b275cf07 Chunk DNS lookups as well 2016-06-28 22:05:36 +02:00
Niklas Keller
e9d2a59eca Chunk authorization requests into groups of 10 2016-06-28 10:27:46 +02:00
Niklas Keller
b9d79bbbe7 Improve documentation for the auto command, closes #31 2016-06-20 15:55:23 +02:00
Niklas Keller
f0c09881ea Update dependencies to include latest amphp/socket v0.9.8 release 2016-06-19 22:21:07 +02:00
Niklas Keller
07f9a03702 Check success for directory creation in key store
Resolves #28.
2016-06-09 16:50:41 +02:00
Niklas Keller
d04e758598 Update dependencies 2016-06-09 16:39:26 +02:00
Niklas Keller
029f4c533a Add check to catch #30 early with a helpful message 2016-06-09 16:32:37 +02:00
Niklas Keller
c02e758a21 Merge pull request #26 from kelunik/automation
Basic working 'auto' command
2016-06-07 09:44:27 +02:00
Niklas Keller
e1ea62b5e7 Doc typo and format fixes 2016-06-07 09:42:52 +02:00
Niklas Keller
a090e99a19 Update documentation for new auto command 2016-06-04 21:47:08 +02:00
Niklas Keller
de3b82da1d Fix output variables for external process outputs 2016-06-04 20:46:57 +02:00
Niklas Keller
791b250742 Show renewals in auto command, fix reference notice, correct exit code for renewed certs 2016-06-04 20:41:48 +02:00
Niklas Keller
9f849691c2 Fix config load path 2016-06-04 19:51:02 +02:00
Niklas Keller
c4d15e2e26 Better exit codes and error messages for auto command 2016-06-04 19:25:46 +02:00
Niklas Keller
0722e104d4 Update deps, use email in setup from config if present 2016-06-03 23:46:38 +02:00
Niklas Keller
583318fa0b Scan for config in /etc and ~, improve automation command 2016-06-03 19:38:47 +02:00
Niklas Keller
3472bd1b3c Basic working 'auto' command 2016-06-03 18:16:29 +02:00
Niklas Keller
6d31cec6ad Update dependencies 2016-06-02 21:39:30 +02:00
Niklas Keller
8cbb3d02a8 Update dependencies 2016-05-27 16:49:35 +02:00
Niklas Keller
d7b71dab24 Merge pull request #27 from kelunik/status
Implement 'status' command
2016-03-29 10:56:14 +02:00
Niklas Keller
944adf0c06 Implement 'status' command 2016-03-28 19:48:39 +02:00
Niklas Keller
a1d65c1483 Don't show help with 'acme-client subcommand h / help', just with '-h' / '--help' flags 2016-03-28 12:35:21 +02:00
Niklas Keller
fb0509ae7e Show help when --help is included, not only if parsing fails and then --help is included 2016-03-28 12:27:02 +02:00
Niklas Keller
866b172c5f Implement version command and better help 2016-03-28 12:26:17 +02:00
Niklas Keller
8d085347b9 Fixup error message for issues with IPv4 resolving 2016-03-26 10:08:24 +01:00
Niklas Keller
fc3b7e948f Document all functions in functions.php 2016-03-25 20:16:43 +01:00
Niklas Keller
2b2daee8bb Refactor AcmeService creation into Factory 2016-03-25 20:04:58 +01:00
Niklas Keller
e4b9203537 Unignore composer.lock 2016-03-25 18:04:12 +01:00
Niklas Keller
c94d9b4795 Add requirements to README 2016-03-25 15:48:34 +01:00
Niklas Keller
7cfcb575fa Show helpful error message if OpenSSL is missing 2016-03-25 15:19:05 +01:00
Niklas Keller
fffaec6d84 Bundle CA bundle into Phar 2016-03-25 15:18:41 +01:00
Niklas Keller
1bc25c738c Show correct binary file in help texts 2016-03-25 13:29:37 +01:00
Niklas Keller
9c8d67b2e9 Add logo to README 2016-03-25 12:36:32 +01:00
Niklas Keller
251a47ebaa Update usage instructions and separate docs from README 2016-03-24 17:27:43 +01:00
Niklas Keller
a33ac65a77 Add sample YAML configuration 2016-03-24 11:53:42 +01:00
Niklas Keller
746bf84adb Fix config file loader 2016-03-24 11:51:12 +01:00
Niklas Keller
ff6e14e2de Fix travis builds 2016-03-24 10:37:36 +01:00
Niklas Keller
9f917ec3ec Add instructions for using it as phar 2016-03-24 09:33:43 +01:00
Niklas Keller
5381587bbf Improve more output 2016-03-24 00:01:17 +01:00
Niklas Keller
266cc06746 Improve output for various commands 2016-03-23 23:58:20 +01:00
Niklas Keller
dd34937e96 Improve check output 2016-03-23 23:45:30 +01:00
Niklas Keller
d34b6eb09d Merge pull request #18 from kelunik/issue-17
Provide possibility to specify storage directory
2016-03-23 23:39:27 +01:00
Niklas Keller
1c4a2387e9 Fix exit codes on PHP 5 2016-03-23 23:35:35 +01:00
Niklas Keller
8549ff9e46 Fix documentation bug: Missing server on revocation 2016-03-23 23:35:07 +01:00
Niklas Keller
93c94d1a9b Fix exit codes 2016-03-23 23:32:13 +01:00
Niklas Keller
6173b779e1 Fix phar creation 2016-03-23 23:31:54 +01:00
Niklas Keller
e8f35811fb Separate paths on Windows with ; instead of : 2016-03-23 23:31:54 +01:00
Niklas Keller
c36ced9b7c Ensure consistent server and storage arguments, fix storage path for setup 2016-03-23 23:31:39 +01:00
Niklas Keller
9ea97f18e1 Implement storage defaulting to the old one and required when using as PHAR 2016-03-23 23:30:39 +01:00
Niklas Keller
1a06a5cadf Replace logger with CLIMate output 2016-03-23 23:29:26 +01:00
Niklas Keller
3e6be4abf1 Better output when parsing of arguments doesn't work 2016-03-23 23:09:45 +01:00
Niklas Keller
67e4fab090 Better output when required parameters are missing 2016-03-23 23:01:18 +01:00
Niklas Keller
5e0d126834 Add syntax checking and PHPUnit to Travis 2016-03-23 11:21:21 +01:00
Niklas Keller
e47eb9c636 Add sample script for renewal 2016-03-20 18:21:30 +01:00
26 changed files with 4547 additions and 330 deletions

3
.gitignore vendored
View File

@@ -1,4 +1,5 @@
/build/
/data/
/info/
/vendor/
/composer.lock
/config.test.yml

15
.php_cs Normal file
View File

@@ -0,0 +1,15 @@
<?php
return Symfony\CS\Config\Config::create()
->level(Symfony\CS\FixerInterface::NONE_LEVEL)
->fixers([
"psr2",
"-braces",
"-psr0",
])
->finder(
Symfony\CS\Finder\DefaultFinder::create()
->in(__DIR__ . "/bin")
->in(__DIR__ . "/src")
->in(__DIR__ . "/test")
);

26
.travis.yml Normal file
View File

@@ -0,0 +1,26 @@
language: php
php:
- 5.5
- 5.6
- 7.0
- nightly
cache:
directories:
- vendor
install:
- phpenv config-rm xdebug.ini || true
- composer config --global discard-changes true
- composer update
- composer require satooshi/php-coveralls dev-master --dev
- composer show --installed
script:
- find -name "*.php" -not -path "./vendor/*" -print0 | xargs -n 1 -0 php -l
- $(php -r 'if (PHP_MAJOR_VERSION >= 7) echo "phpdbg -qrr"; else echo "php";') vendor/bin/phpunit --coverage-text --coverage-clover build/logs/clover.xml
- php vendor/bin/php-cs-fixer --diff --dry-run -v fix
after_script:
- php vendor/bin/coveralls -v

117
README.md
View File

@@ -1,113 +1,14 @@
# acme
![`kelunik/acme-client`](./res/logo.png)
![unstable](https://img.shields.io/badge/api-unstable-orange.svg?style=flat-square)
![MIT license](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)
`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.
`kelunik/acme-client` is a standalone ACME client written in PHP.
It's an alternative for the [official client](https://github.com/letsencrypt/letsencrypt) which is written in python.
## Requirements
> **Warning**: This software is under development. Use at your own risk.
* PHP 5.5+ with OpenSSL
* Works on Unix and Windows
## Installation
## Documentation
**Requirements**
* PHP 5.5+
* Composer
**Instructions**
```bash
# Clone repository
git clone https://github.com/kelunik/acme-client && cd acme-client
# Checkout latest release
git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
# Install dependencies
composer install --no-dev
```
## Migration from 0.1.x to 0.2.x
```bash
# Start in ./data
cd data
# Move your account key to new location:
mkdir accounts
mv account/key.pem accounts/acme-v01.api.letsencrypt.org.directory.pem
# or accounts/acme-staging.api.letsencrypt.org.directory.pem if it's a staging key
# account should now be empty or contain just a config.json, you can delete the folder then
rm -rf account
# Migrate certificates to new location:
cd certs
mkdir acme-v01.api.letsencrypt.org.directory
# Move all your certificate directories
# Repeat for all directories!
mv example.com acme-v01.api.letsencrypt.org.directory
# or acme-staging.api.letsencrypt.org.directory
# Delete all config.json files which may exist
find -name "config.json" | xargs rm
# Update to current version
git checkout master && git pull
# Check out latest release
git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
# Update dependencies
composer update --no-dev
# Reconfigure your webserver to use the new paths
# and check (and fix) your automation commands.
```
## Usage
> **Note**: This client stores all data in `./data`, be sure to backup this folder regularly.
> It contains your account keys, domain keys and certificates.
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 Let's Encrypt there's a [subscriber agreement](https://letsencrypt.org/repository/) you have to accept.
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.
```
bin/acme setup -s letsencrypt --email me@example.com
```
`-s` / `--server` can either be a URI or a shortcut. Available shortcuts:
* `letsencrypt` / `letsencrypt:production`
* `letsencrypt:staging`
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.
```
bin/acme issue -s letsencrypt -d example.com:www.example.com -p /var/www/example.com
```
To revoke a certificate, you need a valid account key currently, just like for issuance.
```
bin/acme revoke --name example.com
```
For renewal, there's the `bin/acme 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:
```
bin/acme check --name example.com --ttl 30 -s letsencrypt || bin/acme issue ...
```
* [Installation](./doc/installation.md)
* [Usage](./doc/usage.md)
* [Migration guide for 0.1.x → 0.2.x](./doc/migrations/0.2.0.md)

137
bin/acme
View File

@@ -2,22 +2,22 @@
<?php
use Auryn\Injector;
use Bramus\Monolog\Formatter\ColoredLineFormatter;
use Kelunik\AcmeClient\LoggerColorScheme;
use Kelunik\AcmeClient\AcmeFactory;
use League\CLImate\CLImate;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use Psr\Log\LoggerInterface;
$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
@@ -26,58 +26,81 @@ HELP;
exit(-1);
}
if (!function_exists("openssl_pkey_get_private")) {
echo $logo;
echo <<<HELP
You need to enable OpenSSL in your php.ini
HELP;
exit(-2);
}
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.",
];
$help = implode("\n ", array_map(function($command) {
return "bin/acme {$command}";
}, $commands));
$binary = \Kelunik\AcmeClient\getBinary();
$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)) {
print $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)) {
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()->info(" Did you mean '$suggestion'?")->br();
$climate->br()->out(" Did you mean '$suggestion'?");
}
$climate->br();
exit(1);
}
@@ -90,31 +113,33 @@ 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)) {
$climate->usage(["bin/acme {$argv[1]}"]);
if (count($argv) === 3 && in_array($argv[2], ["-h", "--help"], true)) {
$climate->usage(["{$binary} {$argv[1]}"]);
$climate->br();
exit(0);
} else {
$climate->error($e->getMessage());
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);
}
$handler = new StreamHandler("php://stdout", Logger::DEBUG);
$handler->setFormatter(new ColoredLineFormatter(new LoggerColorScheme, null, null, true, true));
$logger = new Logger("ACME");
$logger->pushHandler($handler);
$injector->alias(LoggerInterface::class, Logger::class);
$injector->share($logger);
$injector = new Injector;
$injector->share($climate);
$injector->share(new AcmeFactory);
$command = $injector->make($class);
Amp\run(function () use ($command, $climate, $logger) {
$handler = function($e) use ($logger) {
Amp\run(function () use ($command, $climate) {
$handler = function ($e) use ($climate) {
$error = (string) $e;
$lines = explode("\n", $error);
$lines = array_filter($lines, function ($line) {
@@ -122,14 +147,20 @@ Amp\run(function () use ($command, $climate, $logger) {
});
foreach ($lines as $line) {
$logger->error($line);
$climate->error($line)->br();
}
exit(1);
};
try {
yield $command->execute($climate->arguments);
$exitCode = (yield $command->execute($climate->arguments));
if ($exitCode === null) {
exit(0);
}
exit($exitCode);
} catch (Throwable $e) {
$handler($e);
} catch (Exception $e) {

View File

@@ -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",
@@ -11,17 +11,20 @@
"tls"
],
"require": {
"amphp/process": "^0.1.1",
"bramus/monolog-colored-line-formatter": "^2",
"php": "^5.5|^7",
"ext-openssl": "*",
"amphp/process": "^0.1.1",
"kelunik/acme": "^0.3",
"kelunik/certificate": "^1",
"league/climate": "^3",
"monolog/monolog": "^1.17",
"php": "^5.5|^7",
"psr/log": "^1",
"rdlowrey/auryn": "^1",
"webmozart/assert": "^1"
"webmozart/assert": "^1",
"symfony/yaml": "^3.0"
},
"require-dev": {
"phpunit/phpunit": "^4|^5",
"friendsofphp/php-cs-fixer": "^1.9",
"macfja/phar-builder": "^0.2.5"
},
"license": "MIT",
"authors": [
@@ -40,17 +43,30 @@
"src/functions.php"
]
},
"require-dev": {
"phpunit/phpunit": "^5",
"macfja/phar-builder": "^0.2.3"
},
"repositories": [
{
"type": "vcs",
"url": "https://github.com/kelunik/pharbuilder"
}
],
"extra": {
"phar-builder": {
"compression": "GZip",
"name": "acme.phar",
"name": "acme-client.phar",
"output-dir": "build",
"include": ["bin", "src", "vendor"],
"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

File diff suppressed because it is too large Load Diff

70
doc/advanced-usage.md Normal file
View 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
```

66
doc/installation.md Normal file
View File

@@ -0,0 +1,66 @@
# Installation
## Installation using Phar
This is the preferred installation method for usage on a production system.
### Requirements
* PHP 5.5+
### Instructions
```bash
# Go to https://github.com/kelunik/acme-client/releases/latest
# Download the latest release archive.
# Make it executable.
chmod +x acme-client.phar
# Run it.
./acme-client.phar
# Or install it globally.
mv ./acme-client.phar /usr/local/bin/acme-client
acme-client
```
If you want to update, just replace the old `.phar` with a new one.
All commands require a `--storage` argument when using the Phar. That's the path where your keys and certificates will be stored.
On Unix you could use something like `--storage /etc/acme`.
You can add a file named `acme-client.yml` next to the `.phar` with the two keys `storage` and `server`.
These values will be used as default if you don't specify them, but you can still use another server by explicitly adding it as argument.
```yml
# Sample YAML configuration.
# Storage directory for certificates and keys.
storage: /etc/acme
# Server to use. Available shortcuts: letsencrypt, letsencrypt:staging
# You can also use full URLs to the directory resource of an ACME server
server: letsencrypt
```
## Installation using Composer
If you plan to actively develop this client, you probably don't want the Phar but install the dependencies using Composer.
### Requirements
* PHP 5.5+
* [Composer](https://getcomposer.org/)
### Instructions
```bash
# Clone repository
git clone https://github.com/kelunik/acme-client && cd acme-client
# Install dependencies
composer install
```
You can use `./bin/acme` as script instead of the Phar. Please note, that all data will be stored in `./data` as long as you don't provide the `--storage` argument.

43
doc/migrations/0.2.0.md Normal file
View File

@@ -0,0 +1,43 @@
# Migration from 0.1.x to 0.2.x
If you used this client before `0.2.0`, you have a different directory structure than the current one. If you want to upgrade, but keep all your data, here's a migration guide.
```bash
# Start in ./data
cd data
# Move your account key to new location:
mkdir accounts
mv account/key.pem accounts/acme-v01.api.letsencrypt.org.directory.pem
# or accounts/acme-staging.api.letsencrypt.org.directory.pem if it's a staging key
# account should now be empty or contain just a config.json, you can delete the folder then
rm -rf account
# Migrate certificates to new location:
cd certs
mkdir acme-v01.api.letsencrypt.org.directory
# Move all your certificate directories
# Repeat for all directories!
mv example.com acme-v01.api.letsencrypt.org.directory
# or acme-staging.api.letsencrypt.org.directory
# Delete all config.json files which may exist
find -name "config.json" | xargs rm
# Update to current version
# Alternatively have a look at the new installation instructions and use the Phar
git checkout master && git pull
# Check out latest release
git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
# Update dependencies
composer update --no-dev
# Reconfigure your webserver to use the new paths
# and check (and fix) your automation commands.
```

98
doc/usage.md Normal file
View File

@@ -0,0 +1,98 @@
# Basic Usage
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`.
**Be sure to backup that directory regularly.**
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.
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.
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.
## Configuration
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
```
All configuration keys are optional and can be passed as arguments directly (except for `certificates` when using `acme-client auto`).
## 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
```
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.
| 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`.

BIN
res/logo-avatar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

BIN
res/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

16
src/AcmeFactory.php Normal file
View 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
View 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;
}
}

View File

@@ -2,16 +2,18 @@
namespace Kelunik\AcmeClient\Commands;
use Amp\CoroutineResult;
use Kelunik\AcmeClient\Stores\CertificateStore;
use Kelunik\AcmeClient\Stores\CertificateStoreException;
use Kelunik\Certificate\Certificate;
use League\CLImate\Argument\Manager;
use Psr\Log\LoggerInterface;
use League\CLImate\CLImate;
class Check implements Command {
private $logger;
private $climate;
public function __construct(LoggerInterface $logger) {
$this->logger = $logger;
public function __construct(CLImate $climate) {
$this->climate = $climate;
}
public function execute(Manager $args) {
@@ -26,31 +28,49 @@ class Check implements Command {
$server = \Kelunik\AcmeClient\resolveServer($args->get("server"));
$server = \Kelunik\AcmeClient\serverToKeyname($server);
$path = dirname(dirname(__DIR__)) . "/data/certs/" . $server;
$path = \Kelunik\AcmeClient\normalizePath($args->get("storage")) . "/certs/" . $server;
$certificateStore = new CertificateStore($path);
$pem = (yield $certificateStore->get($args->get("name")));
$cert = new Certificate($pem);
try {
$pem = (yield $certificateStore->get($args->get("name")));
} catch (CertificateStoreException $e) {
$this->climate->br()->error(" Certificate not found.")->br();
$this->logger->info("Certificate is valid until " . date("d.m.Y", $cert->getValidTo()));
if ($cert->getValidTo() > time() + $args->get("ttl") * 24 * 60 * 60) {
exit(0);
yield new CoroutineResult(1);
return;
}
$this->logger->warning("Certificate is going to expire within the specified " . $args->get("ttl") . " days.");
$cert = new Certificate($pem);
exit(1);
$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;
}
$this->climate->comment(" Certificate is going to expire within the specified " . $args->get("ttl") . " days.")->br();
yield new CoroutineResult(1);
}
public static function getDefinition() {
return [
"server" => [
"prefix" => "s",
"longPrefix" => "server",
"description" => "",
"required" => true,
],
"server" => \Kelunik\AcmeClient\getArgumentDescription("server"),
"storage" => \Kelunik\AcmeClient\getArgumentDescription("storage"),
"name" => [
"longPrefix" => "name",
"description" => "Common name of the certificate to check.",
@@ -62,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,
],
];
}
}

View File

@@ -2,28 +2,30 @@
namespace Kelunik\AcmeClient\Commands;
use Amp\CombinatorException;
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;
use Kelunik\AcmeClient\Stores\KeyStoreException;
use League\CLImate\Argument\Manager;
use Psr\Log\LoggerInterface;
use League\CLImate\CLImate;
use stdClass;
use Throwable;
class Issue implements Command {
private $logger;
private $climate;
private $acmeFactory;
public function __construct(LoggerInterface $logger) {
$this->logger = $logger;
public function __construct(CLImate $climate, AcmeFactory $acmeFactory) {
$this->climate = $climate;
$this->acmeFactory = $acmeFactory;
}
public function execute(Manager $args) {
@@ -45,11 +47,11 @@ class Issue implements Command {
}
}
$domains = array_map("trim", explode(":", str_replace(",", ":", $args->get("domains"))));
$domains = array_map("trim", explode(":", str_replace([",", ";"], ":", $args->get("domains"))));
yield \Amp\resolve($this->checkDnsRecords($domains));
$docRoots = explode(":", str_replace("\\", "/", $args->get("path")));
$docRoots = array_map(function($root) {
$docRoots = explode(PATH_SEPARATOR, str_replace("\\", "/", $args->get("path")));
$docRoots = array_map(function ($root) {
return rtrim($root, "/");
}, $docRoots);
@@ -64,7 +66,7 @@ class Issue implements Command {
);
}
$keyStore = new KeyStore(dirname(dirname(__DIR__)) . "/data");
$keyStore = new KeyStore(\Kelunik\AcmeClient\normalizePath($args->get("storage")));
$server = \Kelunik\AcmeClient\resolveServer($args->get("server"));
$keyFile = \Kelunik\AcmeClient\serverToKeyname($server);
@@ -72,24 +74,31 @@ class Issue implements Command {
try {
$keyPair = (yield $keyStore->get("accounts/{$keyFile}.pem"));
} catch (KeyStoreException $e) {
$this->logger->error("Account key not found, did you run 'bin/acme setup'?");
exit(1);
throw new AcmeException("Account key not found, did you run 'bin/acme setup'?", 0, $e);
}
$acme = new AcmeService(new AcmeClient($server, $keyPair));
$this->climate->br();
$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->logger->error($error->getMessage());
$this->climate->error($error->getMessage());
}
throw new AcmeException("Issuance failed, not all challenges could be solved.");
@@ -105,16 +114,21 @@ class Issue implements Command {
$keyPair = (yield $keyStore->put($path, $keyPair));
}
$this->logger->info("Requesting certificate ...");
$this->climate->br();
$this->climate->whisper(" Requesting certificate ...");
$location = (yield $acme->requestCertificate($keyPair, $domains));
$certificates = (yield $acme->pollForCertificate($location));
$path = dirname(dirname(__DIR__)) . "/data/certs/" . $keyFile;
$path = \Kelunik\AcmeClient\normalizePath($args->get("storage")) . "/certs/" . $keyFile;
$certificateStore = new CertificateStore($path);
yield $certificateStore->put($certificates);
$this->logger->info("Successfully issued certificate, see {$path}/" . reset($domains));
$this->climate->info(" Successfully issued certificate.");
$this->climate->info(" See {$path}/" . reset($domains));
$this->climate->br();
yield new CoroutineResult(0);
}
private function solveChallenge(AcmeService $acme, KeyPair $keyPair, $domain, $path) {
@@ -132,25 +146,20 @@ class Issue implements Command {
throw new AcmeException("Protocol violation: Invalid Token!");
}
$this->logger->debug("Generating payload...");
$payload = $acme->generateHttp01Payload($keyPair, $token);
$this->logger->info("Providing payload at http://{$domain}/.well-known/acme-challenge/{$token}");
$this->climate->whisper(" Providing payload at http://{$domain}/.well-known/acme-challenge/{$token}");
$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);
$this->logger->info("Successfully self-verified challenge.");
yield $acme->answerChallenge($challenge->uri, $payload);
$this->logger->info("Answered challenge... waiting");
yield $acme->pollForChallenge($location);
$this->logger->info("Challenge successful. {$domain} is now authorized.");
$this->climate->comment(" {$domain} is now authorized.");
yield $challengeStore->delete($token);
} catch (Exception $e) {
@@ -165,22 +174,34 @@ 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));
$this->logger->info("Checked DNS records, all fine.");
throw new AcmeException("Couldn't resolve the following domains to an IPv4 nor IPv6 record: {$failedDomains}\n\n{$reasons}");
}
}
private function findSuitableCombination(stdClass $response) {
@@ -205,22 +226,18 @@ class Issue implements Command {
public static function getDefinition() {
return [
"server" => [
"prefix" => "s",
"longPrefix" => "server",
"description" => "Server to use for issuance, see also 'bin/acme setup'.",
"required" => true,
],
"server" => \Kelunik\AcmeClient\getArgumentDescription("server"),
"storage" => \Kelunik\AcmeClient\getArgumentDescription("storage"),
"domains" => [
"prefix" => "d",
"longPrefix" => "domains",
"description" => "Colon separated list of domains to request a certificate for.",
"description" => "Colon / Semicolon / Comma separated list of domains to request a certificate for.",
"required" => true,
],
"path" => [
"prefix" => "p",
"longPrefix" => "path",
"description" => "Colon separated list of paths to the document roots. The last one will be used for all remaining ones if fewer than the amount of domains is given.",
"description" => "Colon (Unix) / Semicolon (Windows) separated list of paths to the document roots. The last one will be used for all remaining ones if fewer than the amount of domains is given.",
"required" => true,
],
"user" => [
@@ -236,4 +253,4 @@ class Issue implements Command {
],
];
}
}
}

View File

@@ -2,23 +2,22 @@
namespace Kelunik\AcmeClient\Commands;
use Amp\CoroutineResult;
use Amp\File\FilesystemException;
use Amp\Promise;
use Generator;
use Kelunik\Acme\AcmeClient;
use Kelunik\Acme\AcmeException;
use Kelunik\Acme\AcmeService;
use Kelunik\AcmeClient\AcmeFactory;
use Kelunik\AcmeClient\Stores\CertificateStore;
use Kelunik\AcmeClient\Stores\KeyStore;
use Kelunik\Certificate\Certificate;
use League\CLImate\Argument\Manager;
use Psr\Log\LoggerInterface;
use League\CLImate\CLImate;
class Revoke implements Command {
private $logger;
private $climate;
private $acmeFactory;
public function __construct(LoggerInterface $logger) {
$this->logger = $logger;
public function __construct(CLImate $climate, AcmeFactory $acmeFactory) {
$this->climate = $climate;
$this->acmeFactory = $acmeFactory;
}
public function execute(Manager $args) {
@@ -26,17 +25,18 @@ class Revoke implements Command {
}
private function doExecute(Manager $args) {
$keyStore = new KeyStore(dirname(dirname(__DIR__)) . "/data");
$keyStore = new KeyStore(\Kelunik\AcmeClient\normalizePath($args->get("storage")));
$server = \Kelunik\AcmeClient\resolveServer($args->get("server"));
$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->logger->info("Revoking certificate ...");
$this->climate->br();
$this->climate->whisper(" Revoking certificate ...");
$path = dirname(dirname(__DIR__)) . "/data/certs/" . $keyFile . "/" . $args->get("name") . "/cert.pem";
$path = \Kelunik\AcmeClient\normalizePath($args->get("storage")) . "/certs/" . $keyFile . "/" . $args->get("name") . "/cert.pem";
try {
$pem = (yield \Amp\File\get($path));
@@ -46,24 +46,27 @@ class Revoke implements Command {
}
if ($cert->getValidTo() < time()) {
$this->logger->warning("Certificate did already expire, no need to revoke it.");
$this->climate->comment(" Certificate did already expire, no need to revoke it.");
}
$this->logger->info("Certificate was valid for: " . implode(", ", $cert->getNames()));
yield $acme->revokeCertificate($pem);
$this->logger->info("Certificate has been revoked.");
$names = $cert->getNames();
$this->climate->whisper(" Certificate was valid for " . count($names) . " domains.");
$this->climate->whisper(" - " . implode(PHP_EOL . " - ", $names) . PHP_EOL);
yield (new CertificateStore(dirname(dirname(__DIR__)) . "/data/certs/" . $keyFile))->delete($args->get("name"));
yield $acme->revokeCertificate($pem);
$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 CoroutineResult(0);
}
public static function getDefinition() {
return [
"server" => [
"prefix" => "s",
"longPrefix" => "server",
"description" => "",
"required" => true,
],
"server" => \Kelunik\AcmeClient\getArgumentDescription("server"),
"storage" => \Kelunik\AcmeClient\getArgumentDescription("storage"),
"name" => [
"longPrefix" => "name",
"description" => "Common name of the certificate to be revoked.",

View File

@@ -2,27 +2,28 @@
namespace Kelunik\AcmeClient\Commands;
use Amp\CoroutineResult;
use Amp\Dns\NoRecordException;
use Amp\Dns\Record;
use Amp\Dns\ResolutionException;
use Amp\Promise;
use Generator;
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 Psr\Log\LoggerInterface;
use RuntimeException;
use League\CLImate\CLImate;
use Symfony\Component\Yaml\Yaml;
class Setup implements Command {
private $logger;
private $climate;
private $acmeFactory;
public function __construct(LoggerInterface $logger) {
$this->logger = $logger;
public function __construct(CLImate $climate, AcmeFactory $acmeFactory) {
$this->climate = $climate;
$this->acmeFactory = $acmeFactory;
}
public function execute(Manager $args) {
@@ -39,29 +40,32 @@ class Setup implements Command {
$path = "accounts/{$keyFile}.pem";
$bits = 4096;
$keyStore = new KeyStore(dirname(dirname(__DIR__)) . "/data");
$keyStore = new KeyStore(\Kelunik\AcmeClient\normalizePath($args->get("storage")));
$this->climate->br();
try {
$this->logger->info("Loading private key ...");
$keyPair = (yield $keyStore->get($path));
$this->logger->info("Existing private key successfully loaded.");
$this->climate->whisper(" Using existing private key ...");
} catch (KeyStoreException $e) {
$this->logger->info("No existing private key found, generating new one ...");
$keyPair = (new OpenSSLKeyGenerator)->generate($bits);
$this->logger->info("Generated new private key with {$bits} bits.");
$this->climate->whisper(" No private key found, generating new one ...");
$this->logger->info("Saving new private key ...");
$keyPair = (new OpenSSLKeyGenerator)->generate($bits);
$keyPair = (yield $keyStore->put($path, $keyPair));
$this->logger->info("New private key successfully saved.");
$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->logger->info("Registering with ACME server " . substr($server, 8) . " ...");
$this->climate->whisper(" Registering with " . substr($server, 8) . " ...");
/** @var Registration $registration */
$registration = (yield $acme->register($email));
$this->logger->notice("Registration successful with the following contact information: " . implode(", ", $registration->getContact()));
$this->climate->info(" Registration successful. Contacts: " . implode(", ", $registration->getContact()));
$this->climate->br();
yield new CoroutineResult(0);
}
private function checkEmail($email) {
@@ -77,24 +81,35 @@ 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 [
"server" => [
"prefix" => "s",
"longPrefix" => "server",
"description" => "ACME server to use for registration and issuance of certificates.",
"required" => true,
],
$args = [
"server" => \Kelunik\AcmeClient\getArgumentDescription("server"),
"storage" => \Kelunik\AcmeClient\getArgumentDescription("storage"),
"email" => [
"longPrefix" => "email",
"description" => "Email for important issues, will be sent to the ACME server.",
"description" => "E-mail for important issues, will be sent to the ACME server.",
"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
View 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
View 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
View File

@@ -0,0 +1,5 @@
<?php
namespace Kelunik\AcmeClient;
class ConfigException extends \Exception { }

View File

@@ -1,29 +0,0 @@
<?php
namespace Kelunik\AcmeClient;
use Bramus\Ansi\ControlSequences\EscapeSequences\Enums\SGR;
use Bramus\Monolog\Formatter\ColorSchemes\ColorSchemeInterface;
use Bramus\Monolog\Formatter\ColorSchemes\ColorSchemeTrait;
use Monolog\Logger;
class LoggerColorScheme implements ColorSchemeInterface {
use ColorSchemeTrait {
ColorSchemeTrait::__construct as private __constructTrait;
}
public function __construct() {
$this->__constructTrait();
$this->setColorizeArray([
Logger::DEBUG => $this->ansi->color(SGR::COLOR_FG_WHITE)->get(),
Logger::INFO => $this->ansi->color(SGR::COLOR_FG_WHITE_BRIGHT)->get(),
Logger::NOTICE => $this->ansi->color(SGR::COLOR_FG_GREEN)->get(),
Logger::WARNING => $this->ansi->color(SGR::COLOR_FG_YELLOW)->get(),
Logger::ERROR => $this->ansi->color(SGR::COLOR_FG_RED)->get(),
Logger::CRITICAL => $this->ansi->color(SGR::COLOR_FG_RED)->get(),
Logger::ALERT => $this->ansi->color(SGR::COLOR_FG_RED)->get(),
Logger::EMERGENCY => $this->ansi->color(SGR::COLOR_FG_RED)->get(),
]);
}
}

View File

@@ -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());

View File

@@ -2,8 +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");
@@ -26,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");
@@ -39,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) {
@@ -48,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);
@@ -56,4 +86,176 @@ function serverToKeyname($server) {
$keyFile = preg_replace("@\\.+@", ".", $keyFile);
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;
}
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), "/");
}
/**
* 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 ($configPath = getConfigPath()) {
$configContent = file_get_contents($configPath);
try {
$config = Yaml::parse($configContent);
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());
}
}
switch ($argument) {
case "server":
$argument = [
"prefix" => "s",
"longPrefix" => "server",
"description" => "ACME server to use for registration and issuance of certificates.",
"required" => true,
];
if (isset($config["server"])) {
$argument["required"] = false;
$argument["defaultValue"] = $config["server"];
}
return $argument;
case "storage":
$isPhar = isPhar();
$argument = [
"longPrefix" => "storage",
"description" => "Storage directory for account keys and certificates.",
"required" => $isPhar,
];
if (!$isPhar) {
$argument["defaultValue"] = dirname(__DIR__) . "/data";
} else if (isset($config["storage"])) {
$argument["required"] = false;
$argument["defaultValue"] = $config["storage"];
}
return $argument;
default:
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";
if (isPhar()) {
$binary = substr(Phar::running(true), strlen("phar://"));
$path = getenv("PATH");
$locations = explode(PATH_SEPARATOR, $path);
$binaryPath = dirname($binary);
foreach ($locations as $location) {
if ($location === $binaryPath) {
return substr($binary, strlen($binaryPath) + 1);
}
}
$cwd = getcwd();
if ($cwd && strpos($binary, $cwd) === 0) {
$binary = "." . substr($binary, strlen($cwd));
}
}
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;
}

View File

@@ -15,4 +15,15 @@ class FunctionsTest extends \PHPUnit_Framework_TestCase {
$this->assertSame("acme", suggestCommand("acme!", ["acme"]));
$this->assertSame("", suggestCommand("issue", ["acme"]));
}
public function testIsPhar() {
$this->assertFalse(isPhar());
}
public function testNormalizePath() {
$this->assertSame("/etc/foobar", normalizePath("/etc/foobar"));
$this->assertSame("/etc/foobar", normalizePath("/etc/foobar/"));
$this->assertSame("/etc/foobar", normalizePath("/etc/foobar/"));
$this->assertSame("C:/etc/foobar", normalizePath("C:\\etc\\foobar\\"));
}
}