39 Commits

Author SHA1 Message Date
Niklas Keller
b7cfe3c0f1 Update to amphp/parallel v0.2.5
This fixes running as PHAR if the PHAR doesn't end with '.phar'.
2018-03-21 15:52:05 +01:00
Niklas Keller
4053094860 Update .travis.yml 2018-03-21 13:06:30 +01:00
Niklas Keller
a80b7b8497 Remove BlockingDriver usage, as parallel has been fixed 2018-03-21 12:55:25 +01:00
Niklas Keller
f13b0856c7 Update dependencies 2018-03-21 12:54:21 +01:00
Niklas Keller
d4f2009315 Fix build on nightly 2018-01-11 17:18:29 +01:00
Niklas Keller
2b4a200263 Fix CSR generation 2018-01-11 17:15:04 +01:00
Niklas Keller
256aa76011 Fix directory permissions 2018-01-11 17:11:28 +01:00
Niklas Keller
69bc88daf1 Refactor directory creation 2018-01-11 17:00:27 +01:00
Niklas Keller
19f6550e33 Fix key store path in exception message 2018-01-11 16:53:01 +01:00
Niklas Keller
ed3da3c98d Fix DNS lookups 2018-01-11 16:43:32 +01:00
Niklas Keller
56955155fe Work around https://bugs.php.net/bug.php?id=75396 2018-01-11 10:36:45 +01:00
Niklas Keller
e3d7723da3 Fix bugs in stores not yielding the correct things 2018-01-11 10:32:17 +01:00
Niklas Keller
0ae207fce3 Downgrade dependencies to be compatible with PHP 7.0 2018-01-09 19:30:34 +01:00
Niklas Keller
bb7e25704c Work around issue with amphp/parallel and PHARs 2018-01-08 18:44:33 +01:00
Niklas Keller
246a02b5cf Update dependencies 2018-01-08 18:43:52 +01:00
Niklas Keller
2dba4a852a Fix code style 2017-12-29 19:23:48 +01:00
Niklas Keller
d8a93a273c Update meta files 2017-12-29 19:20:59 +01:00
Niklas Keller
9a9a243807 Upgrade to Amp v2 2017-12-29 19:15:47 +01:00
Ayman Nedjmeddine
c71b07ef03 Add sample configuration file 2017-12-16 12:53:09 +01:00
Niklas Keller
f4cabf755b Update dependencies, ignore cookies 2017-05-10 10:22:51 +02:00
Niklas Keller
d78b465739 Add link to phar download 2017-03-18 08:52:18 +01:00
Niklas Keller
aa8186471c Issue certificates sequential, allow changing the challenge concurrency 2017-01-30 19:15:33 +01:00
Niklas Keller
888588cf00 Separate script for cron 2017-01-13 09:00:39 +01:00
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
31 changed files with 2726 additions and 1545 deletions

40
.acme-client.yml.sample Normal file
View File

@@ -0,0 +1,40 @@
# 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 /.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

3
.gitignore vendored
View File

@@ -2,4 +2,5 @@
/data/
/info/
/vendor/
/config.test.yml
/config.test.yml
/.php_cs.cache

15
.php_cs
View File

@@ -1,15 +0,0 @@
<?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")
);

40
.php_cs.dist Normal file
View File

@@ -0,0 +1,40 @@
<?php
return PhpCsFixer\Config::create()
->setRiskyAllowed(true)
->setRules([
"@PSR1" => true,
"@PSR2" => true,
"braces" => [
"allow_single_line_closure" => true,
"position_after_functions_and_oop_constructs" => "same",
],
"array_syntax" => ["syntax" => "short"],
"cast_spaces" => true,
"combine_consecutive_unsets" => true,
"function_to_constant" => true,
"native_function_invocation" => true,
"no_multiline_whitespace_before_semicolons" => true,
"no_unused_imports" => true,
"no_useless_else" => true,
"no_useless_return" => true,
"no_whitespace_before_comma_in_array" => true,
"no_whitespace_in_blank_line" => true,
"non_printable_character" => true,
"normalize_index_brace" => true,
"ordered_imports" => true,
"php_unit_construct" => true,
"php_unit_dedicate_assert" => true,
"php_unit_fqcn_annotation" => true,
"phpdoc_summary" => true,
"phpdoc_types" => true,
"psr4" => true,
"return_type_declaration" => ["space_before" => "none"],
"short_scalar_cast" => true,
"single_blank_line_before_namespace" => true,
])
->setFinder(
PhpCsFixer\Finder::create()
->in(__DIR__ . "/src")
->in(__DIR__ . "/test")
);

View File

@@ -1,9 +1,9 @@
language: php
php:
- 5.5
- 5.6
- 7.0
- 7.1
- 7.2
- nightly
cache:
@@ -11,18 +11,15 @@ cache:
- vendor
install:
- phpenv config-rm xdebug.ini
- composer self-update
- 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 show --installed
- phpenv config-rm xdebug.ini || true
- composer install
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
- phpdbg -qrr vendor/bin/phpunit --coverage-text --coverage-clover build/logs/clover.xml
- PHP_CS_FIXER_IGNORE_ENV=1 php vendor/bin/php-cs-fixer --diff --dry-run -v fix
after_script:
- php vendor/bin/coveralls -v
- curl -OL https://github.com/php-coveralls/php-coveralls/releases/download/v1.0.0/coveralls.phar
- chmod +x coveralls.phar
- ./coveralls.phar -v

View File

