1 Commits

Author SHA1 Message Date
Niklas Keller
c39ce9cf89 Update dependencies 2016-07-13 23:01:40 +02:00
33 changed files with 1995 additions and 5931 deletions

View File

@@ -1,43 +0,0 @@
# 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.
#
# rekey: Regenerate certificate key pairs even if a key pair already exists.
#
- bits: 4096
rekey: true
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

4
.gitignore vendored
View File

@@ -2,6 +2,4 @@
/data/
/info/
/vendor/
/config.test.yml
/.php_cs.cache
/.phpunit.result.cache
/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")
);

View File

@@ -1,10 +0,0 @@
<?php
$config = new Amp\CodeStyle\Config;
$config->getFinder()
->in(__DIR__ . '/src')
->in(__DIR__ . '/test');
$config->setCacheFile(__DIR__ . '/.php_cs.cache');
return $config;

28
.travis.yml Normal file
View File

@@ -0,0 +1,28 @@
language: php
php:
- 5.5
- 5.6
- 7.0
- nightly
cache:
directories:
- 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
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

View File

@@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2015-2021 Niklas Keller
Copyright (c) 2015 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

@@ -4,8 +4,8 @@
## Requirements
* PHP 7.4+ with OpenSSL
* Works on Unix-like systems and Windows
* PHP 5.5+ with OpenSSL
* Works on Unix and Windows
## Documentation

View File

