Upgrade to Amp v2

This commit is contained in:
Niklas Keller
2017-12-29 19:08:26 +01:00
parent c71b07ef03
commit 9a9a243807
22 changed files with 2362 additions and 1490 deletions

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,16 +136,16 @@ try {
$injector = new Injector;
$injector->share($climate);
$injector->share(new AcmeFactory);
$injector->share(new Amp\Artax\Client(new Amp\Artax\Cookie\NullCookieJar));
$injector->share(new Amp\Artax\DefaultClient);
$command = $injector->make($class);
Amp\run(function () use ($command, $climate) {
Loop::run(function () use ($command, $climate) {
$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) {
@@ -155,7 +156,7 @@ Amp\run(function () use ($command, $climate) {
};
try {
$exitCode = (yield $command->execute($climate->arguments));
$exitCode = yield $command->execute($climate->arguments);
if ($exitCode === null) {
exit(0);
@@ -164,9 +165,7 @@ Amp\run(function () use ($command, $climate) {
exit($exitCode);
} catch (Throwable $e) {
$handler($e);
} catch (Exception $e) {
$handler($e);
}
Amp\stop();
Loop::stop();
});

View File

@@ -11,20 +11,20 @@
"tls"
],
"require": {
"php": "^5.5|^7",
"php": ">=7",
"ext-openssl": "*",
"amphp/process": "^0.1.1",
"kelunik/acme": "^0.3",
"amphp/process": "^0.2",
"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": "^4|^5",
"friendsofphp/php-cs-fixer": "^1.9",
"macfja/phar-builder": "^0.2.5"
"phpunit/phpunit": "^6",
"friendsofphp/php-cs-fixer": "^2.9",
"macfja/phar-builder": "^0.2.6"
},
"license": "MIT",
"authors": [
@@ -33,8 +33,6 @@
"email": "me@kelunik.com"
}
],
"minimum-stability": "dev",
"prefer-stable": true,
"autoload": {
"psr-4": {
"Kelunik\\AcmeClient\\": "src"
@@ -43,12 +41,6 @@
"src/functions.php"
]
},
"repositories": [
{
"type": "vcs",
"url": "https://github.com/kelunik/pharbuilder"
}
],
"extra": {
"phar-builder": {
"compression": "GZip",

2090
composer.lock generated

File diff suppressed because it is too large Load Diff

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

@@ -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,230 +32,221 @@ 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) {
$configPath = $args->get("config");
try {
$config = Yaml::parse(
yield \Amp\File\get($configPath)
);
} catch (FilesystemException $e) {
$this->climate->error("Config file ({$configPath}) not found.");
yield new CoroutineResult(self::EXIT_CONFIG_ERROR);
return;
} catch (ParseException $e) {
$this->climate->error("Config file ({$configPath}) had an invalid format and couldn't be parsed.");
yield new CoroutineResult(self::EXIT_CONFIG_ERROR);
return;
}
if ($args->defined("server")) {
$config["server"] = $args->get("server");
} else if (!isset($config["server"]) && $args->exists("server")) {
$config["server"] = $args->get("server");
}
if ($args->defined("storage")) {
$config["storage"] = $args->get("storage");
} else if (!isset($config["storage"]) && $args->exists("storage")) {
$config["storage"] = $args->get("storage");
}
if (!isset($config["server"])) {
$this->climate->error("Config file ({$configPath}) didn't have a 'server' set nor was it passed as command line argument.");
yield new CoroutineResult(self::EXIT_CONFIG_ERROR);
return;
}
if (!isset($config["storage"])) {
$this->climate->error("Config file ({$configPath}) didn't have a 'storage' set nor was it passed as command line argument.");
yield new CoroutineResult(self::EXIT_CONFIG_ERROR);
return;
}
if (!isset($config["email"])) {
$this->climate->error("Config file ({$configPath}) didn't have a 'email' set.");
yield new CoroutineResult(self::EXIT_CONFIG_ERROR);
return;
}
if (!isset($config["certificates"]) || !is_array($config["certificates"])) {
$this->climate->error("Config file ({$configPath}) didn't have a 'certificates' section that's an array.");
yield new CoroutineResult(self::EXIT_CONFIG_ERROR);
return;
}
if (isset($config["challenge-concurrency"]) && !is_numeric($config["challenge-concurrency"])) {
$this->climate->error("Config file ({$configPath}) defines an invalid 'challenge-concurrency' value.");
yield new CoroutineResult(self::EXIT_CONFIG_ERROR);
return;
}
$concurrency = isset($config["challenge-concurrency"]) ? (int) $config["challenge-concurrency"] : null;
$command = implode(" ", array_map("escapeshellarg", [
PHP_BINARY,
$GLOBALS["argv"][0],
"setup",
"--server",
$config["server"],
"--storage",
$config["storage"],
"--email",
$config["email"],
]));
$process = new Process($command);
$result = (yield $process->exec(Process::BUFFER_ALL));
if ($result->exit !== 0) {
$this->climate->error("Registration failed ({$result->exit})");
$this->climate->error($command);
$this->climate->br()->out($result->stdout);
$this->climate->br()->error($result->stderr);
yield new CoroutineResult(self::EXIT_SETUP_ERROR);
return;
}
$errors = [];
$values = [];
foreach ($config["certificates"] as $i => $certificate) {
try {
$result = (yield \Amp\resolve($this->checkAndIssue($certificate, $config["server"], $config["storage"], $concurrency)));
$values[$i] = $result;
} catch (\Exception $e) {
$errors[$i] = $e;
/** @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;
}
}
$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 ($args->defined('server')) {
$config['server'] = $args->get('server');
} else if (!isset($config['server']) && $args->exists('server')) {
$config['server'] = $args->get('server');
}
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 ($args->defined('storage')) {
$config['storage'] = $args->get('storage');
} else if (!isset($config['storage']) && $args->exists('storage')) {
$config['storage'] = $args->get('storage');
}
if (!isset($config['server'])) {
$this->climate->error("Config file ({$configPath}) didn't have a 'server' set nor was it passed as command line argument.");
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;
}
$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, $concurrency = null) {
$domainPathMap = $this->toDomainPathMap($certificate["paths"]);
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),
];
'--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",
'--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[] = '--challenge-concurrency';
$args[] = $concurrency;
}
$command = implode(" ", array_map("escapeshellarg", $args));
$process = new Process($args);
$process->start();
$exit = yield $process->join();
$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 ($exit !== 0) {
// TODO: Print STDOUT and STDERR to file
throw new AcmeException("Unexpected exit code ({$exit}) for '{$process->getCommand()}'.");
}
yield new CoroutineResult(self::STATUS_RENEWED);
return;
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) {
@@ -308,29 +303,29 @@ MESSAGE;
return $result;
}
public static function getDefinition() {
$server = \Kelunik\AcmeClient\getArgumentDescription("server");
$storage = \Kelunik\AcmeClient\getArgumentDescription("storage");
public static function getDefinition(): array {
$server = AcmeClient\getArgumentDescription('server');
$storage = AcmeClient\getArgumentDescription('storage');
$server["required"] = false;
$storage["required"] = false;
$server['required'] = false;
$storage['required'] = false;
$args = [
"server" => $server,
"storage" => $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,76 +18,67 @@ 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);
/**
* @param Manager $args
* @return \Generator
*/
private function doExecute(Manager $args) {
$server = \Kelunik\AcmeClient\resolveServer($args->get("server"));
$server = \Kelunik\AcmeClient\serverToKeyname($server);
$path = AcmeClient\normalizePath($args->get('storage')) . '/certs/' . $server;
$certificateStore = new CertificateStore($path);
$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();
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 ($args->defined("names")) {
$names = array_map("trim", explode(",", $args->get("names")));
$missingNames = array_diff($names, $cert->getNames());
if ($missingNames) {
$this->climate->comment(" The following names are not covered: " . implode(", ", $missingNames))->br();
yield new CoroutineResult(1);
return;
return 1;
}
}
if ($cert->getValidTo() > time() + $args->get("ttl") * 24 * 60 * 60) {
yield new CoroutineResult(0);
return;
}
$cert = new Certificate($pem);
$this->climate->comment(" Certificate is going to expire within the specified " . $args->get("ttl") . " days.")->br();
$this->climate->br();
$this->climate->whisper(' Certificate is valid until ' . date('d.m.Y', $cert->getValidTo()))->br();
yield new CoroutineResult(1);
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 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,
'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,15 @@
namespace Kelunik\AcmeClient\Commands;
use Amp\CoroutineResult;
use Amp\Dns\Record;
use Exception;
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 +18,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,113 +30,104 @@ 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 = [];
$concurrency = $args->get("challenge-concurrency");
$domainChunks = array_chunk($domains, \min(20, \max($concurrency, 1)), 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 = (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)) {
@@ -144,81 +137,57 @@ class Issue implements Command {
$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 {
yield $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, \array_combine($domains, $domains), 'Amp\Dns\resolve');
list($errors) = yield Promise\any($promises);
$domainChunks = array_chunk($domains, 10, true);
foreach ($domainChunks as $domainChunk) {
$promises = [];
foreach ($domainChunk as $domain) {
$promises[$domain] = \Amp\Dns\resolve($domain, [
"types" => [Record::A, Record::AAAA],
"hosts" => false,
]);
}
list($chunkErrors) = (yield \Amp\any($promises));
$errors += $chunkErrors;
}
if (!empty($errors)) {
$failedDomains = implode(", ", array_keys($errors));
if ($errors) {
$failedDomains = implode(', ', array_keys($errors));
$reasons = implode("\n\n", array_map(function ($exception) {
/** @var \Exception|\Throwable $exception */
return get_class($exception) . ": " . $exception->getMessage();
/** @var \Throwable $exception */
return \get_class($exception) . ': ' . $exception->getMessage();
}, $errors));
throw new AcmeException("Couldn't resolve the following domains to an IPv4 nor IPv6 record: {$failedDomains}\n\n{$reasons}");
}
}
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]);
}
}
@@ -226,38 +195,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",
'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,57 +23,55 @@ 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,20 +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;
@@ -26,61 +28,55 @@ 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);
yield Dns\query($host, Record::MX);
} catch (NoRecordException $e) {
throw new AcmeException("No MX record defined for '{$host}'");
} catch (ResolutionException $e) {
@@ -88,25 +84,25 @@ class Setup implements Command {
}
}
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));
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'];
}
}

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,64 +20,59 @@ 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,63 +14,65 @@ 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());
$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);
}
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,79 @@
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, 0644, true) && !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,56 @@
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, 0644, true) && !yield File\isdir($path)) {
throw new ChallengeStoreException("Couldn't create key directory: '{$path}'");
}
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)) {
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 \Amp\File\put("{$path}/{$token}", $payload);
if (isset($userInfo)) {
yield \Amp\File\chown("{$path}/{$token}", $userInfo["uid"], -1);
}
if ($userInfo !== null) {
yield \Amp\File\chown("{$path}/{$token}", $userInfo['uid'], -1);
}
yield \Amp\File\chmod("{$path}/{$token}", 0644);
yield \Amp\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}";
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);
}
if (yield File\exists($path)) {
yield \Amp\File\unlink($path);
}
});
}
}

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,53 @@
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;
$privateKey = yield File\get($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)));
}
$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}'");
}
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);
}
return new PrivateKey($privateKey);
});
}
yield new CoroutineResult($keyPair);
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, 0644, true) && !yield File\isdir($dir)) {
throw new FilesystemException("Couldn't create key directory: '{$path}'");
}
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);
}
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,15 +33,13 @@ 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);
$bestMatch = "";
$bestMatch = '';
$bestMatchPercentage = 0;
$byRefPercentage = 0;
@@ -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,14 +134,14 @@ 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 {
@@ -133,7 +150,7 @@ function getConfigPath() {
if (file_exists($path)) {
return $path;
}
} while (count($paths));
} while (\count($paths));
return null;
}
@@ -142,11 +159,12 @@ 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()) {
@@ -155,11 +173,11 @@ function getArgumentDescription($argument) {
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';
} else if (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");
$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();
if ($cwd && strpos($binary, $cwd) === 0) {
$binary = "." . substr($binary, strlen($cwd));
$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);
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\\"));
}
}