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",

2100
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,145 +32,137 @@ class Auto implements Command {
$this->climate = $climate;
}
public function execute(Manager $args) {
return \Amp\resolve($this->doExecute($args));
}
/**
* @param Manager $args
* @return \Generator
*/
private function doExecute(Manager $args) {
$configPath = $args->get("config");
public function execute(Manager $args): Promise {
return call(function () use ($args) {
$configPath = $args->get('config');
try {
/** @var array $config */
$config = Yaml::parse(
yield \Amp\File\get($configPath)
yield File\get($configPath)
);
} catch (FilesystemException $e) {
$this->climate->error("Config file ({$configPath}) not found.");
yield new CoroutineResult(self::EXIT_CONFIG_ERROR);
return;
return self::EXIT_CONFIG_ERROR;
} 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;
return self::EXIT_CONFIG_ERROR;
}
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('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 ($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"])) {
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;
return self::EXIT_CONFIG_ERROR;
}
if (!isset($config["storage"])) {
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;
return self::EXIT_CONFIG_ERROR;
}
if (!isset($config["email"])) {
if (!isset($config['email'])) {
$this->climate->error("Config file ({$configPath}) didn't have a 'email' set.");
yield new CoroutineResult(self::EXIT_CONFIG_ERROR);
return;
return self::EXIT_CONFIG_ERROR;
}
if (!isset($config["certificates"]) || !is_array($config["certificates"])) {
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;
return self::EXIT_CONFIG_ERROR;
}
if (isset($config["challenge-concurrency"]) && !is_numeric($config["challenge-concurrency"])) {
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;
return self::EXIT_CONFIG_ERROR;
}
$concurrency = isset($config["challenge-concurrency"]) ? (int) $config["challenge-concurrency"] : null;
$concurrency = isset($config['challenge-concurrency']) ? (int) $config['challenge-concurrency'] : null;
$command = implode(" ", array_map("escapeshellarg", [
$process = new Process([
PHP_BINARY,
$GLOBALS["argv"][0],
"setup",
"--server",
$config["server"],
"--storage",
$config["storage"],
"--email",
$config["email"],
]));
$GLOBALS['argv'][0],
'setup',
'--server',
$config['server'],
'--storage',
$config['storage'],
'--email',
$config['email'],
]);
$process = new Process($command);
$result = (yield $process->exec(Process::BUFFER_ALL));
$process->start();
$exit = yield $process->join();
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;
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) {
foreach ($config['certificates'] as $i => $certificate) {
try {
$result = (yield \Amp\resolve($this->checkAndIssue($certificate, $config["server"], $config["storage"], $concurrency)));
$values[$i] = $result;
$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),
'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) {
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.");
$certificate = $config['certificates'][$i];
$this->climate->info('Certificate for ' . implode(', ', array_keys($this->toDomainPathMap($certificate['paths']))) . ' successfully renewed.');
}
}
}
if ($status["failure"] > 0) {
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"]))));
$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
$exitCode = $status['renewed'] > 0
? self::EXIT_ISSUANCE_PARTIAL
: self::EXIT_ISSUANCE_ERROR;
yield new CoroutineResult($exitCode);
return;
return $exitCode;
}
if ($status["renewed"] > 0) {
yield new CoroutineResult(self::EXIT_ISSUANCE_OK);
return;
if ($status['renewed'] > 0) {
return self::EXIT_ISSUANCE_OK;
}
});
}
/**
@@ -174,84 +170,83 @@ class Auto implements Command {
* @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 = \Kelunik\AcmeClient\normalizePath($args->get("storage")) . "/certs/" . $server;
$path = AcmeClient\normalizePath($args->get('storage')) . '/certs/' . $server;
$certificateStore = new CertificateStore($path);
try {
$pem = (yield $certificateStore->get($args->get("name")));
$pem = yield $certificateStore->get($args->get('name'));
} catch (CertificateStoreException $e) {
$this->climate->br()->error(" Certificate not found.")->br();
$this->climate->br()->error(' Certificate not found.')->br();
yield new CoroutineResult(1);
return;
return 1;
}
$cert = new Certificate($pem);
$this->climate->br();
$this->climate->whisper(" Certificate is valid until " . date("d.m.Y", $cert->getValidTo()))->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")));
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();
$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;
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();
$this->climate->comment(' Certificate is going to expire within the specified ' . $args->get('ttl') . ' days.')->br();
yield new CoroutineResult(1);
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 (0 !== stripos(PHP_OS, 'WIN')) {
if (posix_geteuid() !== 0) {
$processUser = posix_getpwnam(posix_geteuid());
$currentUsername = $processUser["name"];
$user = $args->get("user") ?: $currentUsername;
$currentUsername = $processUser['name'];
$user = $args->get('user') ?: $currentUsername;
if ($currentUsername !== $user) {
throw new AcmeException("Running this script with --user only works as root!");
throw new AcmeException('Running this script with --user only works as root!');
}
} else {
$user = $args->get("user") ?: "www-data";
$user = $args->get('user') ?: 'www-data';
}
}
$domains = array_map("trim", explode(":", str_replace([",", ";"], ":", $args->get("domains"))));
yield \Amp\resolve($this->checkDnsRecords($domains));
$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 = explode(PATH_SEPARATOR, str_replace("\\", '/', $args->get('path')));
$docRoots = array_map(function ($root) {
return rtrim($root, "/");
return rtrim($root, '/');
}, $docRoots);
if (count($domains) < count($docRoots)) {
throw new AcmeException("Specified more document roots than domains.");
if (\count($domains) < \count($docRoots)) {
throw new AcmeException('Specified more document roots than domains.');
}
if (count($domains) > count($docRoots)) {
if (\count($domains) > \count($docRoots)) {
$docRoots = array_merge(
$docRoots,
array_fill(count($docRoots), count($domains) - count($docRoots), end($docRoots))
array_fill(\count($docRoots), \count($domains) - \count($docRoots), end($docRoots))
);
}
$keyStore = new KeyStore(\Kelunik\AcmeClient\normalizePath($args->get("storage")));
$keyStore = new KeyStore(\Kelunik\AcmeClient\normalizePath($args->get('storage')));
$server = \Kelunik\AcmeClient\resolveServer($args->get("server"));
$server = \Kelunik\AcmeClient\resolveServer($args->get('server'));
$keyFile = \Kelunik\AcmeClient\serverToKeyname($server);
try {
$keyPair = (yield $keyStore->get("accounts/{$keyFile}.pem"));
$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, $keyPair);
$errors = [];
$acme = $this->acmeFactory->build($server, $key);
$concurrency = \min(20, \max($args->get('challenge-concurrency'), 1));
$concurrency = $args->get("challenge-concurrency");
/** @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);
});
$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));
$errors += $chunkErrors;
}
if (!empty($errors)) {
if ($errors) {
foreach ($errors as $error) {
$this->climate->error($error->getMessage());
}
throw new AcmeException("Issuance failed, not all challenges could be solved.");
throw new AcmeException('Issuance failed, not all challenges could be solved.');
}
$path = "certs/" . $keyFile . "/" . reset($domains) . "/key.pem";
$bits = $args->get("bits");
$path = 'certs/' . $keyFile . '/' . reset($domains) . '/key.pem';
$bits = $args->get('bits');
try {
$keyPair = (yield $keyStore->get($path));
$key = yield $keyStore->get($path);
} catch (KeyStoreException $e) {
$keyPair = (new OpenSSLKeyGenerator)->generate($bits);
$keyPair = (yield $keyStore->put($path, $keyPair));
$key = (new RsaKeyGenerator($bits))->generateKey();
$key = yield $keyStore->put($path, $key);
}
$this->climate->br();
$this->climate->whisper(" Requesting certificate ...");
$this->climate->whisper(' Requesting certificate ...');
$location = (yield $acme->requestCertificate($keyPair, $domains));
$certificates = (yield $acme->pollForCertificate($location));
$csr = (new OpensslCsrGenerator)->generateCsr($key, $domains);
$path = \Kelunik\AcmeClient\normalizePath($args->get("storage")) . "/certs/" . $keyFile;
$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(' Successfully issued certificate.');
$this->climate->info(" See {$path}/" . reset($domains));
$this->climate->br();
yield new CoroutineResult(0);
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')));
private function doExecute(Manager $args) {
$keyStore = new KeyStore(\Kelunik\AcmeClient\normalizePath($args->get("storage")));
$server = AcmeClient\resolveServer($args->get('server'));
$keyFile = AcmeClient\serverToKeyname($server);
$server = \Kelunik\AcmeClient\resolveServer($args->get("server"));
$keyFile = \Kelunik\AcmeClient\serverToKeyname($server);
$keyPair = (yield $keyStore->get("accounts/{$keyFile}.pem"));
$keyPair = yield $keyStore->get("accounts/{$keyFile}.pem");
$acme = $this->acmeFactory->build($server, $keyPair);
$this->climate->br();
$this->climate->whisper(" Revoking certificate ...");
$this->climate->whisper(' Revoking certificate ...');
$path = \Kelunik\AcmeClient\normalizePath($args->get("storage")) . "/certs/" . $keyFile . "/" . $args->get("name") . "/cert.pem";
$path = AcmeClient\normalizePath($args->get('storage')) . '/certs/' . $keyFile . '/' . $args->get('name') . '/cert.pem';
try {
$pem = (yield \Amp\File\get($path));
$pem = yield File\get($path);
$cert = new Certificate($pem);
} catch (FilesystemException $e) {
throw new \RuntimeException("There's no such certificate (" . $path . ")");
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.");
$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);
$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.");
$this->climate->info(' Certificate has been revoked.');
yield (new CertificateStore(\Kelunik\AcmeClient\normalizePath($args->get("storage")) . "/certs/" . $keyFile))->delete($args->get("name"));
yield (new CertificateStore(AcmeClient\normalizePath($args->get('storage')) . '/certs/' . $keyFile))->delete($args->get('name'));
yield new CoroutineResult(0);
return 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);
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);
$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")));
$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 ...");
$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 ...");
$this->climate->whisper(' No private key found, generating new one ...');
$keyPair = (new OpenSSLKeyGenerator)->generate($bits);
$keyPair = (yield $keyStore->put($path, $keyPair));
$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) . " ...");
$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()));
$registration = yield $acme->register($email);
$this->climate->info(' Registration successful. Contacts: ' . implode(', ', $registration->getContact()));
$this->climate->br();
yield new CoroutineResult(0);
return 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,19 +20,12 @@ class Status {
$this->climate = $climate;
}
public function execute(Manager $args) {
return \Amp\resolve($this->doExecute($args));
}
/**
* @param Manager $args
* @return \Generator
*/
private function doExecute(Manager $args) {
$server = \Kelunik\AcmeClient\resolveServer($args->get("server"));
public function execute(Manager $args): Promise {
return call(function () use ($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);
@@ -40,40 +37,42 @@ class Status {
}
$this->climate->br();
$this->climate->out(" [" . ($setup ? "<green> ✓ </green>" : "<red> ✗ </red>") . "] " . ($setup ? "Registered on " : "Not yet registered on ") . $server);
$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}")) {
if (yield File\exists($storage . "/certs/{$keyName}")) {
$certificateStore = new CertificateStore($storage . "/certs/{$keyName}");
$domains = (yield \Amp\File\scandir($storage . "/certs/{$keyName}"));
/** @var array $domains */
$domains = yield File\scandir($storage . "/certs/{$keyName}");
foreach ($domains as $domain) {
$pem = (yield $certificateStore->get($domain));
$pem = yield $certificateStore->get($domain);
$cert = new Certificate($pem);
$symbol = time() > $cert->getValidTo() ? "<red> ✗ </red>" : "<green> ✓ </green>";
$symbol = time() > $cert->getValidTo() ? '<red> ✗ </red>' : '<green> ✓ </green>';
if (time() < $cert->getValidTo() && time() + $args->get("ttl") * 24 * 60 * 60 > $cert->getValidTo()) {
$symbol = "<yellow> ⭮ </yellow>";
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->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) {
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,45 +2,34 @@
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)));
public function __construct(string $root) {
$this->root = rtrim(str_replace("\\", '/', $root), '/');
}
$this->root = rtrim(str_replace("\\", "/", $root), "/");
}
public function get($name) {
return \Amp\resolve($this->doGet($name));
}
private function doGet($name) {
Assert::string($name, "Name must be a string. Got: %s");
public function get(string $name): Promise {
return call(function () use ($name) {
try {
$contents = (yield \Amp\File\get($this->root . "/" . $name . "/cert.pem"));
yield new CoroutineResult($contents);
return yield File\get($this->root . '/' . $name . '/cert.pem');
} catch (FilesystemException $e) {
throw new CertificateStoreException("Failed to load certificate.", 0, $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) {
public function put(array $certificates): Promise {
return call(function () use ($certificates) {
if (empty($certificates)) {
throw new InvalidArgumentException("Empty array not allowed");
throw new \Error('Empty array not allowed');
}
$cert = new Certificate($certificates[0]);
@@ -50,44 +39,42 @@ class CertificateStore {
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)) {
if (!isValidDnsName($commonName)) {
throw new CertificateStoreException("Invalid common name: '{$commonName}'");
}
try {
$chain = array_slice($certificates, 1);
$path = $this->root . "/" . $commonName;
$realpath = realpath($path);
$chain = \array_slice($certificates, 1);
$path = $this->root . '/' . $commonName;
if (!$realpath && !mkdir($path, 0775, true)) {
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 \Amp\File\put($path . "/cert.pem", $certificates[0]);
yield \Amp\File\chmod($path . "/cert.pem", 0644);
yield File\put($path . '/cert.pem', $certificates[0]);
yield 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 File\put($path . '/fullchain.pem', implode("\n", $certificates));
yield File\chmod($path . '/fullchain.pem', 0644);
yield \Amp\File\put($path . "/chain.pem", implode("\n", $chain));
yield \Amp\File\chmod($path . "/chain.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);
foreach ($files as $file) {
yield File\unlink($this->root . '/' . $name . '/' . $file);
}
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);
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)));
public function __construct(string $docroot) {
$this->docroot = rtrim(str_replace("\\", '/', $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));
}
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");
$path = $this->docroot . "/.well-known/acme-challenge";
$realpath = realpath($path);
if (!realpath($this->docroot)) {
if (!yield File\exists($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 (!yield File\isdir($path) && !yield File\mkdir($path, 0644, true) && !yield File\isdir($path)) {
throw new ChallengeStoreException("Couldn't create key directory: '{$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);
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);
});
}
public function delete($token) {
return \Amp\resolve($this->doDelete($token));
}
private function doDelete($token) {
Assert::string($token, "Token must be a string. Got: %s");
public function delete(string $token): Promise {
return call(function () use ($token) {
$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)));
public function __construct(string $root = '') {
$this->root = rtrim(str_replace("\\", '/', $root), '/');
}
$this->root = rtrim(str_replace("\\", "/", $root), "/");
}
public function get(string $path): Promise {
return call(function () use ($path) {
$file = $this->root . '/' . $path;
$privateKey = yield File\get($file);
public function get($path) {
if (!is_string($path)) {
throw new InvalidArgumentException(sprintf("\$root must be of type string, %s given.", gettype($path)));
}
return \Amp\resolve($this->doGet($path));
}
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));
// Check key here to be valid, PrivateKey doesn't do that, we fail early here
$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));
return new PrivateKey($privateKey);
});
}
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;
public function put(string $path, PrivateKey $key): Promise {
return call(function () use ($path, $key) {
$file = $this->root . '/' . $path;
try {
// TODO: Replace with async version once available
if (!file_exists(dirname($file))) {
$success = mkdir(dirname($file), 0755, true);
$dir = \dirname($file);
if (!$success) {
throw new KeyStoreException("Could not create key store directory.");
}
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 \Amp\File\put($file, $keyPair->getPrivate());
yield \Amp\File\chmod($file, 0600);
yield File\put($file, $key->toPem());
yield File\chmod($file, 0600);
} catch (FilesystemException $e) {
throw new KeyStoreException("Could not save key.", 0, $e);
throw new KeyStoreException('Could not save key: ' . $e->getMessage(), 0, $e);
}
yield new CoroutineResult($keyPair);
return $key;
});
}
}

View File

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

View File

@@ -2,12 +2,30 @@
namespace Kelunik\AcmeClient;
use Amp\Sync\LocalSemaphore;
use Amp\Sync\Lock;
use InvalidArgumentException;
use Kelunik\Acme\AcmeException;
use Phar;
use Symfony\Component\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Yaml;
use Webmozart\Assert\Assert;
use function Amp\call;
use function Amp\coroutine;
function concurrentMap(int $concurrency, array $values, callable $functor): array {
$semaphore = new LocalSemaphore($concurrency);
return \array_map(coroutine(function ($value, $key) use ($semaphore, $functor) {
/** @var Lock $lock */
$lock = yield $semaphore->acquire();
try {
return yield call($functor, $value, $key);
} finally {
$lock->release();
}
}), $values, array_keys($values));
}
/**
* Suggests a command based on similarity in a list of available commands.
@@ -15,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\\"));
}
}