@@ -1,14 +1,9 @@
#!/usr/bin/env php
<?php
use Amp\Http\Client\HttpClientBuilder;
use Amp\Loop;
use Auryn\Injector;
use Kelunik\AcmeClient\AcmeFactory;
use Kelunik\AcmeClient\Commands\Command;
use League\CLImate\CLImate;
use function Kelunik\AcmeClient\getBinary;
use function Kelunik\AcmeClient\suggestCommand;
$logo = <<<LOGO
____ __________ ___ ___
@@ -18,7 +13,7 @@ $logo = <<<LOGO
LOGO;
if (!file_exists(__DIR__ . '/../vendor/autoload.php')) {
if (!file_exists(__DIR__ . "/../vendor/autoload.php")) {
echo $logo;
echo <<<HELP
@@ -31,7 +26,7 @@ HELP;
exit(-1);
}
if (!function_exists('openssl_pkey_get_private')) {
if (!function_exists("openssl_pkey_get_private")) {
echo $logo;
echo <<<HELP
@@ -42,20 +37,20 @@ 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 = getBinary();
$binary = \Kelunik\AcmeClient\getBinary();
$help = implode(PHP_EOL, array_map(function ($command) use ($commands) {
$help = " <green>{$command}</green>\n";
@@ -80,25 +75,25 @@ 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 < 70400) {
$climate->yellow("You're using an older version of PHP which is no longer supported by this client. Have a look at https://php.net/supported-versions.php and upgrade at least to PHP 7.4!");
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)) {
if (count($argv) === 1 || in_array($argv[1], ["-h", "help", "--help"], true)) {
$climate->out($logo . $help);
exit(0);
}
if (!array_key_exists($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 = suggestCommand($argv[1], array_keys($commands));
$suggestion = \Kelunik\AcmeClient\suggestCommand($argv[1], array_keys($commands));
if ($suggestion) {
$climate->br()->out(" Did you mean '$suggestion'?");
@@ -109,7 +104,7 @@ if (!array_key_exists($argv[1], $commands)) {
exit(1);
}
/** @var string|Command $class */
/** @var \Kelunik\AcmeClient\Commands\Command $class */
$class = "Kelunik\\AcmeClient\\Commands\\" . ucfirst($argv[1]);
$definition = $class::getDefinition();
@@ -119,14 +114,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();
@@ -140,17 +135,15 @@ try {
$injector = new Injector;
$injector->share($climate);
$injector->share(new AcmeFactory);
$injector->share(HttpClientBuilder::buildDefault());
$command = $injector->make($class);
$exitCode = 1;
Loop::run(function () use ($command, $climate, &$exitCode) {
Amp\run(function () use ($command, $climate) {
$handler = function ($e) use ($climate) {
$error = (string) $e;
$lines = explode("\n", $error);
$lines = array_filter($lines, function ($line) {
return $line !== '' && $line[0] !== '#' && $line !== 'Stack trace:';
return strlen($line) && $line[0] !== "#" && $line !== "Stack trace:";
});
foreach ($lines as $line) {
@@ -161,16 +154,18 @@ Loop::run(function () use ($command, $climate, &$exitCode) {
};
try {
$exitCode = yield $command->execute($climate->arguments);
$exitCode = (yield $command->execute($climate->arguments));
if ($exitCode === null) {
$exitCode = 0;
exit(0);
}
exit($exitCode);
} catch (Throwable $e) {
$handler($e);
} catch (Exception $e) {
$handler($e);
}
Loop::stop();
Amp\stop();
});
exit($exitCode);

View File

@@ -11,23 +11,20 @@
"tls"
],
"require": {
"php": ">=7.2",
"php": "^5.5|^7",
"ext-openssl": "*",
"amphp/process": "^1.1",
"amphp/parallel": "^1.4",
"kelunik/acme": "^1",
"amphp/process": "^0.1.1",
"kelunik/acme": "^0.3",
"kelunik/certificate": "^1",
"league/climate": "^3.4",
"rdlowrey/auryn": "^1.4.4",
"webmozart/assert": "^1.3",
"symfony/yaml": "^5.3.2",
"amphp/log": "^1",
"ext-posix": "*"
"league/climate": "^3",
"rdlowrey/auryn": "^1",
"webmozart/assert": "^1",
"symfony/yaml": "^3.0"
},
"require-dev": {
"phpunit/phpunit": "^8 || ^9",
"amphp/php-cs-fixer-config": "dev-master",
"macfja/phar-builder": "^0.2.6"
"phpunit/phpunit": "^5",
"fabpot/php-cs-fixer": "^1.9",
"macfja/phar-builder": "dev-events-dev-files"
},
"license": "MIT",
"authors": [
@@ -36,6 +33,8 @@
"email": "me@kelunik.com"
}
],
"minimum-stability": "dev",
"prefer-stable": true,
"autoload": {
"psr-4": {
"Kelunik\\AcmeClient\\": "src"
@@ -44,31 +43,24 @@
"src/functions.php"
]
},
"config": {
"platform": {
"php": "7.4.0"
"repositories": [
{
"type": "vcs",
"url": "https://github.com/kelunik/pharbuilder"
}
},
],
"extra": {
"phar-builder": {
"compression": "GZip",
"name": "acme-client.phar",
"output-dir": "build",
"include": [
"info",
"src",
"vendor/kelunik/acme/res"
],
"include-dev": false,
"skip-shebang": false,
"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",
"rm -rf vendor/amphp/file/travis",
"rm -rf vendor/amphp/parallel/travis"
"php -r 'echo time();' > info/build.time"
],
"command.package.end": [
"rm -rf info",

5513
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,7 +24,7 @@ You can separate multiple domains (`-d`) with `,`, `:` or `;`. You can separate
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 `--server letsencrypt:staging`.
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

View File

@@ -2,11 +2,11 @@
## Installation using Phar
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).
This is the preferred installation method for usage on a production system.
### Requirements
* PHP 7.4+
* PHP 5.5+
### Instructions
@@ -50,7 +50,7 @@ If you plan to actively develop this client, you probably don't want the Phar bu
### Requirements
* PHP 7.4+
* PHP 5.5+
* [Composer](https://getcomposer.org/)
### Instructions

View File

@@ -1,5 +0,0 @@
# 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

@@ -53,10 +53,7 @@ certificates:
# user: User running the web server. Challenge files are world readable,
# but some servers might require to be owner of files they serve.
#
# rekey: Regenerate certificate key pairs even if a key pair already exists.
#
- bits: 4096
rekey: true
paths:
/var/www/example:
- example.org
@@ -69,8 +66,6 @@ certificates:
All configuration keys are optional and can be passed as arguments directly (except for `certificates` when using `acme-client auto`).
Before you can issue certificates, you must create an account using `acme-client setup --agree-terms`.
## Certificate Issuance
You can use `acme-client auto` to issue certificates and renew them if necessary. It uses the configuration file to
@@ -82,25 +77,8 @@ 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
#!/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
0 0 * * * acme-client auto; exit=$?; if [[ $exit = 4 ]] || [[ $exit = 5 ]]; then service nginx reload; fi
```
| Exit Code | Description |

View File

@@ -2,27 +2,15 @@
namespace Kelunik\AcmeClient;
use Amp\ByteStream\ResourceOutputStream;
use Amp\Log\ConsoleFormatter;
use Amp\Log\StreamHandler;
use Kelunik\Acme\AcmeClient;
use Kelunik\Acme\AcmeService;
use Kelunik\Acme\Crypto\PrivateKey;
use Monolog\Logger;
use Monolog\Processor\PsrLogMessageProcessor;
use function Amp\ByteStream\getStderr;
use Kelunik\Acme\KeyPair;
use Webmozart\Assert\Assert;
class AcmeFactory
{
public function build(string $directory, PrivateKey $keyPair): AcmeService
{
$handler = new StreamHandler(getStderr());
$handler->setFormatter(new ConsoleFormatter(null, null, true, true));
class AcmeFactory {
public function build($directory, KeyPair $keyPair) {
Assert::string($directory);
$logger = new Logger('acme');
$logger->pushProcessor(new PsrLogMessageProcessor);
$logger->pushHandler($handler);
return new AcmeService(new AcmeClient($directory, $keyPair, null, null, $logger), $logger);
return new AcmeService(new AcmeClient($directory, $keyPair));
}
}
}

View File

@@ -2,314 +2,231 @@
namespace Kelunik\AcmeClient\Commands;
use Amp\File;
use Amp\CoroutineResult;
use Amp\File\FilesystemException;
use Amp\Process\Process;
use Amp\Promise;
use Amp\Process;
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\ByteStream\buffer;
use function Amp\call;
class Auto implements Command
{
private const EXIT_CONFIG_ERROR = 1;
private const EXIT_SETUP_ERROR = 2;
private const EXIT_ISSUANCE_ERROR = 3;
private const EXIT_ISSUANCE_PARTIAL = 4;
private const EXIT_ISSUANCE_OK = 5;
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;
private const STATUS_NO_CHANGE = 0;
private const STATUS_RENEWED = 1;
public static function getDefinition(): array
{
$server = AcmeClient\getArgumentDescription('server');
$storage = 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 = AcmeClient\getConfigPath();
if ($configPath) {
$args['config']['required'] = false;
$args['config']['defaultValue'] = $configPath;
}
return $args;
}
const STATUS_NO_CHANGE = 0;
const STATUS_RENEWED = 1;
private $climate;
public function __construct(CLImate $climate)
{
public function __construct(CLImate $climate) {
$this->climate = $climate;
}
public function execute(Manager $args): Promise
{
return call(function () use ($args) {
$configPath = $args->get('config');
try {
/** @var array $config */
$config = Yaml::parse(
yield File\read($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;
}
if ($args->defined('server')) {
$config['server'] = $args->get('server');
} elseif (!isset($config['server']) && $args->exists('server')) {
$config['server'] = $args->get('server');
}
if ($args->defined('storage')) {
$config['storage'] = $args->get('storage');
} elseif (!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.");
return self::EXIT_CONFIG_ERROR;
}
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;
}
foreach ($config['certificates'] as $certificateConfig) {
if (isset($certificateConfig['rekey']) && !\is_bool($certificateConfig['rekey'])) {
$this->climate->error("Config file ({$configPath}) defines an invalid 'rekey' 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 buffer($process->getStdout()));
$this->climate->br()->error(yield buffer($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;
}
}
$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;
return $exitCode;
}
if ($status['renewed'] > 0) {
return self::EXIT_ISSUANCE_OK;
}
});
public function execute(Manager $args) {
return \Amp\resolve($this->doExecute($args));
}
/**
* @param array $certificate certificate configuration
* @param string $server server to use for issuance
* @param string $storage storage directory
* @param int|null $concurrency concurrent challenges
*
* @param Manager $args
* @return \Generator
* @throws AcmeException if something does wrong
* @throws \Throwable
*/
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);
$processArgs = [
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],
'check',
'--server',
$GLOBALS["argv"][0],
"setup",
"--server",
$server,
'--storage',
"--storage",
$storage,
'--name',
$commonName,
'--names',
\implode(',', $domains),
"--email",
$config["email"],
]));
$process = new Process($command);
$result = (yield $process->exec(Process::BUFFER_ALL));
if ($result->exit !== 0) {
$this->climate->error("Registration failed ({$result->exit})");
$this->climate->error($command);
$this->climate->br()->out($result->stdout);
$this->climate->br()->error($result->stderr);
yield new CoroutineResult(self::EXIT_SETUP_ERROR);
return;
}
$certificateChunks = array_chunk($config["certificates"], 10, true);
$errors = [];
$values = [];
foreach ($certificateChunks as $certificateChunk) {
$promises = [];
foreach ($certificateChunk as $certificate) {
$promises[] = \Amp\resolve($this->checkAndIssue($certificate, $server, $storage));
}
list($chunkErrors, $chunkValues) = (yield \Amp\any($promises));
$errors += $chunkErrors;
$values += $chunkValues;
}
$status = [
"no_change" => count(array_filter($values, function($value) { return $value === self::STATUS_NO_CHANGE; })),
"renewed" => count(array_filter($values, function($value) { return $value === self::STATUS_RENEWED; })),
"failure" => count($errors),
];
if ($certificate['rekey'] ?? false) {
$processArgs[] = '--rekey';
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.");
}
}
}
$process = new Process($processArgs);
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}");
}
$process->start();
$exit = yield $process->join();
$exitCode = $status["renewed"] > 0
? self::EXIT_ISSUANCE_PARTIAL
: self::EXIT_ISSUANCE_ERROR;
if ($exit === 0) {
yield new CoroutineResult($exitCode);
return;
}
if ($status["renewed"] > 0) {
yield new CoroutineResult(self::EXIT_ISSUANCE_OK);
return;
}
}
/**
* @param array $certificate certificate configuration
* @param string $server server to use for issuance
* @param string $storage storage directory
* @return \Generator
* @throws AcmeException if something does wrong
*/
private function checkAndIssue(array $certificate, $server, $storage) {
$domainPathMap = $this->toDomainPathMap($certificate["paths"]);
$domains = array_keys($domainPathMap);
$commonName = reset($domains);
$args = [
PHP_BINARY,
$GLOBALS["argv"][0],
"check",
"--server",
$server,
"--storage",
$storage,
"--name",
$commonName,
];
$command = implode(" ", array_map("escapeshellarg", $args));
$process = new Process($command);
$result = (yield $process->exec(Process::BUFFER_ALL));
if ($result->exit === 0) {
// No need for renewal
return self::STATUS_NO_CHANGE;
yield new CoroutineResult(self::STATUS_NO_CHANGE);
return;
}
if ($exit === 1) {
if ($result->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"];
}
if ($concurrency) {
$args[] = '--challenge-concurrency';
$args[] = $concurrency;
$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);
}
$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;
yield new CoroutineResult(self::STATUS_RENEWED);
return;
}
// TODO: Print STDOUT and STDERR to file
throw new AcmeException("Unexpected exit code ({$exit}) for '{$process->getCommand()}'.");
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)
{
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.
@@ -358,4 +275,26 @@ MESSAGE;
return $result;
}
}
public static function getDefinition() {
$args = [
"server" => \Kelunik\AcmeClient\getArgumentDescription("server"),
"storage" => \Kelunik\AcmeClient\getArgumentDescription("storage"),
"config" => [
"prefix" => "c",
"longPrefix" => "config",
"description" => "Configuration file to read.",
"required" => true,
],
];
$configPath = \Kelunik\AcmeClient\getConfigPath();
if ($configPath) {
$args["config"]["required"] = false;
$args["config"]["defaultValue"] = $configPath;
}
return $args;
}
}

View File

@@ -2,92 +2,74 @@
namespace Kelunik\AcmeClient\Commands;
use Amp\Promise;
use Kelunik\AcmeClient;
use Amp\CoroutineResult;
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;
use function Kelunik\AcmeClient\getArgumentDescription;
class Check implements Command
{
public static function getDefinition(): array
{
return [
'server' => getArgumentDescription('server'),
'storage' => 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',
],
'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,
],
];
}
class Check implements Command {
private $climate;
public function __construct(CLImate $climate)
{
public function __construct(CLImate $climate) {
$this->climate = $climate;
}
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;
});
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"));
$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() {
return [
"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",
],
];
}
}

View File

@@ -2,12 +2,10 @@
namespace Kelunik\AcmeClient\Commands;
use Amp\Promise;
use League\CLImate\Argument\Manager;
interface Command
{
public function execute(Manager $args): Promise;
interface Command {
public function execute(Manager $args);
public static function getDefinition(): array;
}
public static function getDefinition();
}

View File

@@ -2,19 +2,13 @@
namespace Kelunik\AcmeClient\Commands;
use Amp\Dns;
use Amp\Promise;
use Amp\CoroutineResult;
use Amp\Dns\Record;
use Exception;
use Kelunik\Acme\AcmeException;
use Kelunik\Acme\AcmeService;
use Kelunik\Acme\Crypto\Backend\OpensslBackend;
use Kelunik\Acme\Crypto\PrivateKey;
use Kelunik\Acme\Crypto\RsaKeyGenerator;
use Kelunik\Acme\Csr\OpensslCsrGenerator;
use Kelunik\Acme\Protocol\Authorization;
use Kelunik\Acme\Protocol\Challenge;
use Kelunik\Acme\Protocol\Order;
use Kelunik\Acme\Verifiers\Http01;
use Kelunik\AcmeClient;
use Kelunik\Acme\KeyPair;
use Kelunik\Acme\OpenSSLKeyGenerator;
use Kelunik\AcmeClient\AcmeFactory;
use Kelunik\AcmeClient\Stores\CertificateStore;
use Kelunik\AcmeClient\Stores\ChallengeStore;
@@ -22,268 +16,235 @@ use Kelunik\AcmeClient\Stores\KeyStore;
use Kelunik\AcmeClient\Stores\KeyStoreException;
use League\CLImate\Argument\Manager;
use League\CLImate\CLImate;
use function Amp\call;
use function Kelunik\Acme\generateKeyAuthorization;
use function Kelunik\AcmeClient\getArgumentDescription;
use function Kelunik\AcmeClient\normalizePath;
use function Kelunik\AcmeClient\resolveServer;
use function Kelunik\AcmeClient\serverToKeyname;
class Issue implements Command
{
public static function getDefinition(): array
{
return [
'server' => getArgumentDescription('server'),
'storage' => 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,
],
'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',
],
'challenge-concurrency' => [
'longPrefix' => 'challenge-concurrency',
'description' => 'Number of challenges to be solved concurrently.',
'defaultValue' => 10,
'castTo' => 'int',
],
'rekey' => [
'longPrefix' => 'rekey',
'description' => 'Regenerate the key pair even if a key pair already exists.',
'noValue' => true,
],
];
}
use stdClass;
use Throwable;
class Issue implements Command {
private $climate;
private $acmeFactory;
public function __construct(CLImate $climate, AcmeFactory $acmeFactory)
{
public function __construct(CLImate $climate, AcmeFactory $acmeFactory) {
$this->climate = $climate;
$this->acmeFactory = $acmeFactory;
}
public function execute(Manager $args): Promise
{
return call(function () use ($args) {
$user = null;
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!');
}
} else {
$user = $args->get('user') ?: 'www-data';
}
}
$domains = \array_map('trim', \explode(':', \str_replace([',', ';'], ':', $args->get('domains'))));
yield from $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(normalizePath($args->get('storage')));
$server = resolveServer($args->get('server'));
$keyFile = serverToKeyname($server);
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);
}
$this->climate->br();
$acme = $this->acmeFactory->build($server, $key);
$concurrency = \min(20, \max($args->get('challenge-concurrency'), 1));
/** @var Order $order */
$order = yield $acme->newOrder($domains);
/** @var \Throwable[] $errors */
[$errors] = yield AcmeClient\concurrentMap(
$concurrency,
$order->getAuthorizationUrls(),
function ($authorizationUrl, $i) use ($acme, $key, $domains, $docRoots, $user) {
/** @var Authorization $authorization */
$authorization = yield $acme->getAuthorization($authorizationUrl);
if ($authorization->getIdentifier()->getType() !== 'dns') {
throw new AcmeException('Invalid identifier: ' . $authorization->getIdentifier()->getType());
}
$name = $authorization->getIdentifier()->getValue();
if ($authorization->isWildcard()) {
$name .= '*.';
}
$index = \array_search($name, $domains, true);
if ($index === false) {
throw new AcmeException('Unknown identifier returned: ' . $name);
}
return yield from $this->solveChallenge($acme, $key, $authorization, $domains[$i], $docRoots[$i],
$user);
}
);
if ($errors) {
foreach ($errors as $error) {
$this->climate->error($error->getMessage());
}
throw new AcmeException('Issuance failed, not all challenges could be solved.');
}
yield $acme->pollForOrderReady($order->getUrl());
$keyPath = 'certs/' . $keyFile . '/' . \reset($domains) . '/key.pem';
$bits = $args->get('bits');
$regenerateKey = $args->get('rekey');
try {
$key = yield $keyStore->get($keyPath);
} catch (KeyStoreException $e) {
$regenerateKey = true;
}
if ($regenerateKey) {
$this->climate->whisper(' Generating new key pair ...');
$key = (new RsaKeyGenerator($bits))->generateKey();
}
$this->climate->br();
$this->climate->whisper(' Requesting certificate ...');
$csr = yield (new OpensslCsrGenerator)->generateCsr($key, $domains);
yield $acme->finalizeOrder($order->getFinalizationUrl(), $csr);
yield $acme->pollForOrderValid($order->getUrl());
/** @var Order $order */
$order = yield $acme->getOrder($order->getUrl());
$certificates = yield $acme->downloadCertificates($order->getCertificateUrl());
$path = normalizePath($args->get('storage')) . '/certs/' . $keyFile;
$certificateStore = new CertificateStore($path);
yield $keyStore->put($keyPath, $key);
yield $certificateStore->put($certificates);
$this->climate->info(' Successfully issued certificate.');
$this->climate->info(" See {$path}/" . \reset($domains));
$this->climate->br();
return 0;
});
public function execute(Manager $args) {
return \Amp\resolve($this->doExecute($args));
}
private function solveChallenge(
AcmeService $acme,
PrivateKey $key,
Authorization $authorization,
string $domain,
string $path,
string $user = null
): \Generator {
$httpChallenge = $this->findHttpChallenge($authorization);
if ($httpChallenge === 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 ($currentUsername !== $user) {
throw new AcmeException("Running this script with --user only works as root!");
}
} 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));
$errors += $chunkErrors;
}
if (!empty($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 {
$keyPair = (yield $keyStore->get($path));
} catch (KeyStoreException $e) {
$keyPair = (new OpenSSLKeyGenerator)->generate($bits);
$keyPair = (yield $keyStore->put($path, $keyPair));
}
$this->climate->br();
$this->climate->whisper(" Requesting certificate ...");
$location = (yield $acme->requestCertificate($keyPair, $domains));
$certificates = (yield $acme->pollForCertificate($location));
$path = \Kelunik\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();
yield new CoroutineResult(0);
}
private function solveChallenge(AcmeService $acme, KeyPair $keyPair, $domain, $path) {
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!");
}
$token = $httpChallenge->getToken();
if (!\preg_match('#^[a-zA-Z0-9-_]+$#', $token)) {
throw new AcmeException('Protocol violation: Invalid Token!');
$challenge = $challenges->challenges[reset($goodChallenges)];
$token = $challenge->token;
if (!preg_match("#^[a-zA-Z0-9-_]+$#", $token)) {
throw new AcmeException("Protocol violation: Invalid Token!");
}
$payload = generateKeyAuthorization($key, $token, new OpensslBackend);
$payload = $acme->generateHttp01Payload($keyPair, $token);
$this->climate->whisper(" Providing payload at http://{$domain}/.well-known/acme-challenge/{$token}");
$challengeStore = new ChallengeStore($path);
try {
yield $challengeStore->put($token, $payload, $user);
$challengeStore->put($token, $payload, isset($user) ? $user : null);
yield (new Http01)->verifyChallenge($domain, $token, $payload);
yield $acme->finalizeChallenge($httpChallenge->getUrl());
yield $acme->pollForAuthorization($authorization->getUrl());
yield $acme->verifyHttp01Challenge($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(array $domains): \Generator
{
$promises = AcmeClient\concurrentMap(10, $domains, function (string $domain): Promise {
return Dns\resolve($domain);
});
private function checkDnsRecords($domains) {
$errors = [];
[$errors] = yield Promise\any($promises);
$domainChunks = array_chunk($domains, 10, true);
if ($errors) {
$failedDomains = \implode(', ', \array_keys($errors));
$reasons = \implode("\n\n", \array_map(static function (\Throwable $exception) {
return \get_class($exception) . ': ' . $exception->getMessage();
}, $errors));
foreach ($domainChunks as $domainChunk) {
$promises = [];
throw new AcmeException("Couldn't resolve the following domains to an IPv4 nor IPv6 record: {$failedDomains}\n\n{$reasons}");
foreach ($domainChunk as $domain) {
$promises[$domain] = \Amp\Dns\resolve($domain, [
"types" => [Record::A],
"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 record: " . implode(", ", array_keys($errors)));
}
}
private function findHttpChallenge(Authorization $authorization): ?Challenge
{
$challenges = $authorization->getChallenges();
private function findSuitableCombination(stdClass $response) {
$challenges = isset($response->challenges) ? $response->challenges : [];
$combinations = isset($response->combinations) ? $response->combinations : [];
$goodChallenges = [];
foreach ($challenges as $challenge) {
if ($challenge->getType() === 'http-01') {
return $challenge;
foreach ($challenges as $i => $challenge) {
if ($challenge->type === "http-01") {
$goodChallenges[] = $i;
}
}
return null;
foreach ($goodChallenges as $i => $challenge) {
if (!in_array([$challenge], $combinations)) {
unset($goodChallenges[$i]);
}
}
return $goodChallenges;
}
public static function getDefinition() {
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,
],
"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.",
],
"bits" => [
"longPrefix" => "bits",
"description" => "Length of the private key in bit.",
"defaultValue" => 2048,
"castTo" => "int",
],
];
}
}

View File

@@ -2,81 +2,76 @@
namespace Kelunik\AcmeClient\Commands;
use Amp\File;
use Amp\CoroutineResult;
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
{
public static function getDefinition(): array
{
return [
'server' => AcmeClient\getArgumentDescription('server'),
'storage' => AcmeClient\getArgumentDescription('storage'),
'name' => [
'longPrefix' => 'name',
'description' => 'Common name of the certificate to be revoked.',
'required' => true,
],
];
}
class Revoke implements Command {
private $climate;
private $acmeFactory;
public function __construct(CLImate $climate, AcmeFactory $acmeFactory)
{
public function __construct(CLImate $climate, AcmeFactory $acmeFactory) {
$this->climate = $climate;
$this->acmeFactory = $acmeFactory;
}
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\read($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;
});
public function execute(Manager $args) {
return \Amp\resolve($this->doExecute($args));
}
}
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() {
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,
],
];
}
}

View File

@@ -2,128 +2,111 @@
namespace Kelunik\AcmeClient\Commands;
use Amp\Dns;
use Amp\Dns\NoRecordException;
use Amp\CoroutineResult;
use Amp\Dns\Record;
use Amp\Promise;
use Amp\Dns\ResolutionException;
use InvalidArgumentException;
use Kelunik\Acme\AcmeException;
use Kelunik\Acme\Crypto\RsaKeyGenerator;
use Kelunik\Acme\Protocol\Account;
use Kelunik\AcmeClient;
use Kelunik\Acme\OpenSSLKeyGenerator;
use Kelunik\Acme\Registration;
use Kelunik\AcmeClient\AcmeFactory;
use Kelunik\AcmeClient\Stores\KeyStore;
use Kelunik\AcmeClient\Stores\KeyStoreException;
use League\CLImate\Argument\Manager;
use League\CLImate\CLImate;
use Symfony\Component\Yaml\Yaml;
use function Amp\call;
use function Kelunik\AcmeClient\normalizePath;
use function Kelunik\AcmeClient\resolveServer;
use function Kelunik\AcmeClient\serverToKeyname;
class Setup implements Command
{
public static function getDefinition(): array
{
$args = [
'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,
],
'agree-terms' => [
'longPrefix' => 'agree-terms',
'description' => 'Agree to terms of service of the configured ACME server.',
'defaultValue' => false,
'noValue' => true,
],
];
$configPath = 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;
}
class Setup implements Command {
private $climate;
private $acmeFactory;
public function __construct(CLImate $climate, AcmeFactory $acmeFactory)
{
public function __construct(CLImate $climate, AcmeFactory $acmeFactory) {
$this->climate = $climate;
$this->acmeFactory = $acmeFactory;
}
public function execute(Manager $args): Promise
{
return call(function () use ($args) {
$email = $args->get('email');
yield from $this->checkEmail($email);
$server = resolveServer($args->get('server'));
$keyFile = serverToKeyname($server);
$path = "accounts/{$keyFile}.pem";
$bits = 4096;
$keyStore = new KeyStore(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 Account $account */
$account = yield $acme->register($email, $args->get('agree-terms'));
$contacts = \implode(', ', \array_map("strval", $account->getContacts()));
$this->climate->info(' Registration successful. Contacts: ' . $contacts);
$this->climate->br();
return 0;
});
public function execute(Manager $args) {
return \Amp\resolve($this->doExecute($args));
}
private function checkEmail(string $email): \Generator
{
$host = \substr($email, \strrpos($email, '@') + 1);
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);
if (!$host) {
throw new AcmeException("Invalid contact email: '{$email}'");
}
try {
yield Dns\query($host, Record::MX);
} catch (NoRecordException $e) {
yield \Amp\Dns\query($host, Record::MX);
} catch (ResolutionException $e) {
throw new AcmeException("No MX record defined for '{$host}'");
} catch (Dns\DnsException $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() {
$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,
],
];
$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;
}
}

View File

@@ -2,86 +2,79 @@
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;
use function Kelunik\AcmeClient\normalizePath;
use function Kelunik\AcmeClient\resolveServer;
use function Kelunik\AcmeClient\serverToKeyname;
class Status
{
public static function getDefinition(): array
{
return [
'server' => getArgumentDescription('server'),
'storage' => getArgumentDescription('storage'),
'ttl' => [
'longPrefix' => 'ttl',
'description' => 'Minimum valid time in days, shows ⭮ if renewal is required.',
'defaultValue' => 30,
'castTo' => 'int',
],
];
}
class Status {
private $climate;
public function __construct(CLImate $climate)
{
public function __construct(CLImate $climate) {
$this->climate = $climate;
}
public function execute(Manager $args): Promise
{
return call(function () use ($args) {
$server = resolveServer($args->get('server'));
$keyName = serverToKeyname($server);
public function execute(Manager $args) {
return \Amp\resolve($this->doExecute($args));
}
$storage = normalizePath($args->get('storage'));
/**
* @param Manager $args
* @return \Generator
*/
private function doExecute(Manager $args) {
$server = \Kelunik\AcmeClient\resolveServer($args->get("server"));
$keyName = \Kelunik\AcmeClient\serverToKeyname($server);
try {
$keyStore = new KeyStore($storage);
yield $keyStore->get("accounts/{$keyName}.pem");
$storage = \Kelunik\AcmeClient\normalizePath($args->get("storage"));
$setup = true;
} catch (KeyStoreException $e) {
$setup = false;
}
try {
$keyStore = new KeyStore($storage);
yield $keyStore->get("accounts/{$keyName}.pem");
$this->climate->br();
$this->climate->out(' [' . ($setup ? '<green> ✓ </green>' : '<red> ✗ </red>') . '] ' . ($setup ? 'Registered on ' : 'Not yet registered on ') . $server);
$this->climate->br();
$setup = true;
} catch (KeyStoreException $e) {
$setup = false;
}
if (yield File\exists($storage . "/certs/{$keyName}")) {
$certificateStore = new CertificateStore($storage . "/certs/{$keyName}");
$this->climate->br();
$this->climate->out(" [" . ($setup ? "<green> ✓ </green>" : "<red> ✗ </red>") . "] " . ($setup ? "Registered on " : "Not yet registered on ") . $server);
$this->climate->br();
/** @var array $domains */
$domains = yield File\listFiles($storage . "/certs/{$keyName}");
if (yield \Amp\File\exists($storage . "/certs/{$keyName}")) {
$certificateStore = new CertificateStore($storage . "/certs/{$keyName}");
foreach ($domains as $domain) {
$pem = yield $certificateStore->get($domain);
$cert = new Certificate($pem);
$domains = (yield \Amp\File\scandir($storage . "/certs/{$keyName}"));
$validTo = $cert->getValidTo();
$symbol = \time() > $validTo ? '<red> ✗ </red>' : '<green> ✓ </green>';
foreach ($domains as $domain) {
$pem = (yield $certificateStore->get($domain));
$cert = new Certificate($pem);
if (\time() < $validTo && \time() + $args->get('ttl') * 24 * 60 * 60 > $validTo) {
$symbol = '<yellow> ⭮ </yellow>';
}
$symbol = time() > $cert->getValidTo() ? "<red> ✗ </red>" : "<green> ✓ </green>";
$this->climate->out(' [' . $symbol . '] ' . \implode(', ', $cert->getNames()));
if (time() < $cert->getValidTo() && time() + $args->get("ttl") * 24 * 60 * 60 > $cert->getValidTo()) {
$symbol = "<yellow> ⭮ </yellow>";
}
$this->climate->br();
$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",
],
];
}
}

View File

@@ -2,86 +2,75 @@
namespace Kelunik\AcmeClient\Commands;
use Amp\Promise;
use Amp\Success;
use League\CLImate\Argument\Manager;
use League\CLImate\CLImate;
use function Kelunik\AcmeClient\ellipsis;
class Version implements Command
{
public static function getDefinition(): array
{
return [
'deps' => [
'longPrefix' => 'deps',
'description' => 'Show also the bundled dependency versions.',
'noValue' => true,
],
];
}
use RuntimeException;
class Version implements Command {
private $climate;
public function __construct(CLImate $climate)
{
public function __construct(CLImate $climate) {
$this->climate = $climate;
}
public function execute(Manager $args): Promise
{
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));
$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 \Exception('No composer.json found.')));
$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));
$this->climate->out(($args->defined("deps") ? "" : "") . " " . $this->getDescription($package));
if ($args->defined('deps')) {
$lockFile = \json_decode($this->readFileOr('composer.lock', new \Exception('No composer.lock found.')));
if ($args->defined("deps")) {
$lockFile = json_decode($this->readFileOr("composer.lock", new RuntimeException("No composer.lock found.")));
$packages = $lockFile->packages;
for ($i = 0, $count = \count($packages); $i < $count; $i++) {
$link = $i === $count - 1 ? '└──' : '├──';
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 - 1 ? ' ' : '│ ';
$link = $i === count($packages) - 1 ? " " : "";
$this->climate->out("{$link} " . $this->getDescription($packages[$i]));
}
}
return new Success;
}
private function getDescription($package): string
{
return ellipsis($package->description ?? '');
private function getDescription($package) {
return \Kelunik\AcmeClient\ellipsis(isset($package->description) ? $package->description : "");
}
private function getVersion(): string
{
if (\file_exists(__DIR__ . '/../../.git')) {
$version = \shell_exec("git describe --tags");
private function getVersion() {
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(string $file, $default = '')
{
if (\file_exists(__DIR__ . '/../../' . $file)) {
return \file_get_contents(__DIR__ . '/../../' . $file);
}
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;
}
if ($default instanceof \Throwable) {
throw $default;
return $default;
}
return $default;
}
}
public static function getDefinition() {
return [
"deps" => [
"longPrefix" => "deps",
"description" => "Show also the bundled dependency versions.",
"noValue" => true,
],
];
}
}

View File

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

View File

@@ -2,82 +2,92 @@
namespace Kelunik\AcmeClient\Stores;
use Amp\File;
use Amp\CoroutineResult;
use Amp\File\FilesystemException;
use Amp\Promise;
use InvalidArgumentException;
use Kelunik\Certificate\Certificate;
use function Amp\call;
use function Amp\Dns\isValidName;
use Webmozart\Assert\Assert;
class CertificateStore
{
class CertificateStore {
private $root;
public function __construct(string $root)
{
$this->root = \rtrim(\str_replace("\\", '/', $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 get(string $name): Promise
{
return call(function () use ($name) {
try {
return yield File\read($this->root . '/' . $name . '/cert.pem');
} catch (FilesystemException $e) {
throw new CertificateStoreException('Failed to load certificate.', 0, $e);
}
});
public function get($name) {
return \Amp\resolve($this->doGet($name));
}
public function put(array $certificates): Promise
{
return call(function () use ($certificates) {
if (empty($certificates)) {
throw new \Error('Empty array not allowed');
}
private function doGet($name) {
Assert::string($name, "Name must be a string. Got: %s");
$cert = new Certificate($certificates[0]);
$commonName = $cert->getSubject()->getCommonName();
if (!$commonName) {
throw new CertificateStoreException("Certificate doesn't have a common name.");
}
if (!isValidName($commonName)) {
throw new CertificateStoreException("Invalid common name: '{$commonName}'");
}
try {
$chain = \array_slice($certificates, 1);
$path = $this->root . '/' . $commonName;
yield File\createDirectoryRecursively($path, 0755);
yield File\write($path . '/cert.pem', $certificates[0]);
yield File\changePermissions($path . '/cert.pem', 0644);
yield File\write($path . '/fullchain.pem', \implode("\n", $certificates));
yield File\changePermissions($path . '/fullchain.pem', 0644);
yield File\write($path . '/chain.pem', \implode("\n", $chain));
yield File\changePermissions($path . '/chain.pem', 0644);
} catch (FilesystemException $e) {
throw new CertificateStoreException("Couldn't save certificates for '{$commonName}'", 0, $e);
}
});
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 delete(string $name): Promise
{
return call(function () use ($name) {
/** @var array $files */
$files = yield File\listFiles($this->root . '/' . $name);
public function put(array $certificates) {
return \Amp\resolve($this->doPut($certificates));
}
foreach ($files as $file) {
yield File\deleteFile($this->root . '/' . $name . '/' . $file);
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}'");
}
yield File\deleteDirectory($this->root . '/' . $name);
});
yield \Amp\File\put($path . "/cert.pem", $certificates[0]);
yield \Amp\File\chmod($path . "/cert.pem", 0644);
yield \Amp\File\put($path . "/fullchain.pem", implode("\n", $certificates));
yield \Amp\File\chmod($path . "/fullchain.pem", 0644);
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);
}
}
public function delete($name) {
return \Amp\resolve($this->doDelete($name));
}
private function doDelete($name) {
Assert::string($name, "Name must be a string. Got: %s");
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);
}
}

View File

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

View File

@@ -2,58 +2,72 @@
namespace Kelunik\AcmeClient\Stores;
use Amp\File;
use Amp\Promise;
use function Amp\call;
use InvalidArgumentException;
use Webmozart\Assert\Assert;
class ChallengeStore
{
class ChallengeStore {
private $docroot;
public function __construct(string $docroot)
{
$this->docroot = \rtrim(\str_replace("\\", '/', $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 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;
public function put($token, $payload, $user = null) {
return \Amp\resolve($this->doPut($token, $payload, $user));
}
if (!yield File\exists($this->docroot)) {
throw new ChallengeStoreException("Document root doesn't exist: '{$this->docroot}'");
}
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");
yield File\createDirectoryRecursively($path, 0755);
$path = $this->docroot . "/.well-known/acme-challenge";
$realpath = realpath($path);
if ($user && !$userInfo = \posix_getpwnam($user)) {
if (!realpath($this->docroot)) {
throw new ChallengeStoreException("Document root doesn't exist: '{$this->docroot}'");
}
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)) {
throw new ChallengeStoreException("Unknown user: '{$user}'");
}
}
if ($userInfo !== null) {
yield File\changeOwner($this->docroot . '/.well-known', $userInfo['uid']);
yield File\changeOwner($this->docroot . '/.well-known/acme-challenge', $userInfo['uid']);
}
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);
}
yield File\write("{$path}/{$token}", $payload);
yield \Amp\File\put("{$path}/{$token}", $payload);
if ($userInfo !== null) {
yield File\changeOwner("{$path}/{$token}", $userInfo['uid']);
}
if (isset($userInfo)) {
yield \Amp\File\chown("{$path}/{$token}", $userInfo["uid"], -1);
}
yield File\changePermissions("{$path}/{$token}", 0644);
});
yield \Amp\File\chmod("{$path}/{$token}", 0644);
}
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\deleteFile($path);
}
});
public function delete($token) {
return \Amp\resolve($this->doDelete($token));
}
}
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,6 +2,8 @@
namespace Kelunik\AcmeClient\Stores;
class ChallengeStoreException extends \Exception
{
}
use RuntimeException;
class ChallengeStoreException extends RuntimeException {
}

View File

@@ -2,59 +2,85 @@
namespace Kelunik\AcmeClient\Stores;
use Amp\File;
use Amp\CoroutineResult;
use Amp\File\FilesystemException;
use Amp\Promise;
use Kelunik\Acme\Crypto\PrivateKey;
use function Amp\call;
use InvalidArgumentException;
use Kelunik\Acme\KeyPair;
class KeyStore
{
class KeyStore {
private $root;
public function __construct(string $root = '')
{
$this->root = \rtrim(\str_replace("\\", '/', $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 get(string $path): Promise
{
return call(function () use ($path) {
$file = $this->root . '/' . $path;
public function get($path) {
if (!is_string($path)) {
throw new InvalidArgumentException(sprintf("\$root must be of type string, %s given.", gettype($path)));
}
try {
$privateKey = yield File\read($file);
return \Amp\resolve($this->doGet($path));
}
// Check key here to be valid, PrivateKey doesn't do that, we fail early here
$res = \openssl_pkey_get_private($privateKey);
private function doGet($path) {
if (!is_string($path)) {
throw new InvalidArgumentException(sprintf("\$root must be of type string, %s given.", gettype($path)));
}
if ($res === false) {
throw new KeyStoreException("Invalid private key: '{$file}'");
$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.");
}
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);
yield File\createDirectoryRecursively($dir, 0755);
yield File\write($file, $key->toPem());
yield File\changePermissions($file, 0600);
} catch (FilesystemException $e) {
throw new KeyStoreException('Could not save key: ' . $e->getMessage(), 0, $e);
}
return $key;
});
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);
}
}
}

View File

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

View File

@@ -2,31 +2,12 @@
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 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));
}
use Webmozart\Assert\Assert;
/**
* Suggests a command based on similarity in a list of available commands.
@@ -34,19 +15,20 @@ function concurrentMap(int $concurrency, array $values, callable $functor): arra
* @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(string $badCommand, array $commands, int $suggestThreshold = 70): string
{
$badCommand = \strtolower($badCommand);
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");
$bestMatch = '';
$badCommand = strtolower($badCommand);
$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;
@@ -54,7 +36,7 @@ function suggestCommand(string $badCommand, array $commands, int $suggestThresho
}
}
return $bestMatchPercentage >= $suggestThreshold ? $bestMatch : '';
return $bestMatchPercentage >= $suggestThreshold ? $bestMatch : "";
}
/**
@@ -62,48 +44,46 @@ function suggestCommand(string $badCommand, array $commands, int $suggestThresho
* protocol is passed, it will default to HTTPS.
*
* @param string $uri URI to resolve
*
* @return string resolved URI
*/
function resolveServer(string $uri): string
{
function resolveServer($uri) {
Assert::string($uri, "URI must be a string. Got: %s");
$shortcuts = [
'letsencrypt' => 'https://acme-v02.api.letsencrypt.org/directory',
'letsencrypt:production' => 'https://acme-v02.api.letsencrypt.org/directory',
'letsencrypt:staging' => 'https://acme-staging-v02.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(string $server): string
{
$server = \substr($server, \strpos($server, '://') + 3);
function serverToKeyname($server) {
$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;
}
@@ -113,25 +93,22 @@ function serverToKeyname(string $server): string
*
* @return bool {@code true} if running as Phar, {@code false} otherwise
*/
function isPhar(): bool
{
if (!\class_exists('Phar')) {
function isPhar() {
if (!class_exists("Phar")) {
return false;
}
return Phar::running() !== '';
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(string $path): string
{
return \rtrim(\str_replace("\\", '/', $path), '/');
function normalizePath($path) {
return rtrim(str_replace("\\", "/", $path), "/");
}
/**
@@ -139,25 +116,24 @@ function normalizePath(string $path): string
*
* @return string|null Resolves to the config path or null.
*/
function getConfigPath(): ?string
{
$paths = isPhar() ? [\substr(\dirname(Phar::running()), \strlen('phar://')) . '/acme-client.yml'] : [];
function getConfigPath() {
$paths = isPhar() ? [substr(dirname(Phar::running(true)), strlen("phar://")) . "/acme-client.yml"] : [];
if (0 !== \stripos(PHP_OS, 'WIN')) {
if ($home = \getenv('HOME')) {
$paths[] = $home . '/.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';
$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;
}
@@ -166,26 +142,24 @@ function getConfigPath(): ?string
* 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(string $argument): array
{
function getArgumentDescription($argument) {
$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) {
@@ -194,41 +168,41 @@ function getArgumentDescription(string $argument): array
}
switch ($argument) {
case 'server':
$desc = [
'prefix' => 's',
'longPrefix' => 'server',
'description' => 'ACME server to use for registration and issuance of certificates.',
'required' => true,
case "server":
$argument = [
"prefix" => "s",
"longPrefix" => "server",
"description" => "ACME server to use for registration and issuance of certificates.",
"required" => true,
];
if (isset($config['server'])) {
$desc['required'] = false;
$desc['defaultValue'] = $config['server'];
if (isset($config["server"])) {
$argument["required"] = false;
$argument["defaultValue"] = $config["server"];
}
return $desc;
return $argument;
case 'storage':
case "storage":
$isPhar = isPhar();
$desc = [
'longPrefix' => 'storage',
'description' => 'Storage directory for account keys and certificates.',
'required' => $isPhar,
$argument = [
"longPrefix" => "storage",
"description" => "Storage directory for account keys and certificates.",
"required" => $isPhar,
];
if (!$isPhar) {
$desc['defaultValue'] = \dirname(__DIR__) . '/data';
} elseif (isset($config['storage'])) {
$desc['required'] = false;
$desc['defaultValue'] = $config['storage'];
$argument["defaultValue"] = dirname(__DIR__) . "/data";
} else if (isset($config["storage"])) {
$argument["required"] = false;
$argument["defaultValue"] = $config["storage"];
}
return $desc;
return $argument;
default:
throw new InvalidArgumentException('Unknown argument: ' . $argument);
throw new InvalidArgumentException("Unknown argument: " . $argument);
}
}
@@ -237,28 +211,27 @@ function getArgumentDescription(string $argument): array
*
* @return string binary callable, shortened based on PATH and CWD
*/
function getBinary(): string
{
$binary = 'bin/acme';
function getBinary() {
$binary = "bin/acme";
if (isPhar()) {
$binary = \substr(Phar::running(), \strlen('phar://'));
$binary = substr(Phar::running(true), 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));
}
}
@@ -271,20 +244,18 @@ function getBinary(): string
* @param string $text text to shorten
* @param int $max maximum length
* @param string $append appendix when too long
*
* @return string shortened string
*/
function ellipsis(string $text, int $max = 70, string $append = '…'): string
{
if (\strlen($text) <= $max) {
function ellipsis($text, $max = 70, $append = "") {
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,44 +2,28 @@
namespace Kelunik\AcmeClient;
use PHPUnit\Framework\TestCase;
class FunctionsTest extends TestCase
{
public function testResolveServer(): void
{
$this->assertSame('https://acme-v02.api.letsencrypt.org/directory', resolveServer('letsencrypt'));
$this->assertSame('https://acme-v02.api.letsencrypt.org/directory', resolveServer('letsencrypt:production'));
$this->assertSame(
'https://acme-staging-v02.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')
);
class FunctionsTest extends \PHPUnit_Framework_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"));
}
public function testSuggestCommand(): void
{
$this->assertSame('acme', suggestCommand('acme!', ['acme']));
$this->assertSame('', suggestCommand('issue', ['acme']));
public function testSuggestCommand() {
$this->assertSame("acme", suggestCommand("acme!", ["acme"]));
$this->assertSame("", suggestCommand("issue", ["acme"]));
}
public function testIsPhar(): void
{
public function testIsPhar() {
$this->assertFalse(isPhar());
}
public function testNormalizePath(): void
{
$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\\"));
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\\"));
}
}
}