34 Commits

Author SHA1 Message Date
Niklas Keller
361a06ce26 Upgrade dependencies 2021-10-25 22:20:11 +02:00
Niklas Keller
74aa1b82fb Cleanup default parameter in changeOwner 2021-08-10 00:30:37 +02:00
Niklas Keller
b464b52a85 Fix changeOwner → changePermissions 2021-08-10 00:30:37 +02:00
Niklas Keller
82c053fa02 Update usage.md 2021-07-08 23:40:18 +02:00
Niklas Keller
5a8a6d471c Update installation.md 2021-07-08 23:37:01 +02:00
Niklas Keller
1210f0b7fc Update README.md 2021-07-08 23:36:06 +02:00
Niklas Keller
e9b382128d Upgrade to ACME RFC (v2) 2021-07-04 12:40:11 +02:00
Niklas Keller
6bfa43dad9 Update dependencies and code style 2021-06-30 00:28:56 +02:00
Pieter Hordijk
5b112cba3e Fixed required PHP version to run the client (#77) 2019-03-01 19:44:17 +01:00
Niklas Keller
35070bb70a Add logging support for debugging 2018-06-09 19:00:02 +02:00
Niklas Keller
6da46ddaf6 Upgrade dependencies 2018-06-06 21:50:52 +02:00
Niklas Keller
51acff5bd3 Implement --rekey option
Closes #65.
Closes #19.
2018-04-15 19:14:36 +02:00
Niklas Keller
ea3e9dc68c Update LICENSE date 2018-04-15 18:27:00 +02:00
Niklas Keller
6f01055884 Upgrade to amphp/process v0.3.x 2018-04-15 18:19:40 +02:00
Niklas Keller
297e1aa9b1 Update dependencies 2018-04-15 18:12:32 +02:00
Niklas Keller
b7cfe3c0f1 Update to amphp/parallel v0.2.5
This fixes running as PHAR if the PHAR doesn't end with '.phar'.
2018-03-21 15:52:05 +01:00
Niklas Keller
4053094860 Update .travis.yml 2018-03-21 13:06:30 +01:00
Niklas Keller
a80b7b8497 Remove BlockingDriver usage, as parallel has been fixed 2018-03-21 12:55:25 +01:00
Niklas Keller
f13b0856c7 Update dependencies 2018-03-21 12:54:21 +01:00
Niklas Keller
d4f2009315 Fix build on nightly 2018-01-11 17:18:29 +01:00
Niklas Keller
2b4a200263 Fix CSR generation 2018-01-11 17:15:04 +01:00
Niklas Keller
256aa76011 Fix directory permissions 2018-01-11 17:11:28 +01:00
Niklas Keller
69bc88daf1 Refactor directory creation 2018-01-11 17:00:27 +01:00
Niklas Keller
19f6550e33 Fix key store path in exception message 2018-01-11 16:53:01 +01:00
Niklas Keller
ed3da3c98d Fix DNS lookups 2018-01-11 16:43:32 +01:00
Niklas Keller
56955155fe Work around https://bugs.php.net/bug.php?id=75396 2018-01-11 10:36:45 +01:00
Niklas Keller
e3d7723da3 Fix bugs in stores not yielding the correct things 2018-01-11 10:32:17 +01:00
Niklas Keller
0ae207fce3 Downgrade dependencies to be compatible with PHP 7.0 2018-01-09 19:30:34 +01:00
Niklas Keller
bb7e25704c Work around issue with amphp/parallel and PHARs 2018-01-08 18:44:33 +01:00
Niklas Keller
246a02b5cf Update dependencies 2018-01-08 18:43:52 +01:00
Niklas Keller
2dba4a852a Fix code style 2017-12-29 19:23:48 +01:00
Niklas Keller
d8a93a273c Update meta files 2017-12-29 19:20:59 +01:00
Niklas Keller
9a9a243807 Upgrade to Amp v2 2017-12-29 19:15:47 +01:00
Ayman Nedjmeddine
c71b07ef03 Add sample configuration file 2017-12-16 12:53:09 +01:00
33 changed files with 5844 additions and 2145 deletions

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

@@ -0,0 +1,43 @@
# Storage directory for certificates and keys.
storage: /etc/acme
# Server to use. URL to the ACME directory.
# "letsencrypt" and "letsencrypt:staging" are valid shortcuts.
server: letsencrypt
# E-mail to use for the setup.
# This e-mail will receive expiration notices from Let's Encrypt.
email: me@example.com
# List of certificates to issue.
certificates:
# For each certificate, there are a few options.
#
# Required: paths
# Optional: bits, user
#
# paths: Map of document roots to domains. Maps each path to one or multiple
# domains. If one domain is given, it's automatically converted to an
# array. The first domain will be the common name.
#
# The client will place a file into /.well-known/acme-challenge/
# to verify ownership to the CA
#
# bits: Number of bits for the domain private key
#
# user: User running the web server. Challenge files are world readable,
# but some servers might require to be owner of files they serve.
#
# rekey: Regenerate certificate key pairs even if a key pair already exists.
#
- bits: 4096
rekey: true
paths:
/var/www/example:
- example.org
- www.example.org
# You can have multiple certificate with different users and key options.
- user: www-data
paths:
/var/www: example.org

4
.gitignore vendored
View File

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

15
.php_cs
View File

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

10
.php_cs.dist Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,14 @@
#!/usr/bin/env php
<?php
use Amp\Http\Client\HttpClientBuilder;
use Amp\Loop;
use Auryn\Injector;
use Kelunik\AcmeClient\AcmeFactory;
use Kelunik\AcmeClient\Commands\Command;
use League\CLImate\CLImate;
use function Kelunik\AcmeClient\getBinary;
use function Kelunik\AcmeClient\suggestCommand;
$logo = <<<LOGO
____ __________ ___ ___
@@ -13,7 +18,7 @@ $logo = <<<LOGO
LOGO;
if (!file_exists(__DIR__ . "/../vendor/autoload.php")) {
if (!file_exists(__DIR__ . '/../vendor/autoload.php')) {
echo $logo;
echo <<<HELP
@@ -26,7 +31,7 @@ HELP;
exit(-1);
}
if (!function_exists("openssl_pkey_get_private")) {
if (!function_exists('openssl_pkey_get_private')) {
echo $logo;
echo <<<HELP
@@ -37,20 +42,20 @@ HELP;
exit(-2);
}
require __DIR__ . "/../vendor/autoload.php";
require __DIR__ . '/../vendor/autoload.php';
$commands = [
"auto" => "Setup, issue and renew based on a single configuration file.",
"setup" => "Setup and register account.",
"issue" => "Issue a new certificate.",
"check" => "Check if a certificate is still valid long enough.",
"revoke" => "Revoke a certificate.",
"status" => "Show status about local certificates.",
"version" => "Print version information.",
"help" => "Print this help information.",
'auto' => 'Setup, issue and renew based on a single configuration file.',
'setup' => 'Setup and register account.',
'issue' => 'Issue a new certificate.',
'check' => 'Check if a certificate is still valid long enough.',
'revoke' => 'Revoke a certificate.',
'status' => 'Show status about local certificates.',
'version' => 'Print version information.',
'help' => 'Print this help information.',
];
$binary = \Kelunik\AcmeClient\getBinary();
$binary = getBinary();
$help = implode(PHP_EOL, array_map(function ($command) use ($commands) {
$help = " <green>{$command}</green>\n";
@@ -75,25 +80,25 @@ EOT;
$climate = new CLImate;
if (!in_array(PHP_SAPI, ["cli", "phpdbg"], true)) {
$climate->error("Please run this script on the command line!");
if (!in_array(PHP_SAPI, ['cli', 'phpdbg'], true)) {
$climate->error('Please run this script on the command line!');
exit(1);
}
if (PHP_VERSION_ID < 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 < 70400) {
$climate->yellow("You're using an older version of PHP which is no longer supported by this client. Have a look at https://php.net/supported-versions.php and upgrade at least to PHP 7.4!");
$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));
$suggestion = suggestCommand($argv[1], array_keys($commands));
if ($suggestion) {
$climate->br()->out(" Did you mean '$suggestion'?");
@@ -104,7 +109,7 @@ if (!in_array($argv[1], array_keys($commands))) {
exit(1);
}
/** @var \Kelunik\AcmeClient\Commands\Command $class */
/** @var string|Command $class */
$class = "Kelunik\\AcmeClient\\Commands\\" . ucfirst($argv[1]);
$definition = $class::getDefinition();
@@ -114,14 +119,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 +140,17 @@ try {
$injector = new Injector;
$injector->share($climate);
$injector->share(new AcmeFactory);
$injector->share(new Amp\Artax\Client(new Amp\Artax\Cookie\NullCookieJar));
$injector->share(HttpClientBuilder::buildDefault());
$command = $injector->make($class);
$exitCode = 1;
Amp\run(function () use ($command, $climate) {
Loop::run(function () use ($command, $climate, &$exitCode) {
$handler = function ($e) use ($climate) {
$error = (string) $e;
$lines = explode("\n", $error);
$lines = array_filter($lines, function ($line) {
return strlen($line) && $line[0] !== "#" && $line !== "Stack trace:";
return $line !== '' && $line[0] !== '#' && $line !== 'Stack trace:';
});
foreach ($lines as $line) {
@@ -155,18 +161,16 @@ Amp\run(function () use ($command, $climate) {
};
try {
$exitCode = (yield $command->execute($climate->arguments));
$exitCode = yield $command->execute($climate->arguments);
if ($exitCode === null) {
exit(0);
$exitCode = 0;
}
exit($exitCode);
} catch (Throwable $e) {
$handler($e);
} catch (Exception $e) {
$handler($e);
}
Amp\stop();
Loop::stop();
});
exit($exitCode);

View File

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

5534
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,7 +24,7 @@ You can separate multiple domains (`-d`) with `,`, `:` or `;`. You can separate
If you specify less paths than domains, the last one will be used for the remaining domains.
Please note that Let's Encrypt has rate limits. Currently it's five certificates per domain per seven days. If you combine multiple subdomains in a single certificate, they count as just one certificate. If you just want to test things out, you can use their staging server, which has way higher rate limits by appending `--s letsencrypt:staging`.
Please note that Let's Encrypt has rate limits. Currently it's five certificates per domain per seven days. If you combine multiple subdomains in a single certificate, they count as just one certificate. If you just want to test things out, you can use their staging server, which has way higher rate limits by appending `--server letsencrypt:staging`.
## Revoke a Certificate

View File

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

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

@@ -53,7 +53,10 @@ certificates:
# user: User running the web server. Challenge files are world readable,
# but some servers might require to be owner of files they serve.
#
# rekey: Regenerate certificate key pairs even if a key pair already exists.
#
- bits: 4096
rekey: true
paths:
/var/www/example:
- example.org
@@ -66,6 +69,8 @@ certificates:
All configuration keys are optional and can be passed as arguments directly (except for `certificates` when using `acme-client auto`).
Before you can issue certificates, you must create an account using `acme-client setup --agree-terms`.
## Certificate Issuance
You can use `acme-client auto` to issue certificates and renew them if necessary. It uses the configuration file to

View File

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

View File

@@ -2,263 +2,314 @@
namespace Kelunik\AcmeClient\Commands;
use Amp\CoroutineResult;
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\ByteStream\buffer;
use function Amp\call;
class Auto implements Command {
const EXIT_CONFIG_ERROR = 1;
const EXIT_SETUP_ERROR = 2;
const EXIT_ISSUANCE_ERROR = 3;
const EXIT_ISSUANCE_PARTIAL = 4;
const EXIT_ISSUANCE_OK = 5;
class Auto implements Command
{
private const EXIT_CONFIG_ERROR = 1;
private const EXIT_SETUP_ERROR = 2;
private const EXIT_ISSUANCE_ERROR = 3;
private const EXIT_ISSUANCE_PARTIAL = 4;
private const EXIT_ISSUANCE_OK = 5;
const STATUS_NO_CHANGE = 0;
const STATUS_RENEWED = 1;
private const STATUS_NO_CHANGE = 0;
private const STATUS_RENEWED = 1;
public static function getDefinition(): array
{
$server = AcmeClient\getArgumentDescription('server');
$storage = AcmeClient\getArgumentDescription('storage');
$server['required'] = false;
$storage['required'] = false;
$args = [
'server' => $server,
'storage' => $storage,
'config' => [
'prefix' => 'c',
'longPrefix' => 'config',
'description' => 'Configuration file to read.',
'required' => true,
],
];
$configPath = AcmeClient\getConfigPath();
if ($configPath) {
$args['config']['required'] = false;
$args['config']['defaultValue'] = $configPath;
}
return $args;
}
private $climate;
public function __construct(CLImate $climate) {
public function __construct(CLImate $climate)
{
$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\read($configPath)
);
} catch (FilesystemException $e) {
$this->climate->error("Config file ({$configPath}) not found.");
return self::EXIT_CONFIG_ERROR;
} catch (ParseException $e) {
$this->climate->error("Config file ({$configPath}) had an invalid format and couldn't be parsed.");
return self::EXIT_CONFIG_ERROR;
}
}
$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');
} elseif (!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');
} elseif (!isset($config['storage']) && $args->exists('storage')) {
$config['storage'] = $args->get('storage');
}
if (!isset($config['server'])) {
$this->climate->error("Config file ({$configPath}) didn't have a 'server' set nor was it passed as command line argument.");
return self::EXIT_CONFIG_ERROR;
}
if (!isset($config['storage'])) {
$this->climate->error("Config file ({$configPath}) didn't have a 'storage' set nor was it passed as command line argument.");
return self::EXIT_CONFIG_ERROR;
}
if (!isset($config['email'])) {
$this->climate->error("Config file ({$configPath}) didn't have a 'email' set.");
return self::EXIT_CONFIG_ERROR;
}
if (!isset($config['certificates']) || !\is_array($config['certificates'])) {
$this->climate->error("Config file ({$configPath}) didn't have a 'certificates' section that's an array.");
return self::EXIT_CONFIG_ERROR;
}
if (isset($config['challenge-concurrency']) && !\is_numeric($config['challenge-concurrency'])) {
$this->climate->error("Config file ({$configPath}) defines an invalid 'challenge-concurrency' value.");
return self::EXIT_CONFIG_ERROR;
}
foreach ($config['certificates'] as $certificateConfig) {
if (isset($certificateConfig['rekey']) && !\is_bool($certificateConfig['rekey'])) {
$this->climate->error("Config file ({$configPath}) defines an invalid 'rekey' value.");
return self::EXIT_CONFIG_ERROR;
}
}
}
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}");
$concurrency = isset($config['challenge-concurrency']) ? (int) $config['challenge-concurrency'] : null;
$process = new Process([
PHP_BINARY,
$GLOBALS['argv'][0],
'setup',
'--server',
$config['server'],
'--storage',
$config['storage'],
'--email',
$config['email'],
]);
$process->start();
$exit = yield $process->join();
if ($exit !== 0) {
$this->climate->error("Registration failed ({$exit})");
$this->climate->br()->out(yield buffer($process->getStdout()));
$this->climate->br()->error(yield buffer($process->getStderr()));
return self::EXIT_SETUP_ERROR;
}
$exitCode = $status["renewed"] > 0
? self::EXIT_ISSUANCE_PARTIAL
: self::EXIT_ISSUANCE_ERROR;
$errors = [];
$values = [];
yield new CoroutineResult($exitCode);
return;
}
foreach ($config['certificates'] as $i => $certificate) {
try {
$exit = yield call(function () use ($certificate, $config, $concurrency) {
return $this->checkAndIssue($certificate, $config['server'], $config['storage'], $concurrency);
});
if ($status["renewed"] > 0) {
yield new CoroutineResult(self::EXIT_ISSUANCE_OK);
return;
}
$values[$i] = $exit;
} catch (\Exception $e) {
$errors[$i] = $e;
}
}
$status = [
'no_change' => \count(\array_filter($values, function ($value) {
return $value === self::STATUS_NO_CHANGE;
})),
'renewed' => \count(\array_filter($values, function ($value) {
return $value === self::STATUS_RENEWED;
})),
'failure' => \count($errors),
];
if ($status['renewed'] > 0) {
foreach ($values as $i => $value) {
if ($value === self::STATUS_RENEWED) {
$certificate = $config['certificates'][$i];
$this->climate->info('Certificate for ' . \implode(
', ',
\array_keys($this->toDomainPathMap($certificate['paths']))
) . ' successfully renewed.');
}
}
}
if ($status['failure'] > 0) {
foreach ($errors as $i => $error) {
$certificate = $config['certificates'][$i];
$this->climate->error('Issuance for the following domains failed: ' . \implode(
', ',
\array_keys($this->toDomainPathMap($certificate['paths']))
));
$this->climate->error("Reason: {$error}");
}
$exitCode = $status['renewed'] > 0
? self::EXIT_ISSUANCE_PARTIAL
: self::EXIT_ISSUANCE_ERROR;
return $exitCode;
}
if ($status['renewed'] > 0) {
return self::EXIT_ISSUANCE_OK;
}
});
}
/**
* @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"]);
$domains = array_keys($domainPathMap);
$commonName = reset($domains);
$args = [
private function checkAndIssue(
array $certificate,
string $server,
string $storage,
int $concurrency = null
): \Generator {
$domainPathMap = $this->toDomainPathMap($certificate['paths']);
$domains = \array_keys($domainPathMap);
$commonName = \reset($domains);
$processArgs = [
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 = new Process($command);
$result = (yield $process->exec(Process::BUFFER_ALL));
if ($result->exit === 0) {
// No need for renewal
yield new CoroutineResult(self::STATUS_NO_CHANGE);
return;
if ($certificate['rekey'] ?? false) {
$processArgs[] = '--rekey';
}
if ($result->exit === 1) {
$process = new Process($processArgs);
$process->start();
$exit = yield $process->join();
if ($exit === 0) {
// No need for renewal
return self::STATUS_NO_CHANGE;
}
if ($exit === 1) {
// Renew certificate
$args = [
PHP_BINARY,
$GLOBALS["argv"][0],
"issue",
"--server",
$GLOBALS['argv'][0],
'issue',
'--server',
$server,
"--storage",
'--storage',
$storage,
"--domains",
implode(",", $domains),
"--path",
implode(PATH_SEPARATOR, array_values($domainPathMap)),
'--domains',
\implode(',', $domains),
'--path',
\implode(PATH_SEPARATOR, \array_values($domainPathMap)),
];
if (isset($certificate["user"])) {
$args[] = "--user";
$args[] = $certificate["user"];
if (isset($certificate['user'])) {
$args[] = '--user';
$args[] = $certificate['user'];
}
if (isset($certificate["bits"])) {
$args[] = "--bits";
$args[] = $certificate["bits"];
if (isset($certificate['bits'])) {
$args[] = '--bits';
$args[] = $certificate['bits'];
}
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) {
private function toDomainPathMap(array $paths)
{
$result = [];
foreach ($paths as $path => $domains) {
if (is_numeric($path)) {
if (\is_numeric($path)) {
$message = <<<MESSAGE
Your configuration has the wrong format. Received a numeric value as path name.
@@ -307,32 +358,4 @@ MESSAGE;
return $result;
}
public static function getDefinition() {
$server = \Kelunik\AcmeClient\getArgumentDescription("server");
$storage = \Kelunik\AcmeClient\getArgumentDescription("storage");
$server["required"] = false;
$storage["required"] = false;
$args = [
"server" => $server,
"storage" => $storage,
"config" => [
"prefix" => "c",
"longPrefix" => "config",
"description" => "Configuration file to read.",
"required" => true,
],
];
$configPath = \Kelunik\AcmeClient\getConfigPath();
if ($configPath) {
$args["config"]["required"] = false;
$args["config"]["defaultValue"] = $configPath;
}
return $args;
}
}
}

View File

@@ -2,91 +2,92 @@
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;
use function Kelunik\AcmeClient\getArgumentDescription;
class Check implements Command {
private $climate;
public function __construct(CLImate $climate) {
$this->climate = $climate;
}
public function execute(Manager $args) {
return \Amp\resolve($this->doExecute($args));
}
/**
* @param Manager $args
* @return \Generator
*/
private function doExecute(Manager $args) {
$server = \Kelunik\AcmeClient\resolveServer($args->get("server"));
$server = \Kelunik\AcmeClient\serverToKeyname($server);
$path = \Kelunik\AcmeClient\normalizePath($args->get("storage")) . "/certs/" . $server;
$certificateStore = new CertificateStore($path);
try {
$pem = (yield $certificateStore->get($args->get("name")));
} catch (CertificateStoreException $e) {
$this->climate->br()->error(" Certificate not found.")->br();
yield new CoroutineResult(1);
return;
}
$cert = new Certificate($pem);
$this->climate->br();
$this->climate->whisper(" Certificate is valid until " . date("d.m.Y", $cert->getValidTo()))->br();
if ($args->defined("names")) {
$names = array_map("trim", explode(",", $args->get("names")));
$missingNames = array_diff($names, $cert->getNames());
if ($missingNames) {
$this->climate->comment(" The following names are not covered: " . implode(", ", $missingNames))->br();
yield new CoroutineResult(1);
return;
}
}
if ($cert->getValidTo() > time() + $args->get("ttl") * 24 * 60 * 60) {
yield new CoroutineResult(0);
return;
}
$this->climate->comment(" Certificate is going to expire within the specified " . $args->get("ttl") . " days.")->br();
yield new CoroutineResult(1);
}
public static function getDefinition() {
class Check implements Command
{
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' => getArgumentDescription('server'),
'storage' => getArgumentDescription('storage'),
'name' => [
'longPrefix' => 'name',
'description' => 'Common name of the certificate to check.',
'required' => true,
],
"ttl" => [
"longPrefix" => "ttl",
"description" => "Minimum valid time in days.",
"defaultValue" => 30,
"castTo" => "int",
'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,
],
];
}
}
private $climate;
public function __construct(CLImate $climate)
{
$this->climate = $climate;
}
public function execute(Manager $args): Promise
{
return call(function () use ($args) {
$server = AcmeClient\resolveServer($args->get('server'));
$server = AcmeClient\serverToKeyname($server);
$path = AcmeClient\normalizePath($args->get('storage')) . '/certs/' . $server;
$certificateStore = new CertificateStore($path);
try {
$pem = yield $certificateStore->get($args->get('name'));
} catch (CertificateStoreException $e) {
$this->climate->br()->error(' Certificate not found.')->br();
return 1;
}
$cert = new Certificate($pem);
$this->climate->br();
$this->climate->whisper(' Certificate is valid until ' . \date('d.m.Y', $cert->getValidTo()))->br();
if ($args->defined('names')) {
$names = \array_map('trim', \explode(',', $args->get('names')));
$missingNames = \array_diff($names, $cert->getNames());
if ($missingNames) {
$this->climate->comment(' The following names are not covered: ' . \implode(
', ',
$missingNames
))->br();
return 1;
}
}
if ($cert->getValidTo() > \time() + $args->get('ttl') * 24 * 60 * 60) {
return 0;
}
$this->climate->comment(' Certificate is going to expire within the specified ' . $args->get('ttl') . ' days.')->br();
return 1;
});
}
}

View File

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

View File

@@ -2,13 +2,19 @@
namespace Kelunik\AcmeClient\Commands;
use Amp\CoroutineResult;
use Amp\Dns\Record;
use Exception;
use Amp\Dns;
use Amp\Promise;
use Kelunik\Acme\AcmeException;
use Kelunik\Acme\AcmeService;
use Kelunik\Acme\KeyPair;
use Kelunik\Acme\OpenSSLKeyGenerator;
use Kelunik\Acme\Crypto\Backend\OpensslBackend;
use Kelunik\Acme\Crypto\PrivateKey;
use Kelunik\Acme\Crypto\RsaKeyGenerator;
use Kelunik\Acme\Csr\OpensslCsrGenerator;
use Kelunik\Acme\Protocol\Authorization;
use Kelunik\Acme\Protocol\Challenge;
use Kelunik\Acme\Protocol\Order;
use Kelunik\Acme\Verifiers\Http01;
use Kelunik\AcmeClient;
use Kelunik\AcmeClient\AcmeFactory;
use Kelunik\AcmeClient\Stores\CertificateStore;
use Kelunik\AcmeClient\Stores\ChallengeStore;
@@ -16,249 +22,268 @@ 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;
use function Kelunik\AcmeClient\getArgumentDescription;
use function Kelunik\AcmeClient\normalizePath;
use function Kelunik\AcmeClient\resolveServer;
use function Kelunik\AcmeClient\serverToKeyname;
class Issue implements Command
{
public static function getDefinition(): array
{
return [
'server' => getArgumentDescription('server'),
'storage' => getArgumentDescription('storage'),
'domains' => [
'prefix' => 'd',
'longPrefix' => 'domains',
'description' => 'Colon / Semicolon / Comma separated list of domains to request a certificate for.',
'required' => true,
],
'path' => [
'prefix' => 'p',
'longPrefix' => 'path',
'description' => 'Colon (Unix) / Semicolon (Windows) separated list of paths to the document roots. The last one will be used for all remaining ones if fewer than the amount of domains is given.',
'required' => true,
],
'user' => [
'prefix' => 'u',
'longPrefix' => 'user',
'description' => 'User running the web server.',
],
'bits' => [
'longPrefix' => 'bits',
'description' => 'Length of the private key in bit.',
'defaultValue' => 2048,
'castTo' => 'int',
],
'challenge-concurrency' => [
'longPrefix' => 'challenge-concurrency',
'description' => 'Number of challenges to be solved concurrently.',
'defaultValue' => 10,
'castTo' => 'int',
],
'rekey' => [
'longPrefix' => 'rekey',
'description' => 'Regenerate the key pair even if a key pair already exists.',
'noValue' => true,
],
];
}
class Issue implements Command {
private $climate;
private $acmeFactory;
public function __construct(CLImate $climate, AcmeFactory $acmeFactory) {
public function __construct(CLImate $climate, AcmeFactory $acmeFactory)
{
$this->climate = $climate;
$this->acmeFactory = $acmeFactory;
}
public function execute(Manager $args) {
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));
$domains = \array_map('trim', \explode(':', \str_replace([',', ';'], ':', $args->get('domains'))));
yield from $this->checkDnsRecords($domains);
$docRoots = explode(PATH_SEPARATOR, str_replace("\\", "/", $args->get("path")));
$docRoots = array_map(function ($root) {
return rtrim($root, "/");
}, $docRoots);
$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)) {
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))
if (\count($domains) > \count($docRoots)) {
$docRoots = \array_merge(
$docRoots,
\array_fill(\count($docRoots), \count($domains) - \count($docRoots), \end($docRoots))
);
}
$keyStore = new KeyStore(normalizePath($args->get('storage')));
$server = resolveServer($args->get('server'));
$keyFile = serverToKeyname($server);
try {
$key = yield $keyStore->get("accounts/{$keyFile}.pem");
} catch (KeyStoreException $e) {
throw new AcmeException("Account key not found, did you run 'bin/acme setup'?", 0, $e);
}
$this->climate->br();
$acme = $this->acmeFactory->build($server, $key);
$concurrency = \min(20, \max($args->get('challenge-concurrency'), 1));
/** @var Order $order */
$order = yield $acme->newOrder($domains);
/** @var \Throwable[] $errors */
[$errors] = yield AcmeClient\concurrentMap(
$concurrency,
$order->getAuthorizationUrls(),
function ($authorizationUrl, $i) use ($acme, $key, $domains, $docRoots, $user) {
/** @var Authorization $authorization */
$authorization = yield $acme->getAuthorization($authorizationUrl);
if ($authorization->getIdentifier()->getType() !== 'dns') {
throw new AcmeException('Invalid identifier: ' . $authorization->getIdentifier()->getType());
}
$name = $authorization->getIdentifier()->getValue();
if ($authorization->isWildcard()) {
$name .= '*.';
}
$index = \array_search($name, $domains, true);
if ($index === false) {
throw new AcmeException('Unknown identifier returned: ' . $name);
}
return yield from $this->solveChallenge($acme, $key, $authorization, $domains[$i], $docRoots[$i],
$user);
}
);
}
$keyStore = new KeyStore(\Kelunik\AcmeClient\normalizePath($args->get("storage")));
if ($errors) {
foreach ($errors as $error) {
$this->climate->error($error->getMessage());
}
$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]));
throw new AcmeException('Issuance failed, not all challenges could be solved.');
}
list($chunkErrors) = (yield \Amp\any($promises));
yield $acme->pollForOrderReady($order->getUrl());
$errors += $chunkErrors;
}
$keyPath = 'certs/' . $keyFile . '/' . \reset($domains) . '/key.pem';
$bits = $args->get('bits');
if (!empty($errors)) {
foreach ($errors as $error) {
$this->climate->error($error->getMessage());
$regenerateKey = $args->get('rekey');
try {
$key = yield $keyStore->get($keyPath);
} catch (KeyStoreException $e) {
$regenerateKey = true;
}
throw new AcmeException("Issuance failed, not all challenges could be solved.");
}
if ($regenerateKey) {
$this->climate->whisper(' Generating new key pair ...');
$key = (new RsaKeyGenerator($bits))->generateKey();
}
$path = "certs/" . $keyFile . "/" . reset($domains) . "/key.pem";
$bits = $args->get("bits");
$this->climate->br();
$this->climate->whisper(' Requesting certificate ...');
try {
$keyPair = (yield $keyStore->get($path));
} catch (KeyStoreException $e) {
$keyPair = (new OpenSSLKeyGenerator)->generate($bits);
$keyPair = (yield $keyStore->put($path, $keyPair));
}
$csr = yield (new OpensslCsrGenerator)->generateCsr($key, $domains);
$this->climate->br();
$this->climate->whisper(" Requesting certificate ...");
yield $acme->finalizeOrder($order->getFinalizationUrl(), $csr);
yield $acme->pollForOrderValid($order->getUrl());
$location = (yield $acme->requestCertificate($keyPair, $domains));
$certificates = (yield $acme->pollForCertificate($location));
/** @var Order $order */
$order = yield $acme->getOrder($order->getUrl());
$path = \Kelunik\AcmeClient\normalizePath($args->get("storage")) . "/certs/" . $keyFile;
$certificateStore = new CertificateStore($path);
yield $certificateStore->put($certificates);
$certificates = yield $acme->downloadCertificates($order->getCertificateUrl());
$this->climate->info(" Successfully issued certificate.");
$this->climate->info(" See {$path}/" . reset($domains));
$this->climate->br();
$path = normalizePath($args->get('storage')) . '/certs/' . $keyFile;
$certificateStore = new CertificateStore($path);
yield new CoroutineResult(0);
yield $keyStore->put($keyPath, $key);
yield $certificateStore->put($certificates);
$this->climate->info(' Successfully issued certificate.');
$this->climate->info(" See {$path}/" . \reset($domains));
$this->climate->br();
return 0;
});
}
private function solveChallenge(AcmeService $acme, KeyPair $keyPair, $domain, $path) {
list($location, $challenges) = (yield $acme->requestChallenges($domain));
$goodChallenges = $this->findSuitableCombination($challenges);
if (empty($goodChallenges)) {
private function solveChallenge(
AcmeService $acme,
PrivateKey $key,
Authorization $authorization,
string $domain,
string $path,
string $user = null
): \Generator {
$httpChallenge = $this->findHttpChallenge($authorization);
if ($httpChallenge === null) {
throw new AcmeException("Couldn't find any combination of challenges which this client can solve!");
}
$challenge = $challenges->challenges[reset($goodChallenges)];
$token = $challenge->token;
if (!preg_match("#^[a-zA-Z0-9-_]+$#", $token)) {
throw new AcmeException("Protocol violation: Invalid Token!");
$token = $httpChallenge->getToken();
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 $acme->answerChallenge($challenge->uri, $payload);
yield $acme->pollForChallenge($location);
yield (new Http01)->verifyChallenge($domain, $token, $payload);
yield $acme->finalizeChallenge($httpChallenge->getUrl());
yield $acme->pollForAuthorization($authorization->getUrl());
$this->climate->comment(" {$domain} is now authorized.");
} finally {
yield $challengeStore->delete($token);
} catch (Exception $e) {
// no finally because generators...
yield $challengeStore->delete($token);
throw $e;
} catch (Throwable $e) {
// no finally because generators...
yield $challengeStore->delete($token);
throw $e;
}
}
private function checkDnsRecords($domains) {
$errors = [];
private function checkDnsRecords(array $domains): \Generator
{
$promises = AcmeClient\concurrentMap(10, $domains, function (string $domain): Promise {
return Dns\resolve($domain);
});
$domainChunks = array_chunk($domains, 10, true);
[$errors] = yield Promise\any($promises);
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));
$reasons = implode("\n\n", array_map(function ($exception) {
/** @var \Exception|\Throwable $exception */
return get_class($exception) . ": " . $exception->getMessage();
if ($errors) {
$failedDomains = \implode(', ', \array_keys($errors));
$reasons = \implode("\n\n", \array_map(static function (\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 : [];
$goodChallenges = [];
private function findHttpChallenge(Authorization $authorization): ?Challenge
{
$challenges = $authorization->getChallenges();
foreach ($challenges as $i => $challenge) {
if ($challenge->type === "http-01") {
$goodChallenges[] = $i;
foreach ($challenges as $challenge) {
if ($challenge->getType() === 'http-01') {
return $challenge;
}
}
foreach ($goodChallenges as $i => $challenge) {
if (!in_array([$challenge], $combinations)) {
unset($goodChallenges[$i]);
}
}
return $goodChallenges;
}
public static function getDefinition() {
return [
"server" => \Kelunik\AcmeClient\getArgumentDescription("server"),
"storage" => \Kelunik\AcmeClient\getArgumentDescription("storage"),
"domains" => [
"prefix" => "d",
"longPrefix" => "domains",
"description" => "Colon / Semicolon / Comma separated list of domains to request a certificate for.",
"required" => true,
],
"path" => [
"prefix" => "p",
"longPrefix" => "path",
"description" => "Colon (Unix) / Semicolon (Windows) separated list of paths to the document roots. The last one will be used for all remaining ones if fewer than the amount of domains is given.",
"required" => true,
],
"user" => [
"prefix" => "u",
"longPrefix" => "user",
"description" => "User running the web server.",
],
"bits" => [
"longPrefix" => "bits",
"description" => "Length of the private key in bit.",
"defaultValue" => 2048,
"castTo" => "int",
],
"challenge-concurrency" => [
"longPrefix" => "challenge-concurrency",
"description" => "Number of challenges to be solved concurrently.",
"defaultValue" => 10,
"castTo" => "int",
],
];
return null;
}
}

View File

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

View File

@@ -2,114 +2,128 @@
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\Registration;
use Kelunik\Acme\Crypto\RsaKeyGenerator;
use Kelunik\Acme\Protocol\Account;
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;
use function Kelunik\AcmeClient\normalizePath;
use function Kelunik\AcmeClient\resolveServer;
use function Kelunik\AcmeClient\serverToKeyname;
class Setup implements Command
{
public static function getDefinition(): array
{
$args = [
'server' => AcmeClient\getArgumentDescription('server'),
'storage' => AcmeClient\getArgumentDescription('storage'),
'email' => [
'longPrefix' => 'email',
'description' => 'E-mail for important issues, will be sent to the ACME server.',
'required' => true,
],
'agree-terms' => [
'longPrefix' => 'agree-terms',
'description' => 'Agree to terms of service of the configured ACME server.',
'defaultValue' => false,
'noValue' => true,
],
];
$configPath = AcmeClient\getConfigPath();
if ($configPath) {
$config = Yaml::parse(\file_get_contents($configPath));
if (isset($config['email']) && \is_string($config['email'])) {
$args['email']['required'] = false;
$args['email']['defaultValue'] = $config['email'];
}
}
return $args;
}
class Setup implements Command {
private $climate;
private $acmeFactory;
public function __construct(CLImate $climate, AcmeFactory $acmeFactory) {
public function __construct(CLImate $climate, AcmeFactory $acmeFactory)
{
$this->climate = $climate;
$this->acmeFactory = $acmeFactory;
}
public function execute(Manager $args) {
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 = resolveServer($args->get('server'));
$keyFile = serverToKeyname($server);
$path = "accounts/{$keyFile}.pem";
$bits = 4096;
$keyStore = new KeyStore(normalizePath($args->get('storage')));
$this->climate->br();
try {
$keyPair = yield $keyStore->get($path);
$this->climate->whisper(' Using existing private key ...');
} catch (KeyStoreException $e) {
$this->climate->whisper(' No private key found, generating new one ...');
$keyPair = (new RsaKeyGenerator($bits))->generateKey();
$keyPair = yield $keyStore->put($path, $keyPair);
$this->climate->whisper(" Generated new private key with {$bits} bits.");
}
$acme = $this->acmeFactory->build($server, $keyPair);
$this->climate->whisper(' Registering with ' . \substr($server, 8) . ' ...');
/** @var Account $account */
$account = yield $acme->register($email, $args->get('agree-terms'));
$contacts = \implode(', ', \array_map("strval", $account->getContacts()));
$this->climate->info(' Registration successful. Contacts: ' . $contacts);
$this->climate->br();
return 0;
});
}
public function 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): \Generator
{
$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) {
throw new AcmeException("Dns query for an MX record on '{$host}' failed for the following reason: " . $e->getMessage(), null, $e);
} catch (Dns\DnsException $e) {
throw new AcmeException(
"Dns query for an MX record on '{$host}' failed for the following reason: " . $e->getMessage(),
null,
$e
);
}
}
public static function getDefinition() {
$args = [
"server" => \Kelunik\AcmeClient\getArgumentDescription("server"),
"storage" => \Kelunik\AcmeClient\getArgumentDescription("storage"),
"email" => [
"longPrefix" => "email",
"description" => "E-mail for important issues, will be sent to the ACME server.",
"required" => true,
],
];
$configPath = \Kelunik\AcmeClient\getConfigPath();
if ($configPath) {
$config = Yaml::parse(file_get_contents($configPath));
if (isset($config["email"]) && is_string($config["email"])) {
$args["email"]["required"] = false;
$args["email"]["defaultValue"] = $config["email"];
}
}
return $args;
}
}
}

View File

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

View File

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

View File

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

View File

@@ -2,92 +2,82 @@
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\Dns\isValidName;
class CertificateStore {
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\read($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 (!isValidName($commonName)) {
throw new CertificateStoreException("Invalid common name: '{$commonName}'");
}
try {
$chain = \array_slice($certificates, 1);
$path = $this->root . '/' . $commonName;
yield File\createDirectoryRecursively($path, 0755);
yield File\write($path . '/cert.pem', $certificates[0]);
yield File\changePermissions($path . '/cert.pem', 0644);
yield File\write($path . '/fullchain.pem', \implode("\n", $certificates));
yield File\changePermissions($path . '/fullchain.pem', 0644);
yield File\write($path . '/chain.pem', \implode("\n", $chain));
yield File\changePermissions($path . '/chain.pem', 0644);
} catch (FilesystemException $e) {
throw new CertificateStoreException("Couldn't save certificates for '{$commonName}'", 0, $e);
}
});
}
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\listFiles($this->root . '/' . $name);
private function doDelete($name) {
Assert::string($name, "Name must be a string. Got: %s");
foreach ($files as $file) {
yield File\deleteFile($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\deleteDirectory($this->root . '/' . $name);
});
}
}

View File

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

View File

@@ -2,72 +2,58 @@
namespace Kelunik\AcmeClient\Stores;
use InvalidArgumentException;
use Webmozart\Assert\Assert;
use Amp\File;
use Amp\Promise;
use function Amp\call;
class ChallengeStore {
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);
yield File\createDirectoryRecursively($path, 0755);
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\changeOwner($this->docroot . '/.well-known', $userInfo['uid']);
yield File\changeOwner($this->docroot . '/.well-known/acme-challenge', $userInfo['uid']);
}
yield \Amp\File\put("{$path}/{$token}", $payload);
yield File\write("{$path}/{$token}", $payload);
if (isset($userInfo)) {
yield \Amp\File\chown("{$path}/{$token}", $userInfo["uid"], -1);
}
if ($userInfo !== null) {
yield File\changeOwner("{$path}/{$token}", $userInfo['uid']);
}
yield \Amp\File\chmod("{$path}/{$token}", 0644);
yield File\changePermissions("{$path}/{$token}", 0644);
});
}
public function delete($token) {
return \Amp\resolve($this->doDelete($token));
public function delete(string $token): Promise
{
return call(function () use ($token) {
$path = $this->docroot . "/.well-known/acme-challenge/{$token}";
if (yield File\exists($path)) {
yield File\deleteFile($path);
}
});
}
private function doDelete($token) {
Assert::string($token, "Token must be a string. Got: %s");
$path = $this->docroot . "/.well-known/acme-challenge/{$token}";
$realpath = realpath($path);
if ($realpath) {
yield \Amp\File\unlink($realpath);
}
}
}
}