@@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2015 Niklas Keller
Copyright (c) 2015-2017 Niklas Keller
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env php
<?php
use Amp\Loop;
use Auryn\Injector;
use Kelunik\AcmeClient\AcmeFactory;
use League\CLImate\CLImate;
@@ -13,7 +14,7 @@ $logo = <<<LOGO
LOGO;
if (!file_exists(__DIR__ . "/../vendor/autoload.php")) {
if (!file_exists(__DIR__ . '/../vendor/autoload.php')) {
echo $logo;
echo <<<HELP
@@ -26,7 +27,7 @@ HELP;
exit(-1);
}
if (!function_exists("openssl_pkey_get_private")) {
if (!function_exists('openssl_pkey_get_private')) {
echo $logo;
echo <<<HELP
@@ -37,17 +38,17 @@ HELP;
exit(-2);
}
require __DIR__ . "/../vendor/autoload.php";
require __DIR__ . '/../vendor/autoload.php';
$commands = [
"auto" => "Setup, issue and renew based on a single configuration file.",
"setup" => "Setup and register account.",
"issue" => "Issue a new certificate.",
"check" => "Check if a certificate is still valid long enough.",
"revoke" => "Revoke a certificate.",
"status" => "Show status about local certificates.",
"version" => "Print version information.",
"help" => "Print this help information.",
'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();
@@ -75,22 +76,22 @@ EOT;
$climate = new CLImate;
if (!in_array(PHP_SAPI, ["cli", "phpdbg"], true)) {
$climate->error("Please run this script on the command line!");
if (!in_array(PHP_SAPI, ['cli', 'phpdbg'], true)) {
$climate->error('Please run this script on the command line!');
exit(1);
}
if (PHP_VERSION_ID < 50600) {
$climate->yellow("You're using an older version of PHP which is no longer supported and will not even receive security fixes anymore. Have a look at http://php.net/supported-versions.php and upgrade now!");
if (PHP_VERSION_ID < 70000) {
$climate->yellow("You're using an older version of PHP which is no longer supported by this client. Have a look at http://php.net/supported-versions.php and upgrade at least to PHP 7.0!");
$climate->br(2);
}
if (count($argv) === 1 || in_array($argv[1], ["-h", "help", "--help"], true)) {
if (count($argv) === 1 || in_array($argv[1], ['-h', 'help', '--help'], true)) {
$climate->out($logo . $help);
exit(0);
}
if (!in_array($argv[1], array_keys($commands))) {
if (!array_key_exists($argv[1], $commands)) {
$climate->error("Unknown command '{$argv[1]}'. Use --help for a list of available commands.");
$suggestion = \Kelunik\AcmeClient\suggestCommand($argv[1], array_keys($commands));
@@ -114,14 +115,14 @@ try {
$climate->arguments->add($definition);
if (count($argv) === 3 && in_array($argv[2], ["-h", "--help"], true)) {
if (count($argv) === 3 && in_array($argv[2], ['-h', '--help'], true)) {
$climate->usage(["{$binary} {$argv[1]}"]);
$climate->br();
exit(0);
} else {
$climate->arguments->parse(array_values($args));
}
$climate->arguments->parse(array_values($args));
} catch (Exception $e) {
$climate->usage(["{$binary} {$argv[1]}"]);
$climate->br();
@@ -135,15 +136,17 @@ try {
$injector = new Injector;
$injector->share($climate);
$injector->share(new AcmeFactory);
$injector->share(new Amp\Artax\DefaultClient);
$command = $injector->make($class);
$exitCode = 1;
Amp\run(function () use ($command, $climate) {
Loop::run(function () use ($command, $climate, &$exitCode) {
$handler = function ($e) use ($climate) {
$error = (string) $e;
$lines = explode("\n", $error);
$lines = array_filter($lines, function ($line) {
return strlen($line) && $line[0] !== "#" && $line !== "Stack trace:";
return $line !== '' && $line[0] !== '#' && $line !== 'Stack trace:';
});
foreach ($lines as $line) {
@@ -154,18 +157,16 @@ Amp\run(function () use ($command, $climate) {
};
try {
$exitCode = (yield $command->execute($climate->arguments));
$exitCode = yield $command->execute($climate->arguments);
if ($exitCode === null) {
exit(0);
$exitCode = 0;
}
exit($exitCode);
} catch (Throwable $e) {
$handler($e);
} catch (Exception $e) {
$handler($e);
}
Amp\stop();
Loop::stop();
});
exit($exitCode);

View File

@@ -11,20 +11,21 @@
"tls"
],
"require": {
"php": "^5.5|^7",
"php": ">=7",
"ext-openssl": "*",
"amphp/process": "^0.1.1",
"kelunik/acme": "^0.3",
"amphp/process": "^0.2",
"amphp/parallel": "^0.2.5",
"kelunik/acme": "^0.5",
"kelunik/certificate": "^1",
"league/climate": "^3",
"rdlowrey/auryn": "^1",
"webmozart/assert": "^1",
"league/climate": "^3.2",
"rdlowrey/auryn": "^1.4.2",
"webmozart/assert": "^1.2",
"symfony/yaml": "^3.0"
},
"require-dev": {
"phpunit/phpunit": "^5",
"friendsofphp/php-cs-fixer": "^1.9",
"macfja/phar-builder": "dev-events-dev-files"
"phpunit/phpunit": "^6",
"friendsofphp/php-cs-fixer": "^2.9",
"macfja/phar-builder": "^0.2.6"
},
"license": "MIT",
"authors": [
@@ -33,8 +34,6 @@
"email": "me@kelunik.com"
}
],
"minimum-stability": "dev",
"prefer-stable": true,
"autoload": {
"psr-4": {
"Kelunik\\AcmeClient\\": "src"
@@ -43,24 +42,27 @@
"src/functions.php"
]
},
"repositories": [
{
"type": "vcs",
"url": "https://github.com/kelunik/pharbuilder"
"config": {
"platform": {
"php": "7.0.0"
}
],
},
"extra": {
"phar-builder": {
"compression": "GZip",
"name": "acme-client.phar",
"output-dir": "build",
"include": ["info", "src", "vendor/kelunik/acme/res", "vendor/amphp/socket/var"],
"include": ["info", "src", "vendor/kelunik/acme/res"],
"include-dev": false,
"skip-shebang": false,
"entry-point": "bin/acme",
"events": {
"command.package.start": [
"mkdir -p info",
"git describe --tags > info/build.version",
"php -r 'echo time();' > info/build.time"
"php -r 'echo time();' > info/build.time",
"rm -rf vendor/amphp/file/travis",
"rm -rf vendor/amphp/parallel/travis"
],
"command.package.end": [
"rm -rf info",

2294
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
## Installation using Phar
This is the preferred installation method for usage on a production system.
This is the preferred installation method for usage on a production system. You can download `acme-client.phar` in the [release section](https://github.com/kelunik/acme-client/releases).
### Requirements

5
doc/migrations/0.3.0.md Normal file
View File

@@ -0,0 +1,5 @@
# Migration from 0.2.x to 0.3.x
If you used this client before `0.3.0` via the command line, nothing should change with this release. It is an internal rewrite to Amp v2, which is the underlying concurrency framework.
If you're depending on this package's internals, some things might have changed slightly. A detailed changelog can't be provided. My focus is the public command line API.

View File

@@ -77,8 +77,25 @@ the script will be quiet to be cron friendly. If an error occurs, the script wil
You should execute `acme-client auto` as a daily cron. It's recommended to setup e-mail notifications for all output of
that script.
Create a new script, e.g. in `/usr/local/bin/acme-renew`. The `PATH` might need to be modified to suit your system.
```bash
0 0 * * * acme-client auto; exit=$?; if [[ $exit = 4 ]] || [[ $exit = 5 ]]; then service nginx reload; fi
#!/usr/bin/env bash
export PATH='/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
acme-client auto
RC=$?
if [ $RC = 4 ] || [ $RC = 5 ]; then
service nginx reload
fi
```
```sh
# Cron Job Configuration
0 0 * * * /usr/local/bin/acme-renew
```
| Exit Code | Description |

View File

@@ -4,13 +4,10 @@ namespace Kelunik\AcmeClient;
use Kelunik\Acme\AcmeClient;
use Kelunik\Acme\AcmeService;
use Kelunik\Acme\KeyPair;
use Webmozart\Assert\Assert;
use Kelunik\Acme\Crypto\PrivateKey;
class AcmeFactory {
public function build($directory, KeyPair $keyPair) {
Assert::string($directory);
public function build(string $directory, PrivateKey $keyPair): AcmeService {
return new AcmeService(new AcmeClient($directory, $keyPair));
}
}
}

View File

@@ -2,15 +2,19 @@
namespace Kelunik\AcmeClient\Commands;
use Amp\CoroutineResult;
use Amp\ByteStream\Message;
use Amp\File;
use Amp\File\FilesystemException;
use Amp\Process;
use Amp\Process\Process;
use Amp\Promise;
use Kelunik\Acme\AcmeException;
use Kelunik\AcmeClient;
use Kelunik\AcmeClient\ConfigException;
use League\CLImate\Argument\Manager;
use League\CLImate\CLImate;
use Symfony\Component\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Yaml;
use function Amp\call;
class Auto implements Command {
const EXIT_CONFIG_ERROR = 1;
@@ -28,205 +32,228 @@ class Auto implements Command {
$this->climate = $climate;
}
public function execute(Manager $args) {
return \Amp\resolve($this->doExecute($args));
}
public function execute(Manager $args): Promise {
return call(function () use ($args) {
$configPath = $args->get('config');
/**
* @param Manager $args
* @return \Generator
*/
private function doExecute(Manager $args) {
$server = $args->get("server");
$storage = $args->get("storage");
$configPath = $args->get("config");
try {
$config = Yaml::parse(
yield \Amp\File\get($configPath)
);
} catch (FilesystemException $e) {
$this->climate->error("Config file ({$configPath}) not found.");
yield new CoroutineResult(self::EXIT_CONFIG_ERROR);
return;
} catch (ParseException $e) {
$this->climate->error("Config file ({$configPath}) had an invalid format and couldn't be parsed.");
yield new CoroutineResult(self::EXIT_CONFIG_ERROR);
return;
}
if (!isset($config["email"])) {
$this->climate->error("Config file ({$configPath}) didn't have a 'email' set.");
yield new CoroutineResult(self::EXIT_CONFIG_ERROR);
return;
}
if (!isset($config["certificates"]) || !is_array($config["certificates"])) {
$this->climate->error("Config file ({$configPath}) didn't have a 'certificates' section that's an array.");
yield new CoroutineResult(self::EXIT_CONFIG_ERROR);
return;
}
$command = implode(" ", array_map("escapeshellarg", [
PHP_BINARY,
$GLOBALS["argv"][0],
"setup",
"--server",
$server,
"--storage",
$storage,
"--email",
$config["email"],
]));
$process = new Process($command);
$result = (yield $process->exec(Process::BUFFER_ALL));
if ($result->exit !== 0) {
$this->climate->error("Registration failed ({$result->exit})");
$this->climate->error($command);
$this->climate->br()->out($result->stdout);
$this->climate->br()->error($result->stderr);
yield new CoroutineResult(self::EXIT_SETUP_ERROR);
return;
}
$certificateChunks = array_chunk($config["certificates"], 10, true);
$errors = [];
$values = [];
foreach ($certificateChunks as $certificateChunk) {
$promises = [];
foreach ($certificateChunk as $certificate) {
$promises[] = \Amp\resolve($this->checkAndIssue($certificate, $server, $storage));
try {
/** @var array $config */
$config = Yaml::parse(
yield File\get($configPath)
);
} catch (FilesystemException $e) {
$this->climate->error("Config file ({$configPath}) not found.");
return self::EXIT_CONFIG_ERROR;
} catch (ParseException $e) {
$this->climate->error("Config file ({$configPath}) had an invalid format and couldn't be parsed.");
return self::EXIT_CONFIG_ERROR;
}
list($chunkErrors, $chunkValues) = (yield \Amp\any($promises));
if ($args->defined('server')) {
$config['server'] = $args->get('server');
} elseif (!isset($config['server']) && $args->exists('server')) {
$config['server'] = $args->get('server');
}
$errors += $chunkErrors;
$values += $chunkValues;
}
if ($args->defined('storage')) {
$config['storage'] = $args->get('storage');
} elseif (!isset($config['storage']) && $args->exists('storage')) {
$config['storage'] = $args->get('storage');
}
$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 (!isset($config['server'])) {
$this->climate->error("Config file ({$configPath}) didn't have a 'server' set nor was it passed as command line argument.");
return self::EXIT_CONFIG_ERROR;
}
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 (!isset($config['storage'])) {
$this->climate->error("Config file ({$configPath}) didn't have a 'storage' set nor was it passed as command line argument.");
return self::EXIT_CONFIG_ERROR;
}
if (!isset($config['email'])) {
$this->climate->error("Config file ({$configPath}) didn't have a 'email' set.");
return self::EXIT_CONFIG_ERROR;
}
if (!isset($config['certificates']) || !\is_array($config['certificates'])) {
$this->climate->error("Config file ({$configPath}) didn't have a 'certificates' section that's an array.");
return self::EXIT_CONFIG_ERROR;
}
if (isset($config['challenge-concurrency']) && !\is_numeric($config['challenge-concurrency'])) {
$this->climate->error("Config file ({$configPath}) defines an invalid 'challenge-concurrency' value.");
return self::EXIT_CONFIG_ERROR;
}
$concurrency = isset($config['challenge-concurrency']) ? (int) $config['challenge-concurrency'] : null;
$process = new Process([
PHP_BINARY,
$GLOBALS['argv'][0],
'setup',
'--server',
$config['server'],
'--storage',
$config['storage'],
'--email',
$config['email'],
]);
$process->start();
$exit = yield $process->join();
if ($exit !== 0) {
$this->climate->error("Registration failed ({$exit})");
$this->climate->br()->out(yield new Message($process->getStdout()));
$this->climate->br()->error(yield new Message($process->getStderr()));
return self::EXIT_SETUP_ERROR;
}
$errors = [];
$values = [];
foreach ($config['certificates'] as $i => $certificate) {
try {
$exit = yield call(function () use ($certificate, $config, $concurrency) {
return $this->checkAndIssue($certificate, $config['server'], $config['storage'], $concurrency);
});
$values[$i] = $exit;
} catch (\Exception $e) {
$errors[$i] = $e;
}
}
}
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}");
$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.');
}
}
}
$exitCode = $status["renewed"] > 0
? self::EXIT_ISSUANCE_PARTIAL
: self::EXIT_ISSUANCE_ERROR;
if ($status['failure'] > 0) {
foreach ($errors as $i => $error) {
$certificate = $config['certificates'][$i];
$this->climate->error('Issuance for the following domains failed: ' . \implode(', ', \array_keys($this->toDomainPathMap($certificate['paths']))));
$this->climate->error("Reason: {$error}");
}
yield new CoroutineResult($exitCode);
return;
}
$exitCode = $status['renewed'] > 0
? self::EXIT_ISSUANCE_PARTIAL
: self::EXIT_ISSUANCE_ERROR;
if ($status["renewed"] > 0) {
yield new CoroutineResult(self::EXIT_ISSUANCE_OK);
return;
}
return $exitCode;
}
if ($status['renewed'] > 0) {
return self::EXIT_ISSUANCE_OK;
}
});
}
/**
* @param array $certificate certificate configuration
* @param string $server server to use for issuance
* @param string $storage storage directory
* @param array $certificate certificate configuration
* @param string $server server to use for issuance
* @param string $storage storage directory
* @param int|null $concurrency concurrent challenges
*
* @return \Generator
* @throws AcmeException if something does wrong
* @throws \Throwable
*/
private function checkAndIssue(array $certificate, $server, $storage) {
$domainPathMap = $this->toDomainPathMap($certificate["paths"]);
$domains = array_keys($domainPathMap);
$commonName = reset($domains);
private function checkAndIssue(array $certificate, string $server, string $storage, int $concurrency = null): \Generator {
$domainPathMap = $this->toDomainPathMap($certificate['paths']);
$domains = \array_keys($domainPathMap);
$commonName = \reset($domains);
$args = [
$process = new Process([
PHP_BINARY,
$GLOBALS["argv"][0],
"check",
"--server",
$GLOBALS['argv'][0],
'check',
'--server',
$server,
"--storage",
'--storage',
$storage,
"--name",
'--name',
$commonName,
];
'--names',
\implode(',', $domains),
]);
$command = implode(" ", array_map("escapeshellarg", $args));
$process->start();
$exit = yield $process->join();
$process = new Process($command);
$result = (yield $process->exec(Process::BUFFER_ALL));
if ($result->exit === 0) {
if ($exit === 0) {
// No need for renewal
yield new CoroutineResult(self::STATUS_NO_CHANGE);
return;
return self::STATUS_NO_CHANGE;
}
if ($result->exit === 1) {
if ($exit === 1) {
// Renew certificate
$args = [
PHP_BINARY,
$GLOBALS["argv"][0],
"issue",
"--server",
$GLOBALS['argv'][0],
'issue',
'--server',
$server,
"--storage",
'--storage',
$storage,
"--domains",
implode(",", $domains),
"--path",
implode(PATH_SEPARATOR, array_values($domainPathMap)),
'--domains',
\implode(',', $domains),
'--path',
\implode(PATH_SEPARATOR, \array_values($domainPathMap)),
];
if (isset($certificate["user"])) {
$args[] = "--user";
$args[] = $certificate["user"];
if (isset($certificate['user'])) {
$args[] = '--user';
$args[] = $certificate['user'];
}
if (isset($certificate["bits"])) {
$args[] = "--bits";
$args[] = $certificate["bits"];
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);
if ($concurrency) {
$args[] = '--challenge-concurrency';
$args[] = $concurrency;
}
yield new CoroutineResult(self::STATUS_RENEWED);
return;
$process = new Process($args);
$process->start();
$exit = yield $process->join();
if ($exit !== 0) {
// TODO: Print STDOUT and STDERR to file
throw new AcmeException("Unexpected exit code ({$exit}) for '{$process->getCommand()}'.");
}
return self::STATUS_RENEWED;
}
throw new AcmeException("Unexpected exit code ({$result->exit}) for '{$command}'." . PHP_EOL . $result->stdout . PHP_EOL . PHP_EOL . $result->stderr);
// TODO: Print STDOUT and STDERR to file
throw new AcmeException("Unexpected exit code ({$exit}) for '{$process->getCommand()}'.");
}
private function toDomainPathMap(array $paths) {
$result = [];
foreach ($paths as $path => $domains) {
if (is_numeric($path)) {
if (\is_numeric($path)) {
$message = <<<MESSAGE
Your configuration has the wrong format. Received a numeric value as path name.
@@ -276,25 +303,31 @@ MESSAGE;
return $result;
}
public static function getDefinition() {
public static function getDefinition(): array {
$server = AcmeClient\getArgumentDescription('server');
$storage = AcmeClient\getArgumentDescription('storage');
$server['required'] = false;
$storage['required'] = false;
$args = [
"server" => \Kelunik\AcmeClient\getArgumentDescription("server"),
"storage" => \Kelunik\AcmeClient\getArgumentDescription("storage"),
"config" => [
"prefix" => "c",
"longPrefix" => "config",
"description" => "Configuration file to read.",
"required" => true,
'server' => $server,
'storage' => $storage,
'config' => [
'prefix' => 'c',
'longPrefix' => 'config',
'description' => 'Configuration file to read.',
'required' => true,
],
];
$configPath = \Kelunik\AcmeClient\getConfigPath();
$configPath = AcmeClient\getConfigPath();
if ($configPath) {
$args["config"]["required"] = false;
$args["config"]["defaultValue"] = $configPath;
$args['config']['required'] = false;
$args['config']['defaultValue'] = $configPath;
}
return $args;
}
}
}

View File

@@ -2,12 +2,14 @@
namespace Kelunik\AcmeClient\Commands;
use Amp\CoroutineResult;
use Amp\Promise;
use Kelunik\AcmeClient;
use Kelunik\AcmeClient\Stores\CertificateStore;
use Kelunik\AcmeClient\Stores\CertificateStoreException;
use Kelunik\Certificate\Certificate;
use League\CLImate\Argument\Manager;
use League\CLImate\CLImate;
use function Amp\call;
class Check implements Command {
private $climate;
@@ -16,60 +18,68 @@ class Check implements Command {
$this->climate = $climate;
}
public function execute(Manager $args) {
return \Amp\resolve($this->doExecute($args));
public function execute(Manager $args): Promise {
return call(function () use ($args) {
$server = AcmeClient\resolveServer($args->get('server'));
$server = AcmeClient\serverToKeyname($server);
$path = AcmeClient\normalizePath($args->get('storage')) . '/certs/' . $server;
$certificateStore = new CertificateStore($path);
try {
$pem = yield $certificateStore->get($args->get('name'));
} catch (CertificateStoreException $e) {
$this->climate->br()->error(' Certificate not found.')->br();
return 1;
}
$cert = new Certificate($pem);
$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();
return 1;
}
}
if ($cert->getValidTo() > \time() + $args->get('ttl') * 24 * 60 * 60) {
return 0;
}
$this->climate->comment(' Certificate is going to expire within the specified ' . $args->get('ttl') . ' days.')->br();
return 1;
});
}
/**
* @param Manager $args
* @return \Generator
*/
private function doExecute(Manager $args) {
$server = \Kelunik\AcmeClient\resolveServer($args->get("server"));
$server = \Kelunik\AcmeClient\serverToKeyname($server);
$path = \Kelunik\AcmeClient\normalizePath($args->get("storage")) . "/certs/" . $server;
$certificateStore = new CertificateStore($path);
try {
$pem = (yield $certificateStore->get($args->get("name")));
} catch (CertificateStoreException $e) {
$this->climate->br()->error(" Certificate not found.")->br();
yield new CoroutineResult(1);
return;
}
$cert = new Certificate($pem);
$this->climate->br();
$this->climate->whisper(" Certificate is valid until " . date("d.m.Y", $cert->getValidTo()))->br();
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() {
public static function getDefinition(): array {
return [
"server" => \Kelunik\AcmeClient\getArgumentDescription("server"),
"storage" => \Kelunik\AcmeClient\getArgumentDescription("storage"),
"name" => [
"longPrefix" => "name",
"description" => "Common name of the certificate to check.",
"required" => true,
'server' => \Kelunik\AcmeClient\getArgumentDescription('server'),
'storage' => \Kelunik\AcmeClient\getArgumentDescription('storage'),
'name' => [
'longPrefix' => 'name',
'description' => 'Common name of the certificate to check.',
'required' => true,
],
"ttl" => [
"longPrefix" => "ttl",
"description" => "Minimum valid time in days.",
"defaultValue" => 30,
"castTo" => "int",
'ttl' => [
'longPrefix' => 'ttl',
'description' => 'Minimum valid time in days.',
'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,10 +2,11 @@
namespace Kelunik\AcmeClient\Commands;
use Amp\Promise;
use League\CLImate\Argument\Manager;
interface Command {
public function execute(Manager $args);
public function execute(Manager $args): Promise;
public static function getDefinition();
}
public static function getDefinition(): array;
}

View File

@@ -2,13 +2,16 @@
namespace Kelunik\AcmeClient\Commands;
use Amp\CoroutineResult;
use Amp\Dns\Record;
use Exception;
use Amp\Dns;
use Amp\Promise;
use Kelunik\Acme\AcmeException;
use Kelunik\Acme\AcmeService;
use Kelunik\Acme\KeyPair;
use Kelunik\Acme\OpenSSLKeyGenerator;
use Kelunik\Acme\Crypto\Backend\OpensslBackend;
use Kelunik\Acme\Crypto\PrivateKey;
use Kelunik\Acme\Crypto\RsaKeyGenerator;
use Kelunik\Acme\Csr\OpensslCsrGenerator;
use Kelunik\Acme\Verifiers\Http01;
use Kelunik\AcmeClient;
use Kelunik\AcmeClient\AcmeFactory;
use Kelunik\AcmeClient\Stores\CertificateStore;
use Kelunik\AcmeClient\Stores\ChallengeStore;
@@ -16,8 +19,8 @@ use Kelunik\AcmeClient\Stores\KeyStore;
use Kelunik\AcmeClient\Stores\KeyStoreException;
use League\CLImate\Argument\Manager;
use League\CLImate\CLImate;
use stdClass;
use Throwable;
use function Amp\call;
use function Kelunik\Acme\generateKeyAuthorization;
class Issue implements Command {
private $climate;
@@ -28,189 +31,167 @@ class Issue implements Command {
$this->acmeFactory = $acmeFactory;
}
public function execute(Manager $args) {
return \Amp\resolve($this->doExecute($args));
}
public function execute(Manager $args): Promise {
return call(function () use ($args) {
$user = null;
private function doExecute(Manager $args) {
if (strtoupper(substr(PHP_OS, 0, 3)) !== 'WIN') {
if (posix_geteuid() !== 0) {
$processUser = posix_getpwnam(posix_geteuid());
$currentUsername = $processUser["name"];
$user = $args->get("user") ?: $currentUsername;
if (0 !== \stripos(PHP_OS, 'WIN')) {
if (\posix_geteuid() !== 0) {
$processUser = \posix_getpwnam(\posix_geteuid());
$currentUsername = $processUser['name'];
$user = $args->get('user') ?: $currentUsername;
if ($currentUsername !== $user) {
throw new AcmeException("Running this script with --user only works as root!");
if ($currentUsername !== $user) {
throw new AcmeException('Running this script with --user only works as root!');
}
} else {
$user = $args->get('user') ?: 'www-data';
}
} else {
$user = $args->get("user") ?: "www-data";
}
}
$domains = array_map("trim", explode(":", str_replace([",", ";"], ":", $args->get("domains"))));
yield \Amp\resolve($this->checkDnsRecords($domains));
$docRoots = explode(PATH_SEPARATOR, str_replace("\\", "/", $args->get("path")));
$docRoots = array_map(function ($root) {
return rtrim($root, "/");
}, $docRoots);
if (count($domains) < count($docRoots)) {
throw new AcmeException("Specified more document roots than domains.");
}
if (count($domains) > count($docRoots)) {
$docRoots = array_merge(
$docRoots,
array_fill(count($docRoots), count($domains) - count($docRoots), end($docRoots))
);
}
$keyStore = new KeyStore(\Kelunik\AcmeClient\normalizePath($args->get("storage")));
$server = \Kelunik\AcmeClient\resolveServer($args->get("server"));
$keyFile = \Kelunik\AcmeClient\serverToKeyname($server);
try {
$keyPair = (yield $keyStore->get("accounts/{$keyFile}.pem"));
} catch (KeyStoreException $e) {
throw new AcmeException("Account key not found, did you run 'bin/acme setup'?", 0, $e);
}
$this->climate->br();
$acme = $this->acmeFactory->build($server, $keyPair);
$errors = [];
$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));
$domains = \array_map('trim', \explode(':', \str_replace([',', ';'], ':', $args->get('domains'))));
yield from $this->checkDnsRecords($domains);
$errors += $chunkErrors;
}
$docRoots = \explode(PATH_SEPARATOR, \str_replace("\\", '/', $args->get('path')));
$docRoots = \array_map(function ($root) {
return \rtrim($root, '/');
}, $docRoots);
if (!empty($errors)) {
foreach ($errors as $error) {
$this->climate->error($error->getMessage());
if (\count($domains) < \count($docRoots)) {
throw new AcmeException('Specified more document roots than domains.');
}
throw new AcmeException("Issuance failed, not all challenges could be solved.");
}
if (\count($domains) > \count($docRoots)) {
$docRoots = \array_merge(
$docRoots,
\array_fill(\count($docRoots), \count($domains) - \count($docRoots), \end($docRoots))
);
}
$path = "certs/" . $keyFile . "/" . reset($domains) . "/key.pem";
$bits = $args->get("bits");
$keyStore = new KeyStore(\Kelunik\AcmeClient\normalizePath($args->get('storage')));
try {
$keyPair = (yield $keyStore->get($path));
} catch (KeyStoreException $e) {
$keyPair = (new OpenSSLKeyGenerator)->generate($bits);
$keyPair = (yield $keyStore->put($path, $keyPair));
}
$server = \Kelunik\AcmeClient\resolveServer($args->get('server'));
$keyFile = \Kelunik\AcmeClient\serverToKeyname($server);
$this->climate->br();
$this->climate->whisper(" Requesting certificate ...");
try {
$key = yield $keyStore->get("accounts/{$keyFile}.pem");
} catch (KeyStoreException $e) {
throw new AcmeException("Account key not found, did you run 'bin/acme setup'?", 0, $e);
}
$location = (yield $acme->requestCertificate($keyPair, $domains));
$certificates = (yield $acme->pollForCertificate($location));
$this->climate->br();
$path = \Kelunik\AcmeClient\normalizePath($args->get("storage")) . "/certs/" . $keyFile;
$certificateStore = new CertificateStore($path);
yield $certificateStore->put($certificates);
$acme = $this->acmeFactory->build($server, $key);
$concurrency = \min(20, \max($args->get('challenge-concurrency'), 1));
$this->climate->info(" Successfully issued certificate.");
$this->climate->info(" See {$path}/" . reset($domains));
$this->climate->br();
/** @var \Throwable[] $errors */
list($errors) = yield AcmeClient\concurrentMap($concurrency, $domains, function ($domain, $i) use ($acme, $key, $docRoots, $user) {
return $this->solveChallenge($acme, $key, $domain, $docRoots[$i], $user);
});
yield new CoroutineResult(0);
if ($errors) {
foreach ($errors as $error) {
$this->climate->error($error->getMessage());
}
throw new AcmeException('Issuance failed, not all challenges could be solved.');
}
$path = 'certs/' . $keyFile . '/' . \reset($domains) . '/key.pem';
$bits = $args->get('bits');
try {
$key = yield $keyStore->get($path);
} catch (KeyStoreException $e) {
$key = (new RsaKeyGenerator($bits))->generateKey();
$key = yield $keyStore->put($path, $key);
}
$this->climate->br();
$this->climate->whisper(' Requesting certificate ...');
$csr = yield (new OpensslCsrGenerator)->generateCsr($key, $domains);
$location = yield $acme->requestCertificate($csr);
$certificates = yield $acme->pollForCertificate($location);
$path = AcmeClient\normalizePath($args->get('storage')) . '/certs/' . $keyFile;
$certificateStore = new CertificateStore($path);
yield $certificateStore->put($certificates);
$this->climate->info(' Successfully issued certificate.');
$this->climate->info(" See {$path}/" . \reset($domains));
$this->climate->br();
return 0;
});
}
private function solveChallenge(AcmeService $acme, KeyPair $keyPair, $domain, $path) {
list($location, $challenges) = (yield $acme->requestChallenges($domain));
private function solveChallenge(AcmeService $acme, PrivateKey $key, string $domain, string $path, string $user = null): \Generator {
list($location, $challenges) = yield $acme->requestChallenges($domain);
$goodChallenges = $this->findSuitableCombination($challenges);
if (empty($goodChallenges)) {
throw new AcmeException("Couldn't find any combination of challenges which this client can solve!");
}
$challenge = $challenges->challenges[reset($goodChallenges)];
$challenge = $challenges->challenges[\reset($goodChallenges)];
$token = $challenge->token;
if (!preg_match("#^[a-zA-Z0-9-_]+$#", $token)) {
throw new AcmeException("Protocol violation: Invalid Token!");
if (!\preg_match('#^[a-zA-Z0-9-_]+$#', $token)) {
throw new AcmeException('Protocol violation: Invalid Token!');
}
$payload = $acme->generateHttp01Payload($keyPair, $token);
$payload = generateKeyAuthorization($key, $token, new OpensslBackend);
$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, $user);
yield $acme->verifyHttp01Challenge($domain, $token, $payload);
yield (new Http01)->verifyChallenge($domain, $token, $payload);
yield $acme->answerChallenge($challenge->uri, $payload);
yield $acme->pollForChallenge($location);
$this->climate->comment(" {$domain} is now authorized.");
} finally {
yield $challengeStore->delete($token);
} catch (Exception $e) {
// no finally because generators...
yield $challengeStore->delete($token);
throw $e;
} catch (Throwable $e) {
// no finally because generators...
yield $challengeStore->delete($token);
throw $e;
}
}
private function checkDnsRecords($domains) {
$errors = [];
private function checkDnsRecords(array $domains): \Generator {
$promises = AcmeClient\concurrentMap(10, $domains, function (string $domain): Promise {
return Dns\resolve($domain);
});
$domainChunks = array_chunk($domains, 10, true);
list($errors) = yield Promise\any($promises);
foreach ($domainChunks as $domainChunk) {
$promises = [];
if ($errors) {
$failedDomains = \implode(', ', \array_keys($errors));
$reasons = \implode("\n\n", \array_map(function ($exception) {
/** @var \Throwable $exception */
return \get_class($exception) . ': ' . $exception->getMessage();
}, $errors));
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;
}
if (!empty($errors)) {
throw new AcmeException("Couldn't resolve the following domains to an IPv4 nor IPv6 record: " . implode(", ", array_keys($errors)));
throw new AcmeException("Couldn't resolve the following domains to an IPv4 nor IPv6 record: {$failedDomains}\n\n{$reasons}");
}
}
private function findSuitableCombination(stdClass $response) {
$challenges = isset($response->challenges) ? $response->challenges : [];
$combinations = isset($response->combinations) ? $response->combinations : [];
private function findSuitableCombination(\stdClass $response): array {
$challenges = $response->challenges ?? [];
$combinations = $response->combinations ?? [];
$goodChallenges = [];
foreach ($challenges as $i => $challenge) {
if ($challenge->type === "http-01") {
if ($challenge->type === 'http-01') {
$goodChallenges[] = $i;
}
}
foreach ($goodChallenges as $i => $challenge) {
if (!in_array([$challenge], $combinations)) {
if (!\in_array([$challenge], $combinations, true)) {
unset($goodChallenges[$i]);
}
}
@@ -218,32 +199,38 @@ class Issue implements Command {
return $goodChallenges;
}
public static function getDefinition() {
public static function getDefinition(): array {
return [
"server" => \Kelunik\AcmeClient\getArgumentDescription("server"),
"storage" => \Kelunik\AcmeClient\getArgumentDescription("storage"),
"domains" => [
"prefix" => "d",
"longPrefix" => "domains",
"description" => "Colon / Semicolon / Comma separated list of domains to request a certificate for.",
"required" => true,
'server' => AcmeClient\getArgumentDescription('server'),
'storage' => AcmeClient\getArgumentDescription('storage'),
'domains' => [
'prefix' => 'd',
'longPrefix' => 'domains',
'description' => 'Colon / Semicolon / Comma separated list of domains to request a certificate for.',
'required' => true,
],
"path" => [
"prefix" => "p",
"longPrefix" => "path",
"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,
'path' => [
'prefix' => 'p',
'longPrefix' => 'path',
'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" => [
"prefix" => "u",
"longPrefix" => "user",
"description" => "User running the web server.",
'user' => [
'prefix' => 'u',
'longPrefix' => 'user',
'description' => 'User running the web server.',
],
"bits" => [
"longPrefix" => "bits",
"description" => "Length of the private key in bit.",
"defaultValue" => 2048,
"castTo" => "int",
'bits' => [
'longPrefix' => 'bits',
'description' => 'Length of the private key in bit.',
'defaultValue' => 2048,
'castTo' => 'int',
],
'challenge-concurrency' => [
'longPrefix' => 'challenge-concurrency',
'description' => 'Number of challenges to be solved concurrently.',
'defaultValue' => 10,
'castTo' => 'int',
],
];
}

View File

@@ -2,14 +2,17 @@
namespace Kelunik\AcmeClient\Commands;
use Amp\CoroutineResult;
use Amp\File;
use Amp\File\FilesystemException;
use Amp\Promise;
use Kelunik\AcmeClient;
use Kelunik\AcmeClient\AcmeFactory;
use Kelunik\AcmeClient\Stores\CertificateStore;
use Kelunik\AcmeClient\Stores\KeyStore;
use Kelunik\Certificate\Certificate;
use League\CLImate\Argument\Manager;
use League\CLImate\CLImate;
use function Amp\call;
class Revoke implements Command {
private $climate;
@@ -20,58 +23,56 @@ class Revoke implements Command {
$this->acmeFactory = $acmeFactory;
}
public function execute(Manager $args) {
return \Amp\resolve($this->doExecute($args));
public function execute(Manager $args): Promise {
return call(function () use ($args) {
$keyStore = new KeyStore(AcmeClient\normalizePath($args->get('storage')));
$server = AcmeClient\resolveServer($args->get('server'));
$keyFile = AcmeClient\serverToKeyname($server);
$keyPair = yield $keyStore->get("accounts/{$keyFile}.pem");
$acme = $this->acmeFactory->build($server, $keyPair);
$this->climate->br();
$this->climate->whisper(' Revoking certificate ...');
$path = AcmeClient\normalizePath($args->get('storage')) . '/certs/' . $keyFile . '/' . $args->get('name') . '/cert.pem';
try {
$pem = yield File\get($path);
$cert = new Certificate($pem);
} catch (FilesystemException $e) {
throw new \RuntimeException("There's no such certificate (" . $path . ')');
}
if ($cert->getValidTo() < \time()) {
$this->climate->comment(' Certificate did already expire, no need to revoke it.');
}
$names = $cert->getNames();
$this->climate->whisper(' Certificate was valid for ' . \count($names) . ' domains.');
$this->climate->whisper(' - ' . \implode(PHP_EOL . ' - ', $names) . PHP_EOL);
yield $acme->revokeCertificate($pem);
$this->climate->br();
$this->climate->info(' Certificate has been revoked.');
yield (new CertificateStore(AcmeClient\normalizePath($args->get('storage')) . '/certs/' . $keyFile))->delete($args->get('name'));
return 0;
});
}
private function doExecute(Manager $args) {
$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 = $this->acmeFactory->build($server, $keyPair);
$this->climate->br();
$this->climate->whisper(" Revoking certificate ...");
$path = \Kelunik\AcmeClient\normalizePath($args->get("storage")) . "/certs/" . $keyFile . "/" . $args->get("name") . "/cert.pem";
try {
$pem = (yield \Amp\File\get($path));
$cert = new Certificate($pem);
} catch (FilesystemException $e) {
throw new \RuntimeException("There's no such certificate (" . $path . ")");
}
if ($cert->getValidTo() < time()) {
$this->climate->comment(" Certificate did already expire, no need to revoke it.");
}
$names = $cert->getNames();
$this->climate->whisper(" Certificate was valid for " . count($names) . " domains.");
$this->climate->whisper(" - " . implode(PHP_EOL . " - ", $names) . PHP_EOL);
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() {
public static function getDefinition(): array {
return [
"server" => \Kelunik\AcmeClient\getArgumentDescription("server"),
"storage" => \Kelunik\AcmeClient\getArgumentDescription("storage"),
"name" => [
"longPrefix" => "name",
"description" => "Common name of the certificate to be revoked.",
"required" => true,
'server' => AcmeClient\getArgumentDescription('server'),
'storage' => AcmeClient\getArgumentDescription('storage'),
'name' => [
'longPrefix' => 'name',
'description' => 'Common name of the certificate to be revoked.',
'required' => true,
],
];
}
}
}

View File

@@ -2,19 +2,22 @@
namespace Kelunik\AcmeClient\Commands;
use Amp\CoroutineResult;
use Amp\Dns;
use Amp\Dns\NoRecordException;
use Amp\Dns\Record;
use Amp\Dns\ResolutionException;
use InvalidArgumentException;
use Amp\Promise;
use Kelunik\Acme\AcmeException;
use Kelunik\Acme\OpenSSLKeyGenerator;
use Kelunik\Acme\Crypto\RsaKeyGenerator;
use Kelunik\Acme\Registration;
use Kelunik\AcmeClient;
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;
use function Amp\call;
class Setup implements Command {
private $climate;
@@ -25,88 +28,84 @@ class Setup implements Command {
$this->acmeFactory = $acmeFactory;
}
public function execute(Manager $args) {
return \Amp\resolve($this->doExecute($args));
public function execute(Manager $args): Promise {
return call(function () use ($args) {
$email = $args->get('email');
yield from $this->checkEmail($email);
$server = AcmeClient\resolveServer($args->get('server'));
$keyFile = AcmeClient\serverToKeyname($server);
$path = "accounts/{$keyFile}.pem";
$bits = 4096;
$keyStore = new KeyStore(\Kelunik\AcmeClient\normalizePath($args->get('storage')));
$this->climate->br();
try {
$keyPair = yield $keyStore->get($path);
$this->climate->whisper(' Using existing private key ...');
} catch (KeyStoreException $e) {
$this->climate->whisper(' No private key found, generating new one ...');
$keyPair = (new RsaKeyGenerator($bits))->generateKey();
$keyPair = yield $keyStore->put($path, $keyPair);
$this->climate->whisper(" Generated new private key with {$bits} bits.");
}
$acme = $this->acmeFactory->build($server, $keyPair);
$this->climate->whisper(' Registering with ' . \substr($server, 8) . ' ...');
/** @var Registration $registration */
$registration = yield $acme->register($email);
$this->climate->info(' Registration successful. Contacts: ' . \implode(', ', $registration->getContact()));
$this->climate->br();
return 0;
});
}
public function doExecute(Manager $args) {
$email = $args->get("email");
yield \Amp\resolve($this->checkEmail($email));
$server = \Kelunik\AcmeClient\resolveServer($args->get("server"));
$keyFile = \Kelunik\AcmeClient\serverToKeyname($server);
$path = "accounts/{$keyFile}.pem";
$bits = 4096;
$keyStore = new KeyStore(\Kelunik\AcmeClient\normalizePath($args->get("storage")));
$this->climate->br();
try {
$keyPair = (yield $keyStore->get($path));
$this->climate->whisper(" Using existing private key ...");
} catch (KeyStoreException $e) {
$this->climate->whisper(" No private key found, generating new one ...");
$keyPair = (new OpenSSLKeyGenerator)->generate($bits);
$keyPair = (yield $keyStore->put($path, $keyPair));
$this->climate->whisper(" Generated new private key with {$bits} bits.");
}
$acme = $this->acmeFactory->build($server, $keyPair);
$this->climate->whisper(" Registering with " . substr($server, 8) . " ...");
/** @var Registration $registration */
$registration = (yield $acme->register($email));
$this->climate->info(" Registration successful. Contacts: " . implode(", ", $registration->getContact()));
$this->climate->br();
yield new CoroutineResult(0);
}
private function checkEmail($email) {
if (!is_string($email)) {
throw new InvalidArgumentException(sprintf("\$email must be of type string, %s given.", gettype($email)));
}
$host = substr($email, strrpos($email, "@") + 1);
private function checkEmail(string $email) {
$host = \substr($email, \strrpos($email, '@') + 1);
if (!$host) {
throw new AcmeException("Invalid contact email: '{$email}'");
}
try {
yield \Amp\Dns\query($host, Record::MX);
} catch (ResolutionException $e) {
yield Dns\query($host, Record::MX);
} 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() {
public static function getDefinition(): array {
$args = [
"server" => \Kelunik\AcmeClient\getArgumentDescription("server"),
"storage" => \Kelunik\AcmeClient\getArgumentDescription("storage"),
"email" => [
"longPrefix" => "email",
"description" => "E-mail for important issues, will be sent to the ACME server.",
"required" => true,
'server' => AcmeClient\getArgumentDescription('server'),
'storage' => AcmeClient\getArgumentDescription('storage'),
'email' => [
'longPrefix' => 'email',
'description' => 'E-mail for important issues, will be sent to the ACME server.',
'required' => true,
],
];
$configPath = \Kelunik\AcmeClient\getConfigPath();
$configPath = AcmeClient\getConfigPath();
if ($configPath) {
$config = Yaml::parse(file_get_contents($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"];
if (isset($config['email']) && \is_string($config['email'])) {
$args['email']['required'] = false;
$args['email']['defaultValue'] = $config['email'];
}
}
return $args;
}
}
}

View File

@@ -2,12 +2,16 @@
namespace Kelunik\AcmeClient\Commands;
use Amp\File;
use Amp\Promise;
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;
use function Amp\call;
use function Kelunik\AcmeClient\getArgumentDescription;
class Status {
private $climate;
@@ -16,65 +20,60 @@ class Status {
$this->climate = $climate;
}
public function execute(Manager $args) {
return \Amp\resolve($this->doExecute($args));
}
public function execute(Manager $args): Promise {
return call(function () use ($args) {
$server = \Kelunik\AcmeClient\resolveServer($args->get('server'));
$keyName = \Kelunik\AcmeClient\serverToKeyname($server);
/**
* @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'));
$storage = \Kelunik\AcmeClient\normalizePath($args->get("storage"));
try {
$keyStore = new KeyStore($storage);
yield $keyStore->get("accounts/{$keyName}.pem");
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()));
$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 File\exists($storage . "/certs/{$keyName}")) {
$certificateStore = new CertificateStore($storage . "/certs/{$keyName}");
/** @var array $domains */
$domains = yield 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() {
public static function getDefinition(): array {
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",
'server' => getArgumentDescription('server'),
'storage' => getArgumentDescription('storage'),
'ttl' => [
'longPrefix' => 'ttl',
'description' => 'Minimum valid time in days, shows ⭮ if renewal is required.',
'defaultValue' => 30,
'castTo' => 'int',
],
];
}
}
}

View File

@@ -2,9 +2,10 @@
namespace Kelunik\AcmeClient\Commands;
use Amp\Promise;
use Amp\Success;
use League\CLImate\Argument\Manager;
use League\CLImate\CLImate;
use RuntimeException;
class Version implements Command {
private $climate;
@@ -13,64 +14,66 @@ class Version implements Command {
$this->climate = $climate;
}
public function execute(Manager $args) {
public function execute(Manager $args): Promise {
$version = $this->getVersion();
$buildTime = $this->readFileOr("info/build.time", time());
$buildDate = date('M jS Y H:i:s T', (int) trim($buildTime));
$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.")));
$package = \json_decode($this->readFileOr('composer.json', new \Exception('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));
$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.")));
if ($args->defined('deps')) {
$lockFile = \json_decode($this->readFileOr('composer.lock', new \Exception('No composer.lock found.')));
$packages = $lockFile->packages;
for ($i = 0; $i < count($packages); $i++) {
$link = $i === count($packages) - 1 ? "└──" : "├──";
for ($i = 0, $count = \count($packages); $i < $count; $i++) {
$link = $i === $count - 1 ? '└──' : '├──';
$this->climate->out("{$link} <green>{$packages[$i]->name}</green> @ <yellow>{$packages[$i]->version}</yellow>");
$link = $i === count($packages) - 1 ? " " : "";
$link = $i === $count - 1 ? ' ' : '│ ';
$this->climate->out("{$link} " . $this->getDescription($packages[$i]));
}
}
return new Success;
}
private function getDescription($package) {
return \Kelunik\AcmeClient\ellipsis(isset($package->description) ? $package->description : "");
return \Kelunik\AcmeClient\ellipsis($package->description ?? '');
}
private function getVersion() {
if (file_exists(__DIR__ . "/../../.git")) {
if (\file_exists(__DIR__ . '/../../.git')) {
$version = `git describe --tags`;
} else {
$version = $this->readFileOr("info/build.version", "-unknown");
$version = $this->readFileOr('info/build.version', '-unknown');
}
return substr(trim($version), 1);
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;
private function readFileOr($file, $default = '') {
if (\file_exists(__DIR__ . '/../../' . $file)) {
return \file_get_contents(__DIR__ . '/../../' . $file);
}
if ($default instanceof \Throwable) {
throw $default;
}
return $default;
}
public static function getDefinition() {
public static function getDefinition(): array {
return [
"deps" => [
"longPrefix" => "deps",
"description" => "Show also the bundled dependency versions.",
"noValue" => true,
'deps' => [
'longPrefix' => 'deps',
'description' => 'Show also the bundled dependency versions.',
'noValue' => true,
],
];
}
}
}

View File

@@ -2,4 +2,5 @@
namespace Kelunik\AcmeClient;
class ConfigException extends \Exception { }
class ConfigException extends \Exception {
}

View File

@@ -2,92 +2,83 @@
namespace Kelunik\AcmeClient\Stores;
use Amp\CoroutineResult;
use Amp\File;
use Amp\File\FilesystemException;
use InvalidArgumentException;
use Amp\Promise;
use Kelunik\Certificate\Certificate;
use Webmozart\Assert\Assert;
use function Amp\call;
use function Amp\Uri\isValidDnsName;
class CertificateStore {
private $root;
public function __construct($root) {
if (!is_string($root)) {
throw new InvalidArgumentException(sprintf("\$root must be of type string, %s given.", gettype($root)));
}
$this->root = rtrim(str_replace("\\", "/", $root), "/");
public function __construct(string $root) {
$this->root = \rtrim(\str_replace("\\", '/', $root), '/');
}
public function get($name) {
return \Amp\resolve($this->doGet($name));
public function get(string $name): Promise {
return call(function () use ($name) {
try {
return yield File\get($this->root . '/' . $name . '/cert.pem');
} catch (FilesystemException $e) {
throw new CertificateStoreException('Failed to load certificate.', 0, $e);
}
});
}
private function doGet($name) {
Assert::string($name, "Name must be a string. Got: %s");
try {
$contents = (yield \Amp\File\get($this->root . "/" . $name . "/cert.pem"));
yield new CoroutineResult($contents);
} catch (FilesystemException $e) {
throw new CertificateStoreException("Failed to load certificate.", 0, $e);
}
}
public function put(array $certificates) {
return \Amp\resolve($this->doPut($certificates));
}
private function doPut(array $certificates) {
if (empty($certificates)) {
throw new InvalidArgumentException("Empty array not allowed");
}
$cert = new Certificate($certificates[0]);
$commonName = $cert->getSubject()->getCommonName();
if (!$commonName) {
throw new CertificateStoreException("Certificate doesn't have a common name.");
}
// See https://github.com/amphp/dns/blob/4c4d450d4af26fc55dc56dcf45ec7977373a38bf/lib/functions.php#L83
if (isset($commonName[253]) || !preg_match("~^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9]){0,1})(?:\\.[a-z0-9][a-z0-9-]{0,61}[a-z0-9])*$~i", $commonName)) {
throw new CertificateStoreException("Invalid common name: '{$commonName}'");
}
try {
$chain = array_slice($certificates, 1);
$path = $this->root . "/" . $commonName;
$realpath = realpath($path);
if (!$realpath && !mkdir($path, 0775, true)) {
throw new FilesystemException("Couldn't create certificate directory: '{$path}'");
public function put(array $certificates): Promise {
return call(function () use ($certificates) {
if (empty($certificates)) {
throw new \Error('Empty array not allowed');
}
yield \Amp\File\put($path . "/cert.pem", $certificates[0]);
yield \Amp\File\chmod($path . "/cert.pem", 0644);
$cert = new Certificate($certificates[0]);
$commonName = $cert->getSubject()->getCommonName();
yield \Amp\File\put($path . "/fullchain.pem", implode("\n", $certificates));
yield \Amp\File\chmod($path . "/fullchain.pem", 0644);
if (!$commonName) {
throw new CertificateStoreException("Certificate doesn't have a common name.");
}
yield \Amp\File\put($path . "/chain.pem", implode("\n", $chain));
yield \Amp\File\chmod($path . "/chain.pem", 0644);
} catch (FilesystemException $e) {
throw new CertificateStoreException("Couldn't save certificates for '{$commonName}'", 0, $e);
}
if (!isValidDnsName($commonName)) {
throw new CertificateStoreException("Invalid common name: '{$commonName}'");
}
try {
$chain = \array_slice($certificates, 1);
$path = $this->root . '/' . $commonName;
if (!yield File\isdir($path)) {
yield File\mkdir($path, 0755, true);
if (!yield File\isdir($path)) {
throw new FilesystemException("Couldn't create certificate directory: '{$path}'");
}
}
yield File\put($path . '/cert.pem', $certificates[0]);
yield File\chmod($path . '/cert.pem', 0644);
yield File\put($path . '/fullchain.pem', \implode("\n", $certificates));
yield File\chmod($path . '/fullchain.pem', 0644);
yield File\put($path . '/chain.pem', \implode("\n", $chain));
yield File\chmod($path . '/chain.pem', 0644);
} catch (FilesystemException $e) {
throw new CertificateStoreException("Couldn't save certificates for '{$commonName}'", 0, $e);
}
});
}
public function delete($name) {
return \Amp\resolve($this->doDelete($name));
}
public function delete(string $name): Promise {
return call(function () use ($name) {
/** @var array $files */
$files = yield File\scandir($this->root . '/' . $name);
private function doDelete($name) {
Assert::string($name, "Name must be a string. Got: %s");
foreach ($files as $file) {
yield File\unlink($this->root . '/' . $name . '/' . $file);
}
foreach ((yield \Amp\File\scandir($this->root . "/" . $name)) as $file) {
yield \Amp\File\unlink($this->root . "/" . $name . "/" . $file);
}
yield \Amp\File\rmdir($this->root . "/" . $name);
yield File\rmdir($this->root . '/' . $name);
});
}
}

View File

@@ -2,8 +2,5 @@
namespace Kelunik\AcmeClient\Stores;
use RuntimeException;
class CertificateStoreException extends RuntimeException {
}
class CertificateStoreException extends \Exception {
}

View File

@@ -2,72 +2,60 @@
namespace Kelunik\AcmeClient\Stores;
use InvalidArgumentException;
use Webmozart\Assert\Assert;
use Amp\File;
use Amp\Promise;
use function Amp\call;
class ChallengeStore {
private $docroot;
public function __construct($docroot) {
if (!is_string($docroot)) {
throw new InvalidArgumentException(sprintf("\$docroot must be of type string, %s given.", gettype($docroot)));
}
$this->docroot = rtrim(str_replace("\\", "/", $docroot), "/");
public function __construct(string $docroot) {
$this->docroot = \rtrim(\str_replace("\\", '/', $docroot), '/');
}
public function put($token, $payload, $user = null) {
return \Amp\resolve($this->doPut($token, $payload, $user));
}
public function put(string $token, string $payload, string $user = null): Promise {
return call(function () use ($token, $payload, $user) {
$path = $this->docroot . '/.well-known/acme-challenge';
$userInfo = null;
private function doPut($token, $payload, $user = null) {
Assert::string($token, "Token must be a string. Got: %s");
Assert::string($payload, "Payload must be a string. Got: %s");
Assert::nullOrString($user, "User must be a string or null. Got: %s");
if (!yield File\exists($this->docroot)) {
throw new ChallengeStoreException("Document root doesn't exist: '{$this->docroot}'");
}
$path = $this->docroot . "/.well-known/acme-challenge";
$realpath = realpath($path);
if (!yield File\isdir($path)) {
yield File\mkdir($path, 0755, true);
if (!realpath($this->docroot)) {
throw new ChallengeStoreException("Document root doesn't exist: '{$this->docroot}'");
}
if (!yield File\isdir($path)) {
throw new ChallengeStoreException("Couldn't create key directory: '{$path}'");
}
}
if (!$realpath && !@mkdir($path, 0755, true)) {
throw new ChallengeStoreException("Couldn't create public directory to serve the challenges: '{$path}'");
}
if ($user) {
if (!$userInfo = posix_getpwnam($user)) {
if ($user && !$userInfo = \posix_getpwnam($user)) {
throw new ChallengeStoreException("Unknown user: '{$user}'");
}
}
if (isset($userInfo)) {
yield \Amp\File\chown($this->docroot . "/.well-known", $userInfo["uid"], -1);
yield \Amp\File\chown($this->docroot . "/.well-known/acme-challenge", $userInfo["uid"], -1);
}
if ($userInfo !== null) {
yield File\chown($this->docroot . '/.well-known', $userInfo['uid'], -1);
yield File\chown($this->docroot . '/.well-known/acme-challenge', $userInfo['uid'], -1);
}
yield \Amp\File\put("{$path}/{$token}", $payload);
yield File\put("{$path}/{$token}", $payload);
if (isset($userInfo)) {
yield \Amp\File\chown("{$path}/{$token}", $userInfo["uid"], -1);
}
if ($userInfo !== null) {
yield File\chown("{$path}/{$token}", $userInfo['uid'], -1);
}
yield \Amp\File\chmod("{$path}/{$token}", 0644);
yield File\chmod("{$path}/{$token}", 0644);
});
}
public function delete($token) {
return \Amp\resolve($this->doDelete($token));
public function delete(string $token): Promise {
return call(function () use ($token) {
$path = $this->docroot . "/.well-known/acme-challenge/{$token}";
if (yield File\exists($path)) {
yield File\unlink($path);
}
});
}
private function doDelete($token) {
Assert::string($token, "Token must be a string. Got: %s");
$path = $this->docroot . "/.well-known/acme-challenge/{$token}";
$realpath = realpath($path);
if ($realpath) {
yield \Amp\File\unlink($realpath);
}
}
}
}

View File

@@ -2,8 +2,5 @@
namespace Kelunik\AcmeClient\Stores;
use RuntimeException;
class ChallengeStoreException extends RuntimeException {
}
class ChallengeStoreException extends \Exception {
}

View File

@@ -2,85 +2,62 @@
namespace Kelunik\AcmeClient\Stores;
use Amp\CoroutineResult;
use Amp\File;
use Amp\File\FilesystemException;
use InvalidArgumentException;
use Kelunik\Acme\KeyPair;
use Amp\Promise;
use Kelunik\Acme\Crypto\PrivateKey;
use function Amp\call;
class KeyStore {
private $root;
public function __construct($root = "") {
if (!is_string($root)) {
throw new InvalidArgumentException(sprintf("\$root must be of type string, %s given.", gettype($root)));
}
$this->root = rtrim(str_replace("\\", "/", $root), "/");
public function __construct(string $root = '') {
$this->root = \rtrim(\str_replace("\\", '/', $root), '/');
}
public function get($path) {
if (!is_string($path)) {
throw new InvalidArgumentException(sprintf("\$root must be of type string, %s given.", gettype($path)));
}
public function get(string $path): Promise {
return call(function () use ($path) {
$file = $this->root . '/' . $path;
return \Amp\resolve($this->doGet($path));
}
try {
$privateKey = yield File\get($file);
private function doGet($path) {
if (!is_string($path)) {
throw new InvalidArgumentException(sprintf("\$root must be of type string, %s given.", gettype($path)));
}
// Check key here to be valid, PrivateKey doesn't do that, we fail early here
$res = \openssl_pkey_get_private($privateKey);
$file = $this->root . "/" . $path;
$realpath = realpath($file);
if (!$realpath) {
throw new KeyStoreException("File not found: '{$file}'");
}
$privateKey = (yield \Amp\File\get($realpath));
$res = openssl_pkey_get_private($privateKey);
if ($res === false) {
throw new KeyStoreException("Invalid private key: '{$file}'");
}
$publicKey = openssl_pkey_get_details($res)["key"];
yield new CoroutineResult(new KeyPair($privateKey, $publicKey));
}
public function put($path, KeyPair $keyPair) {
if (!is_string($path)) {
throw new InvalidArgumentException(sprintf("\$root must be of type string, %s given.", gettype($path)));
}
return \Amp\resolve($this->doPut($path, $keyPair));
}
private function doPut($path, KeyPair $keyPair) {
if (!is_string($path)) {
throw new InvalidArgumentException(sprintf("\$root must be of type string, %s given.", gettype($path)));
}
$file = $this->root . "/" . $path;
try {
// TODO: Replace with async version once available
if (!file_exists(dirname($file))) {
$success = mkdir(dirname($file), 0755, true);
if (!$success) {
throw new KeyStoreException("Could not create key store directory.");
if ($res === false) {
throw new KeyStoreException("Invalid private key: '{$file}'");
}
return new PrivateKey($privateKey);
} catch (FilesystemException $e) {
throw new KeyStoreException("Key not found: '{$file}'");
}
});
}
public function put(string $path, PrivateKey $key): Promise {
return call(function () use ($path, $key) {
$file = $this->root . '/' . $path;
try {
$dir = \dirname($file);
if (!yield File\isdir($dir)) {
yield File\mkdir($dir, 0755, true);
if (!yield File\isdir($dir)) {
throw new FilesystemException("Couldn't create key directory: '{$dir}'");
}
}
yield File\put($file, $key->toPem());
yield File\chmod($file, 0600);
} catch (FilesystemException $e) {
throw new KeyStoreException('Could not save key: ' . $e->getMessage(), 0, $e);
}
yield \Amp\File\put($file, $keyPair->getPrivate());
yield \Amp\File\chmod($file, 0600);
} catch (FilesystemException $e) {
throw new KeyStoreException("Could not save key.", 0, $e);
}
yield new CoroutineResult($keyPair);
return $key;
});
}
}
}

View File

@@ -2,8 +2,5 @@
namespace Kelunik\AcmeClient\Stores;
use RuntimeException;
class KeyStoreException extends RuntimeException {
}
class KeyStoreException extends \Exception {
}

View File

@@ -2,12 +2,30 @@
namespace Kelunik\AcmeClient;
use Amp\Sync\LocalSemaphore;
use Amp\Sync\Lock;
use InvalidArgumentException;
use Kelunik\Acme\AcmeException;
use Phar;
use Symfony\Component\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Yaml;
use Webmozart\Assert\Assert;
use function Amp\call;
use function Amp\coroutine;
function concurrentMap(int $concurrency, array $values, callable $functor): array {
$semaphore = new LocalSemaphore($concurrency);
return \array_map(coroutine(function ($value, $key) use ($semaphore, $functor) {
/** @var Lock $lock */
$lock = yield $semaphore->acquire();
try {
return yield call($functor, $value, $key);
} finally {
$lock->release();
}
}), $values, \array_keys($values));
}
/**
* Suggests a command based on similarity in a list of available commands.
@@ -15,20 +33,18 @@ use Webmozart\Assert\Assert;
* @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");
function suggestCommand(string $badCommand, array $commands, int $suggestThreshold = 70): string {
$badCommand = \strtolower($badCommand);
$badCommand = strtolower($badCommand);
$bestMatch = "";
$bestMatch = '';
$bestMatchPercentage = 0;
$byRefPercentage = 0;
foreach ($commands as $command) {
\similar_text($badCommand, strtolower($command), $byRefPercentage);
\similar_text($badCommand, \strtolower($command), $byRefPercentage);
if ($byRefPercentage > $bestMatchPercentage) {
$bestMatchPercentage = $byRefPercentage;
@@ -36,7 +52,7 @@ function suggestCommand($badCommand, array $commands, $suggestThreshold = 70) {
}
}
return $bestMatchPercentage >= $suggestThreshold ? $bestMatch : "";
return $bestMatchPercentage >= $suggestThreshold ? $bestMatch : '';
}
/**
@@ -44,46 +60,46 @@ function suggestCommand($badCommand, array $commands, $suggestThreshold = 70) {
* 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");
function resolveServer(string $uri): string {
$shortcuts = [
"letsencrypt" => "https://acme-v01.api.letsencrypt.org/directory",
"letsencrypt:production" => "https://acme-v01.api.letsencrypt.org/directory",
"letsencrypt:staging" => "https://acme-staging.api.letsencrypt.org/directory",
'letsencrypt' => 'https://acme-v01.api.letsencrypt.org/directory',
'letsencrypt:production' => 'https://acme-v01.api.letsencrypt.org/directory',
'letsencrypt:staging' => 'https://acme-staging.api.letsencrypt.org/directory',
];
if (isset($shortcuts[$uri])) {
return $shortcuts[$uri];
}
if (strpos($uri, "/") === false) {
throw new InvalidArgumentException("Invalid server URI: " . $uri);
if (\strpos($uri, '/') === false) {
throw new InvalidArgumentException('Invalid server URI: ' . $uri);
}
$protocol = substr($uri, 0, strpos($uri, "://"));
$protocol = \substr($uri, 0, \strpos($uri, '://'));
if (!$protocol || $protocol === $uri) {
return "https://{$uri}";
} else {
return $uri;
}
return $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);
function serverToKeyname(string $server): string {
$server = \substr($server, \strpos($server, '://') + 3);
$keyFile = str_replace("/", ".", $server);
$keyFile = preg_replace("@[^a-z0-9._-]@", "", $keyFile);
$keyFile = preg_replace("@\\.+@", ".", $keyFile);
$keyFile = \str_replace('/', '.', $server);
$keyFile = \preg_replace('@[^a-z0-9._-]@', '', $keyFile);
$keyFile = \preg_replace("@\\.+@", '.', $keyFile);
return $keyFile;
}
@@ -93,22 +109,23 @@ function serverToKeyname($server) {
*
* @return bool {@code true} if running as Phar, {@code false} otherwise
*/
function isPhar() {
if (!class_exists("Phar")) {
function isPhar(): bool {
if (!\class_exists('Phar')) {
return false;
}
return Phar::running(true) !== "";
return Phar::running() !== '';
}
/**
* 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 normalizePath(string $path): string {
return \rtrim(\str_replace("\\", '/', $path), '/');
}
/**
@@ -117,23 +134,23 @@ function normalizePath($path) {
* @return string|null Resolves to the config path or null.
*/
function getConfigPath() {
$paths = isPhar() ? [substr(dirname(Phar::running(true)), strlen("phar://")) . "/acme-client.yml"] : [];
$paths = isPhar() ? [\substr(\dirname(Phar::running()), \strlen('phar://')) . '/acme-client.yml'] : [];
if (strtoupper(substr(PHP_OS, 0, 3)) !== 'WIN') {
if ($home = getenv("HOME")) {
$paths[] = $home . "/.acme-client.yml";
if (0 !== \stripos(PHP_OS, 'WIN')) {
if ($home = \getenv('HOME')) {
$paths[] = $home . '/.acme-client.yml';
}
$paths[] = "/etc/acme-client.yml";
$paths[] = '/etc/acme-client.yml';
}
do {
$path = array_shift($paths);
$path = \array_shift($paths);
if (file_exists($path)) {
if (\file_exists($path)) {
return $path;
}
} while (count($paths));
} while (\count($paths));
return null;
}
@@ -142,24 +159,25 @@ function getConfigPath() {
* 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) {
function getArgumentDescription($argument): array {
$config = [];
if ($configPath = getConfigPath()) {
$configContent = file_get_contents($configPath);
$configContent = \file_get_contents($configPath);
try {
$config = Yaml::parse($configContent);
if (isset($config["server"]) && !is_string($config["server"])) {
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"])) {
if (isset($config['storage']) && !\is_string($config['storage'])) {
throw new ConfigException("'storage' set, but not a string.");
}
} catch (ParseException $e) {
@@ -168,41 +186,41 @@ function getArgumentDescription($argument) {
}
switch ($argument) {
case "server":
$argument = [
"prefix" => "s",
"longPrefix" => "server",
"description" => "ACME server to use for registration and issuance of certificates.",
"required" => true,
case 'server':
$desc = [
'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"];
if (isset($config['server'])) {
$desc['required'] = false;
$desc['defaultValue'] = $config['server'];
}
return $argument;
return $desc;
case "storage":
case 'storage':
$isPhar = isPhar();
$argument = [
"longPrefix" => "storage",
"description" => "Storage directory for account keys and certificates.",
"required" => $isPhar,
$desc = [
'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"];
$desc['defaultValue'] = \dirname(__DIR__) . '/data';
} elseif (isset($config['storage'])) {
$desc['required'] = false;
$desc['defaultValue'] = $config['storage'];
}
return $argument;
return $desc;
default:
throw new InvalidArgumentException("Unknown argument: " . $argument);
throw new InvalidArgumentException('Unknown argument: ' . $argument);
}
}
@@ -211,27 +229,27 @@ function getArgumentDescription($argument) {
*
* @return string binary callable, shortened based on PATH and CWD
*/
function getBinary() {
$binary = "bin/acme";
function getBinary(): string {
$binary = 'bin/acme';
if (isPhar()) {
$binary = substr(Phar::running(true), strlen("phar://"));
$binary = \substr(Phar::running(), \strlen('phar://'));
$path = getenv("PATH");
$locations = explode(PATH_SEPARATOR, $path);
$path = \getenv('PATH');
$locations = \explode(PATH_SEPARATOR, $path);
$binaryPath = dirname($binary);
$binaryPath = \dirname($binary);
foreach ($locations as $location) {
if ($location === $binaryPath) {
return substr($binary, strlen($binaryPath) + 1);
return \substr($binary, \strlen($binaryPath) + 1);
}
}
$cwd = getcwd();
$cwd = \getcwd();
if ($cwd && strpos($binary, $cwd) === 0) {
$binary = "." . substr($binary, strlen($cwd));
if ($cwd && \strpos($binary, $cwd) === 0) {
$binary = '.' . \substr($binary, \strlen($cwd));
}
}
@@ -244,18 +262,19 @@ function getBinary() {
* @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) {
function ellipsis($text, $max = 70, $append = '…'): string {
if (\strlen($text) <= $max) {
return $text;
}
$out = substr($text, 0, $max);
$out = \substr($text, 0, $max);
if (strpos($text, " ") === false) {
if (\strpos($text, ' ') === false) {
return $out . $append;
}
return preg_replace("/\\w+$/", "", $out) . $append;
}
return \preg_replace("/\\w+$/", '', $out) . $append;
}

View File

@@ -2,18 +2,20 @@
namespace Kelunik\AcmeClient;
class FunctionsTest extends \PHPUnit_Framework_TestCase {
use PHPUnit\Framework\TestCase;
class FunctionsTest extends TestCase {
public function testResolveServer() {
$this->assertSame("https://acme-v01.api.letsencrypt.org/directory", resolveServer("letsencrypt"));
$this->assertSame("https://acme-v01.api.letsencrypt.org/directory", resolveServer("letsencrypt:production"));
$this->assertSame("https://acme-staging.api.letsencrypt.org/directory", resolveServer("letsencrypt:staging"));
$this->assertSame("https://acme-v01.api.letsencrypt.org/directory", resolveServer("acme-v01.api.letsencrypt.org/directory"));
$this->assertSame("https://acme-v01.api.letsencrypt.org/directory", resolveServer("https://acme-v01.api.letsencrypt.org/directory"));
$this->assertSame('https://acme-v01.api.letsencrypt.org/directory', resolveServer('letsencrypt'));
$this->assertSame('https://acme-v01.api.letsencrypt.org/directory', resolveServer('letsencrypt:production'));
$this->assertSame('https://acme-staging.api.letsencrypt.org/directory', resolveServer('letsencrypt:staging'));
$this->assertSame('https://acme-v01.api.letsencrypt.org/directory', resolveServer('acme-v01.api.letsencrypt.org/directory'));
$this->assertSame('https://acme-v01.api.letsencrypt.org/directory', resolveServer('https://acme-v01.api.letsencrypt.org/directory'));
}
public function testSuggestCommand() {
$this->assertSame("acme", suggestCommand("acme!", ["acme"]));
$this->assertSame("", suggestCommand("issue", ["acme"]));
$this->assertSame('acme', suggestCommand('acme!', ['acme']));
$this->assertSame('', suggestCommand('issue', ['acme']));
}
public function testIsPhar() {
@@ -21,9 +23,9 @@ class FunctionsTest extends \PHPUnit_Framework_TestCase {
}
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\\"));
$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\\"));
}
}
}