View File

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

View File

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

View File

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

View File

@@ -2,12 +2,31 @@
namespace Kelunik\AcmeClient;
use Amp\Sync\LocalSemaphore;
use Amp\Sync\Lock;
use InvalidArgumentException;
use Kelunik\Acme\AcmeException;
use Phar;
use Symfony\Component\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Yaml;
use Webmozart\Assert\Assert;
use function Amp\call;
use function Amp\coroutine;
function concurrentMap(int $concurrency, array $values, callable $functor): array
{
$semaphore = new LocalSemaphore($concurrency);
return \array_map(coroutine(function ($value, $key) use ($semaphore, $functor) {
/** @var Lock $lock */
$lock = yield $semaphore->acquire();
try {
return yield call($functor, $value, $key);
} finally {
$lock->release();
}
}), $values, \array_keys($values));
}
/**
* Suggests a command based on similarity in a list of available commands.
@@ -15,20 +34,19 @@ use Webmozart\Assert\Assert;
* @param string $badCommand invalid command
* @param array $commands list of available commands
* @param int $suggestThreshold similarity threshold
*
* @return string suggestion or empty string if no command is similar enough
*/
function suggestCommand($badCommand, array $commands, $suggestThreshold = 70) {
Assert::string($badCommand, "Bad command must be a string. Got: %s");
Assert::integer($suggestThreshold, "Suggest threshold must be an integer. Got: %s");
function suggestCommand(string $badCommand, array $commands, int $suggestThreshold = 70): string
{
$badCommand = \strtolower($badCommand);
$badCommand = strtolower($badCommand);
$bestMatch = "";
$bestMatch = '';
$bestMatchPercentage = 0;
$byRefPercentage = 0;
foreach ($commands as $command) {
\similar_text($badCommand, strtolower($command), $byRefPercentage);
\similar_text($badCommand, \strtolower($command), $byRefPercentage);
if ($byRefPercentage > $bestMatchPercentage) {
$bestMatchPercentage = $byRefPercentage;
@@ -36,7 +54,7 @@ function suggestCommand($badCommand, array $commands, $suggestThreshold = 70) {
}
}
return $bestMatchPercentage >= $suggestThreshold ? $bestMatch : "";
return $bestMatchPercentage >= $suggestThreshold ? $bestMatch : '';
}
/**
@@ -44,46 +62,48 @@ 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-v02.api.letsencrypt.org/directory',
'letsencrypt:production' => 'https://acme-v02.api.letsencrypt.org/directory',
'letsencrypt:staging' => 'https://acme-staging-v02.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 +113,25 @@ 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), '/');
}
/**
@@ -116,24 +139,25 @@ 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"] : [];
function getConfigPath(): ?string
{
$paths = isPhar() ? [\substr(\dirname(Phar::running()), \strlen('phar://')) . '/acme-client.yml'] : [];
if (strtoupper(substr(PHP_OS, 0, 3)) !== 'WIN') {
if ($home = getenv("HOME")) {
$paths[] = $home . "/.acme-client.yml";
if (0 !== \stripos(PHP_OS, 'WIN')) {
if ($home = \getenv('HOME')) {
$paths[] = $home . '/.acme-client.yml';
}
$paths[] = "/etc/acme-client.yml";
$paths[] = '/etc/acme-client.yml';
}
do {
$path = array_shift($paths);
$path = \array_shift($paths);
if (file_exists($path)) {
if (\file_exists($path)) {
return $path;
}
} while (count($paths));
} while (\count($paths));
return null;
}
@@ -142,24 +166,26 @@ 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(string $argument): array
{
$config = [];
if ($configPath = getConfigPath()) {
$configContent = file_get_contents($configPath);
$configContent = \file_get_contents($configPath);
try {
$config = Yaml::parse($configContent);
if (isset($config["server"]) && !is_string($config["server"])) {
if (isset($config['server']) && !\is_string($config['server'])) {
throw new ConfigException("'server' set, but not a string.");
}
if (isset($config["storage"]) && !is_string($config["storage"])) {
if (isset($config['storage']) && !\is_string($config['storage'])) {
throw new ConfigException("'storage' set, but not a string.");
}
} catch (ParseException $e) {
@@ -168,41 +194,41 @@ function getArgumentDescription($argument) {
}
switch ($argument) {
case "server":
$argument = [
"prefix" => "s",
"longPrefix" => "server",
"description" => "ACME server to use for registration and issuance of certificates.",
"required" => true,
case 'server':
$desc = [
'prefix' => 's',
'longPrefix' => 'server',
'description' => 'ACME server to use for registration and issuance of certificates.',
'required' => true,
];
if (isset($config["server"])) {
$argument["required"] = false;
$argument["defaultValue"] = $config["server"];
if (isset($config['server'])) {
$desc['required'] = false;
$desc['defaultValue'] = $config['server'];
}
return $argument;
return $desc;
case "storage":
case 'storage':
$isPhar = isPhar();
$argument = [
"longPrefix" => "storage",
"description" => "Storage directory for account keys and certificates.",
"required" => $isPhar,
$desc = [
'longPrefix' => 'storage',
'description' => 'Storage directory for account keys and certificates.',
'required' => $isPhar,
];
if (!$isPhar) {
$argument["defaultValue"] = dirname(__DIR__) . "/data";
} else if (isset($config["storage"])) {
$argument["required"] = false;
$argument["defaultValue"] = $config["storage"];
$desc['defaultValue'] = \dirname(__DIR__) . '/data';
} elseif (isset($config['storage'])) {
$desc['required'] = false;
$desc['defaultValue'] = $config['storage'];
}
return $argument;
return $desc;
default:
throw new InvalidArgumentException("Unknown argument: " . $argument);
throw new InvalidArgumentException('Unknown argument: ' . $argument);
}
}
@@ -211,27 +237,28 @@ function getArgumentDescription($argument) {
*
* @return string binary callable, shortened based on PATH and CWD
*/
function getBinary() {
$binary = "bin/acme";
function getBinary(): string
{
$binary = 'bin/acme';
if (isPhar()) {
$binary = substr(Phar::running(true), strlen("phar://"));
$binary = \substr(Phar::running(), \strlen('phar://'));
$path = getenv("PATH");
$locations = explode(PATH_SEPARATOR, $path);
$path = \getenv('PATH');
$locations = \explode(PATH_SEPARATOR, $path);
$binaryPath = dirname($binary);
$binaryPath = \dirname($binary);
foreach ($locations as $location) {
if ($location === $binaryPath) {
return substr($binary, strlen($binaryPath) + 1);
return \substr($binary, \strlen($binaryPath) + 1);
}
}
$cwd = getcwd();
$cwd = \getcwd();
if ($cwd && strpos($binary, $cwd) === 0) {
$binary = "." . substr($binary, strlen($cwd));
if ($cwd && \strpos($binary, $cwd) === 0) {
$binary = '.' . \substr($binary, \strlen($cwd));
}
}
@@ -244,18 +271,20 @@ 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(string $text, int $max = 70, string $append = '…'): string
{
if (\strlen($text) <= $max) {
return $text;
}
$out = substr($text, 0, $max);
$out = \substr($text, 0, $max);
if (strpos($text, " ") === false) {
if (\strpos($text, ' ') === false) {
return $out . $append;
}
return preg_replace("/\\w+$/", "", $out) . $append;
}
return \preg_replace("/\\w+$/", '', $out) . $append;
}

View File

@@ -2,28 +2,44 @@
namespace Kelunik\AcmeClient;
class FunctionsTest extends \PHPUnit_Framework_TestCase {
public function testResolveServer() {
$this->assertSame("https://acme-v01.api.letsencrypt.org/directory", resolveServer("letsencrypt"));
$this->assertSame("https://acme-v01.api.letsencrypt.org/directory", resolveServer("letsencrypt:production"));
$this->assertSame("https://acme-staging.api.letsencrypt.org/directory", resolveServer("letsencrypt:staging"));
$this->assertSame("https://acme-v01.api.letsencrypt.org/directory", resolveServer("acme-v01.api.letsencrypt.org/directory"));
$this->assertSame("https://acme-v01.api.letsencrypt.org/directory", resolveServer("https://acme-v01.api.letsencrypt.org/directory"));
use PHPUnit\Framework\TestCase;
class FunctionsTest extends TestCase
{
public function testResolveServer(): void
{
$this->assertSame('https://acme-v02.api.letsencrypt.org/directory', resolveServer('letsencrypt'));
$this->assertSame('https://acme-v02.api.letsencrypt.org/directory', resolveServer('letsencrypt:production'));
$this->assertSame(
'https://acme-staging-v02.api.letsencrypt.org/directory',
resolveServer('letsencrypt:staging')
);
$this->assertSame(
'https://acme-v01.api.letsencrypt.org/directory',
resolveServer('acme-v01.api.letsencrypt.org/directory')
);
$this->assertSame(
'https://acme-v01.api.letsencrypt.org/directory',
resolveServer('https://acme-v01.api.letsencrypt.org/directory')
);
}
public function testSuggestCommand() {
$this->assertSame("acme", suggestCommand("acme!", ["acme"]));
$this->assertSame("", suggestCommand("issue", ["acme"]));
public function testSuggestCommand(): void
{
$this->assertSame('acme', suggestCommand('acme!', ['acme']));
$this->assertSame('', suggestCommand('issue', ['acme']));
}
public function testIsPhar() {
public function testIsPhar(): void
{
$this->assertFalse(isPhar());
}
public function testNormalizePath() {
$this->assertSame("/etc/foobar", normalizePath("/etc/foobar"));
$this->assertSame("/etc/foobar", normalizePath("/etc/foobar/"));
$this->assertSame("/etc/foobar", normalizePath("/etc/foobar/"));
$this->assertSame("C:/etc/foobar", normalizePath("C:\\etc\\foobar\\"));
public function testNormalizePath(): void
{
$this->assertSame('/etc/foobar', normalizePath('/etc/foobar'));
$this->assertSame('/etc/foobar', normalizePath('/etc/foobar/'));
$this->assertSame('/etc/foobar', normalizePath('/etc/foobar/'));
$this->assertSame('C:/etc/foobar', normalizePath("C:\\etc\\foobar\\"));
}
}
}