24 Commits

Author SHA1 Message Date
Niklas Keller
34909784d0 Update to actions/upload-artifact@v4 2025-08-01 20:35:59 +02:00
Niklas Keller
5c9165d681 Update dependencies
Most importantly update to kelunik/acme v1.0.1 to fix https://github.com/kelunik/acme/issues/41.
2025-08-01 20:32:05 +02:00
Niklas Keller
63979e4ec3 Fetch tags for built phar version info 2024-04-03 21:07:12 +02:00
Niklas Keller
b507ddec18 Fix code style 2024-04-03 20:59:31 +02:00
Niklas Keller
6da6baeb6d Add CI setup
This will also build the PHARs instead of building them locally.
2024-04-03 20:57:04 +02:00
Niklas Keller
e377d32e64 Upgrade dependencies 2024-04-03 20:48:59 +02:00
dependabot[bot]
7f7b6092b5 Bump amphp/http from 1.6.3 to 1.7.3 (#96)
Bumps [amphp/http](https://github.com/amphp/http) from 1.6.3 to 1.7.3.
- [Release notes](https://github.com/amphp/http/releases)
- [Commits](https://github.com/amphp/http/compare/v1.6.3...v1.7.3)

---
updated-dependencies:
- dependency-name: amphp/http
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-03 20:11:55 +02:00
Bob Weinand
130ae8468c Fix issuance of reordered domain names (#92) 2022-09-17 11:03:39 +02:00
Alexey Shirokov
ee538c53b2 Update register command (#94) 2022-09-17 08:49:59 +02:00
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
31 changed files with 4565 additions and 1792 deletions

View File

@@ -28,7 +28,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

81
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,81 @@
name: Continuous Integration
on:
- push
- pull_request
jobs:
tests:
strategy:
matrix:
include:
- operating-system: 'ubuntu-latest'
php-version: '8.1'
name: PHP ${{ matrix.php-version }} ${{ matrix.job-description }}
runs-on: ${{ matrix.operating-system }}
steps:
- name: Set git to use LF
run: |
git config --global core.autocrlf false
git config --global core.eol lf
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
ini-values: zend.assertions=1, assert.exception=1
env:
update: true
- name: Get Composer cache directory
id: composer-cache
run: echo "dir=$(composer config cache-dir)" >> $GITHUB_OUTPUT
shell: bash
- name: Cache dependencies
uses: actions/cache@v3
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: composer-${{ runner.os }}-${{ matrix.php-version }}-${{ hashFiles('**/composer.*') }}-${{ matrix.composer-flags }}
restore-keys: |
composer-${{ runner.os }}-${{ matrix.php-version }}-${{ hashFiles('**/composer.*') }}-
composer-${{ runner.os }}-${{ matrix.php-version }}-
composer-${{ runner.os }}-
composer-
- name: Install dependencies
uses: nick-invision/retry@v2
with:
timeout_minutes: 5
max_attempts: 5
retry_wait_seconds: 30
command: |
composer update --optimize-autoloader --no-interaction --no-progress ${{ matrix.composer-flags }}
composer info -D
- name: Run tests
run: vendor/bin/phpunit ${{ matrix.phpunit-flags }}
- name: Run style fixer
env:
PHP_CS_FIXER_IGNORE_ENV: 1
run: vendor/bin/php-cs-fixer --diff --dry-run -v fix
if: matrix.php-cs-fixer != 'none'
- name: Build phar
run: php -dphar.readonly=0 vendor/bin/phar-builder package
- name: Upload phar
uses: actions/upload-artifact@v4
with:
name: acme-client.phar
path: |
build/acme-client.phar

3
.gitignore vendored
View File

@@ -3,4 +3,5 @@
/info/
/vendor/
/config.test.yml
/.php_cs.cache
/.php_cs.cache
/.phpunit.result.cache

View File

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

View File

@@ -1,25 +0,0 @@
language: php
php:
- 7.0
- 7.1
- 7.2
- nightly
cache:
directories:
- vendor
install:
- phpenv config-rm xdebug.ini || true
- composer install
script:
- find -name "*.php" -not -path "./vendor/*" -print0 | xargs -n 1 -0 php -l
- phpdbg -qrr vendor/bin/phpunit --coverage-text --coverage-clover build/logs/clover.xml
- PHP_CS_FIXER_IGNORE_ENV=1 php vendor/bin/php-cs-fixer --diff --dry-run -v fix
after_script:
- curl -OL https://github.com/php-coveralls/php-coveralls/releases/download/v1.0.0/coveralls.phar
- chmod +x coveralls.phar
- ./coveralls.phar -v

View File

@@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2015-2017 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,10 +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
____ __________ ___ ___
@@ -51,7 +55,7 @@ $commands = [
'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";
@@ -81,8 +85,8 @@ if (!in_array(PHP_SAPI, ['cli', 'phpdbg'], true)) {
exit(1);
}
if (PHP_VERSION_ID < 70000) {
$climate->yellow("You're using an older version of PHP which is no longer supported by this client. Have a look at http://php.net/supported-versions.php and upgrade at least to PHP 7.0!");
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);
}
@@ -94,7 +98,7 @@ if (count($argv) === 1 || in_array($argv[1], ['-h', 'help', '--help'], true)) {
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'?");
@@ -105,7 +109,7 @@ if (!array_key_exists($argv[1], $commands)) {
exit(1);
}
/** @var \Kelunik\AcmeClient\Commands\Command $class */
/** @var string|Command $class */
$class = "Kelunik\\AcmeClient\\Commands\\" . ucfirst($argv[1]);
$definition = $class::getDefinition();
@@ -136,7 +140,7 @@ try {
$injector = new Injector;
$injector->share($climate);
$injector->share(new AcmeFactory);
$injector->share(new Amp\Artax\DefaultClient);
$injector->share(HttpClientBuilder::buildDefault());
$command = $injector->make($class);
$exitCode = 1;

View File

@@ -11,20 +11,22 @@
"tls"
],
"require": {
"php": ">=7",
"php": ">=7.2",
"ext-openssl": "*",
"amphp/process": "^0.2",
"amphp/parallel": "^0.2.5",
"kelunik/acme": "^0.5",
"amphp/process": "^1.1",
"amphp/parallel": "^1.4",
"kelunik/acme": "^1",
"kelunik/certificate": "^1",
"league/climate": "^3.2",
"rdlowrey/auryn": "^1.4.2",
"webmozart/assert": "^1.2",
"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": "^6",
"friendsofphp/php-cs-fixer": "^2.9",
"phpunit/phpunit": "^8 || ^9",
"amphp/php-cs-fixer-config": "dev-master",
"macfja/phar-builder": "^0.2.6"
},
"license": "MIT",
@@ -44,7 +46,7 @@
},
"config": {
"platform": {
"php": "7.0.0"
"php": "7.4.0"
}
},
"extra": {
@@ -52,7 +54,11 @@
"compression": "GZip",
"name": "acme-client.phar",
"output-dir": "build",
"include": ["info", "src", "vendor/kelunik/acme/res"],
"include": [
"info",
"src",
"vendor/kelunik/acme/res"
],
"include-dev": false,
"skip-shebang": false,
"entry-point": "bin/acme",

5291
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@ Please read the document about [basic usage](./usage.md) first.
## Register an Account
```
acme-client setup --email me@example.com
acme-client setup --agree-terms --email me@example.com
```
After a successful registration you're able to issue certificates.
@@ -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

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,12 +2,26 @@
namespace Kelunik\AcmeClient;
use Amp\Log\ConsoleFormatter;
use Amp\Log\StreamHandler;
use Kelunik\Acme\AcmeClient;
use Kelunik\Acme\AcmeService;
use Kelunik\Acme\Crypto\PrivateKey;
use Monolog\Logger;
use Monolog\Processor\PsrLogMessageProcessor;
use function Amp\ByteStream\getStderr;
class AcmeFactory {
public function build(string $directory, PrivateKey $keyPair): AcmeService {
return new AcmeService(new AcmeClient($directory, $keyPair));
class AcmeFactory
{
public function build(string $directory, PrivateKey $keyPair): AcmeService
{
$handler = new StreamHandler(getStderr());
$handler->setFormatter(new ConsoleFormatter(null, null, true, true));
$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,7 +2,6 @@
namespace Kelunik\AcmeClient\Commands;
use Amp\ByteStream\Message;
use Amp\File;
use Amp\File\FilesystemException;
use Amp\Process\Process;
@@ -14,32 +13,65 @@ 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): Promise {
public function execute(Manager $args): Promise
{
return call(function () use ($args) {
$configPath = $args->get('config');
try {
/** @var array $config */
$config = Yaml::parse(
yield File\get($configPath)
yield File\read($configPath)
);
} catch (FilesystemException $e) {
$this->climate->error("Config file ({$configPath}) not found.");
@@ -86,6 +118,13 @@ class Auto implements Command {
return self::EXIT_CONFIG_ERROR;
}
foreach ($config['certificates'] as $certificateConfig) {
if (isset($certificateConfig['rekey']) && !\is_bool($certificateConfig['rekey'])) {
$this->climate->error("Config file ({$configPath}) defines an invalid 'rekey' value.");
return self::EXIT_CONFIG_ERROR;
}
}
$concurrency = isset($config['challenge-concurrency']) ? (int) $config['challenge-concurrency'] : null;
$process = new Process([
@@ -105,8 +144,8 @@ class Auto implements Command {
if ($exit !== 0) {
$this->climate->error("Registration failed ({$exit})");
$this->climate->br()->out(yield new Message($process->getStdout()));
$this->climate->br()->error(yield new Message($process->getStderr()));
$this->climate->br()->out(yield buffer($process->getStdout()));
$this->climate->br()->error(yield buffer($process->getStderr()));
return self::EXIT_SETUP_ERROR;
}
@@ -140,7 +179,10 @@ class Auto implements Command {
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.');
$this->climate->info('Certificate for ' . \implode(
', ',
\array_keys($this->toDomainPathMap($certificate['paths']))
) . ' successfully renewed.');
}
}
}
@@ -148,7 +190,10 @@ class Auto implements Command {
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('Issuance for the following domains failed: ' . \implode(
', ',
\array_keys($this->toDomainPathMap($certificate['paths']))
));
$this->climate->error("Reason: {$error}");
}
@@ -175,12 +220,16 @@ class Auto implements Command {
* @throws AcmeException if something does wrong
* @throws \Throwable
*/
private function checkAndIssue(array $certificate, string $server, string $storage, int $concurrency = null): \Generator {
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);
$process = new Process([
$processArgs = [
PHP_BINARY,
$GLOBALS['argv'][0],
'check',
@@ -192,7 +241,13 @@ class Auto implements Command {
$commonName,
'--names',
\implode(',', $domains),
]);
];
if ($certificate['rekey'] ?? false) {
$processArgs[] = '--rekey';
}
$process = new Process($processArgs);
$process->start();
$exit = yield $process->join();
@@ -249,7 +304,8 @@ class Auto implements Command {
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) {
@@ -302,32 +358,4 @@ MESSAGE;
return $result;
}
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;
}
}

View File

@@ -10,15 +10,43 @@ use Kelunik\Certificate\Certificate;
use League\CLImate\Argument\Manager;
use League\CLImate\CLImate;
use function Amp\call;
use function Kelunik\AcmeClient\getArgumentDescription;
class Check implements Command
{
public static function getDefinition(): array
{
return [
'server' => getArgumentDescription('server'),
'storage' => getArgumentDescription('storage'),
'name' => [
'longPrefix' => 'name',
'description' => 'Common name of the certificate to check.',
'required' => true,
],
'ttl' => [
'longPrefix' => 'ttl',
'description' => 'Minimum valid time in days.',
'defaultValue' => 30,
'castTo' => 'int',
],
'names' => [
'longPrefix' => 'names',
'description' => 'Names that must be covered by the certificate identified based on the common name. Names have to be separated by commas.',
'required' => false,
],
];
}
class Check implements Command {
private $climate;
public function __construct(CLImate $climate) {
public function __construct(CLImate $climate)
{
$this->climate = $climate;
}
public function execute(Manager $args): Promise {
public function execute(Manager $args): Promise
{
return call(function () use ($args) {
$server = AcmeClient\resolveServer($args->get('server'));
$server = AcmeClient\serverToKeyname($server);
@@ -44,7 +72,10 @@ class Check implements Command {
$missingNames = \array_diff($names, $cert->getNames());
if ($missingNames) {
$this->climate->comment(' The following names are not covered: ' . \implode(', ', $missingNames))->br();
$this->climate->comment(' The following names are not covered: ' . \implode(
', ',
$missingNames
))->br();
return 1;
}
@@ -59,27 +90,4 @@ class Check implements Command {
return 1;
});
}
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,
],
'ttl' => [
'longPrefix' => 'ttl',
'description' => 'Minimum valid time in days.',
'defaultValue' => 30,
'castTo' => 'int',
],
'names' => [
'longPrefix' => 'names',
'description' => 'Names that must be covered by the certificate identified based on the common name. Names have to be separated by commas.',
'required' => false,
],
];
}
}

View File

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

View File

@@ -10,6 +10,9 @@ 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;
@@ -21,17 +24,66 @@ use League\CLImate\Argument\Manager;
use League\CLImate\CLImate;
use function Amp\call;
use function Kelunik\Acme\generateKeyAuthorization;
use function Kelunik\AcmeClient\getArgumentDescription;
use function Kelunik\AcmeClient\normalizePath;
use function Kelunik\AcmeClient\resolveServer;
use function Kelunik\AcmeClient\serverToKeyname;
class Issue implements Command
{
public static function getDefinition(): array
{
return [
'server' => getArgumentDescription('server'),
'storage' => getArgumentDescription('storage'),
'domains' => [
'prefix' => 'd',
'longPrefix' => 'domains',
'description' => 'Colon / Semicolon / Comma separated list of domains to request a certificate for.',
'required' => true,
],
'path' => [
'prefix' => 'p',
'longPrefix' => 'path',
'description' => 'Colon (Unix) / Semicolon (Windows) separated list of paths to the document roots. The last one will be used for all remaining ones if fewer than the amount of domains is given.',
'required' => true,
],
'user' => [
'prefix' => 'u',
'longPrefix' => 'user',
'description' => 'User running the web server.',
],
'bits' => [
'longPrefix' => 'bits',
'description' => 'Length of the private key in bit.',
'defaultValue' => 2048,
'castTo' => 'int',
],
'challenge-concurrency' => [
'longPrefix' => 'challenge-concurrency',
'description' => 'Number of challenges to be solved concurrently.',
'defaultValue' => 10,
'castTo' => 'int',
],
'rekey' => [
'longPrefix' => 'rekey',
'description' => 'Regenerate the key pair even if a key pair already exists.',
'noValue' => true,
],
];
}
class Issue implements Command {
private $climate;
private $acmeFactory;
public function __construct(CLImate $climate, AcmeFactory $acmeFactory) {
public function __construct(CLImate $climate, AcmeFactory $acmeFactory)
{
$this->climate = $climate;
$this->acmeFactory = $acmeFactory;
}
public function execute(Manager $args): Promise {
public function execute(Manager $args): Promise
{
return call(function () use ($args) {
$user = null;
@@ -68,10 +120,10 @@ class Issue implements Command {
);
}
$keyStore = new KeyStore(\Kelunik\AcmeClient\normalizePath($args->get('storage')));
$keyStore = new KeyStore(normalizePath($args->get('storage')));
$server = \Kelunik\AcmeClient\resolveServer($args->get('server'));
$keyFile = \Kelunik\AcmeClient\serverToKeyname($server);
$server = resolveServer($args->get('server'));
$keyFile = serverToKeyname($server);
try {
$key = yield $keyStore->get("accounts/{$keyFile}.pem");
@@ -84,10 +136,41 @@ class Issue implements Command {
$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 */
list($errors) = yield AcmeClient\concurrentMap($concurrency, $domains, function ($domain, $i) use ($acme, $key, $docRoots, $user) {
return $this->solveChallenge($acme, $key, $domain, $docRoots[$i], $user);
});
[$errors] = yield AcmeClient\concurrentMap(
$concurrency,
$order->getAuthorizationUrls(),
function ($authorizationUrl) 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,
$name,
$docRoots[$index],
$user
);
}
);
if ($errors) {
foreach ($errors as $error) {
@@ -97,14 +180,22 @@ class Issue implements Command {
throw new AcmeException('Issuance failed, not all challenges could be solved.');
}
$path = 'certs/' . $keyFile . '/' . \reset($domains) . '/key.pem';
yield $acme->pollForOrderReady($order->getUrl());
$keyPath = 'certs/' . $keyFile . '/' . \reset($domains) . '/key.pem';
$bits = $args->get('bits');
$regenerateKey = $args->get('rekey');
try {
$key = yield $keyStore->get($path);
$key = yield $keyStore->get($keyPath);
} catch (KeyStoreException $e) {
$regenerateKey = true;
}
if ($regenerateKey) {
$this->climate->whisper(' Generating new key pair ...');
$key = (new RsaKeyGenerator($bits))->generateKey();
$key = yield $keyStore->put($path, $key);
}
$this->climate->br();
@@ -112,11 +203,18 @@ class Issue implements Command {
$csr = yield (new OpensslCsrGenerator)->generateCsr($key, $domains);
$location = yield $acme->requestCertificate($csr);
$certificates = yield $acme->pollForCertificate($location);
yield $acme->finalizeOrder($order->getFinalizationUrl(), $csr);
yield $acme->pollForOrderValid($order->getUrl());
$path = AcmeClient\normalizePath($args->get('storage')) . '/certs/' . $keyFile;
/** @var Order $order */
$order = yield $acme->getOrder($order->getUrl());
$certificates = yield $acme->downloadCertificates($order->getCertificateUrl());
$path = normalizePath($args->get('storage')) . '/certs/' . $keyFile;
$certificateStore = new CertificateStore($path);
yield $keyStore->put($keyPath, $key);
yield $certificateStore->put($certificates);
$this->climate->info(' Successfully issued certificate.');
@@ -127,17 +225,20 @@ class Issue implements Command {
});
}
private function solveChallenge(AcmeService $acme, PrivateKey $key, string $domain, string $path, string $user = null): \Generator {
list($location, $challenges) = yield $acme->requestChallenges($domain);
$goodChallenges = $this->findSuitableCombination($challenges);
if (empty($goodChallenges)) {
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;
$token = $httpChallenge->getToken();
if (!\preg_match('#^[a-zA-Z0-9-_]+$#', $token)) {
throw new AcmeException('Protocol violation: Invalid Token!');
}
@@ -152,8 +253,8 @@ class Issue implements Command {
yield $challengeStore->put($token, $payload, $user);
yield (new Http01)->verifyChallenge($domain, $token, $payload);
yield $acme->answerChallenge($challenge->uri, $payload);
yield $acme->pollForChallenge($location);
yield $acme->finalizeChallenge($httpChallenge->getUrl());
yield $acme->pollForAuthorization($authorization->getUrl());
$this->climate->comment(" {$domain} is now authorized.");
} finally {
@@ -161,17 +262,17 @@ class Issue implements Command {
}
}
private function checkDnsRecords(array $domains): \Generator {
private function checkDnsRecords(array $domains): \Generator
{
$promises = AcmeClient\concurrentMap(10, $domains, function (string $domain): Promise {
return Dns\resolve($domain);
});
list($errors) = yield Promise\any($promises);
[$errors] = yield Promise\any($promises);
if ($errors) {
$failedDomains = \implode(', ', \array_keys($errors));
$reasons = \implode("\n\n", \array_map(function ($exception) {
/** @var \Throwable $exception */
$reasons = \implode("\n\n", \array_map(static function (\Throwable $exception) {
return \get_class($exception) . ': ' . $exception->getMessage();
}, $errors));
@@ -179,59 +280,16 @@ class Issue implements Command {
}
}
private function findSuitableCombination(\stdClass $response): array {
$challenges = $response->challenges ?? [];
$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, true)) {
unset($goodChallenges[$i]);
}
}
return $goodChallenges;
}
public static function getDefinition(): array {
return [
'server' => AcmeClient\getArgumentDescription('server'),
'storage' => AcmeClient\getArgumentDescription('storage'),
'domains' => [
'prefix' => 'd',
'longPrefix' => 'domains',
'description' => 'Colon / Semicolon / Comma separated list of domains to request a certificate for.',
'required' => true,
],
'path' => [
'prefix' => 'p',
'longPrefix' => 'path',
'description' => 'Colon (Unix) / Semicolon (Windows) separated list of paths to the document roots. The last one will be used for all remaining ones if fewer than the amount of domains is given.',
'required' => true,
],
'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

@@ -14,16 +14,32 @@ use League\CLImate\Argument\Manager;
use League\CLImate\CLImate;
use function Amp\call;
class Revoke implements Command {
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,
],
];
}
private $climate;
private $acmeFactory;
public function __construct(CLImate $climate, AcmeFactory $acmeFactory) {
public function __construct(CLImate $climate, AcmeFactory $acmeFactory)
{
$this->climate = $climate;
$this->acmeFactory = $acmeFactory;
}
public function execute(Manager $args): Promise {
public function execute(Manager $args): Promise
{
return call(function () use ($args) {
$keyStore = new KeyStore(AcmeClient\normalizePath($args->get('storage')));
@@ -39,7 +55,7 @@ class Revoke implements Command {
$path = AcmeClient\normalizePath($args->get('storage')) . '/certs/' . $keyFile . '/' . $args->get('name') . '/cert.pem';
try {
$pem = yield File\get($path);
$pem = yield File\read($path);
$cert = new Certificate($pem);
} catch (FilesystemException $e) {
throw new \RuntimeException("There's no such certificate (" . $path . ')');
@@ -63,16 +79,4 @@ class Revoke implements Command {
return 0;
});
}
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,
],
];
}
}

View File

@@ -5,11 +5,10 @@ namespace Kelunik\AcmeClient\Commands;
use Amp\Dns;
use Amp\Dns\NoRecordException;
use Amp\Dns\Record;
use Amp\Dns\ResolutionException;
use Amp\Promise;
use Kelunik\Acme\AcmeException;
use Kelunik\Acme\Crypto\RsaKeyGenerator;
use Kelunik\Acme\Registration;
use Kelunik\Acme\Protocol\Account;
use Kelunik\AcmeClient;
use Kelunik\AcmeClient\AcmeFactory;
use Kelunik\AcmeClient\Stores\KeyStore;
@@ -18,28 +17,66 @@ use League\CLImate\Argument\Manager;
use League\CLImate\CLImate;
use Symfony\Component\Yaml\Yaml;
use function Amp\call;
use function Kelunik\AcmeClient\normalizePath;
use function Kelunik\AcmeClient\resolveServer;
use function Kelunik\AcmeClient\serverToKeyname;
class Setup implements Command
{
public static function getDefinition(): array
{
$args = [
'server' => AcmeClient\getArgumentDescription('server'),
'storage' => AcmeClient\getArgumentDescription('storage'),
'email' => [
'longPrefix' => 'email',
'description' => 'E-mail for important issues, will be sent to the ACME server.',
'required' => true,
],
'agree-terms' => [
'longPrefix' => 'agree-terms',
'description' => 'Agree to terms of service of the configured ACME server.',
'defaultValue' => false,
'noValue' => true,
],
];
$configPath = AcmeClient\getConfigPath();
if ($configPath) {
$config = Yaml::parse(\file_get_contents($configPath));
if (isset($config['email']) && \is_string($config['email'])) {
$args['email']['required'] = false;
$args['email']['defaultValue'] = $config['email'];
}
}
return $args;
}
class Setup implements Command {
private $climate;
private $acmeFactory;
public function __construct(CLImate $climate, AcmeFactory $acmeFactory) {
public function __construct(CLImate $climate, AcmeFactory $acmeFactory)
{
$this->climate = $climate;
$this->acmeFactory = $acmeFactory;
}
public function execute(Manager $args): Promise {
public function execute(Manager $args): Promise
{
return call(function () use ($args) {
$email = $args->get('email');
yield from $this->checkEmail($email);
$server = AcmeClient\resolveServer($args->get('server'));
$keyFile = AcmeClient\serverToKeyname($server);
$server = resolveServer($args->get('server'));
$keyFile = serverToKeyname($server);
$path = "accounts/{$keyFile}.pem";
$bits = 4096;
$keyStore = new KeyStore(\Kelunik\AcmeClient\normalizePath($args->get('storage')));
$keyStore = new KeyStore(normalizePath($args->get('storage')));
$this->climate->br();
@@ -59,16 +96,18 @@ class Setup implements Command {
$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()));
/** @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;
});
}
private function checkEmail(string $email) {
private function checkEmail(string $email): \Generator
{
$host = \substr($email, \strrpos($email, '@') + 1);
if (!$host) {
@@ -79,33 +118,12 @@ class Setup implements Command {
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(): 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,
],
];
$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;
}
}

View File

@@ -12,20 +12,40 @@ use League\CLImate\Argument\Manager;
use League\CLImate\CLImate;
use function Amp\call;
use function Kelunik\AcmeClient\getArgumentDescription;
use function Kelunik\AcmeClient\normalizePath;
use function Kelunik\AcmeClient\resolveServer;
use function Kelunik\AcmeClient\serverToKeyname;
class Status
{
public static function getDefinition(): array
{
return [
'server' => getArgumentDescription('server'),
'storage' => getArgumentDescription('storage'),
'ttl' => [
'longPrefix' => 'ttl',
'description' => 'Minimum valid time in days, shows ⭮ if renewal is required.',
'defaultValue' => 30,
'castTo' => 'int',
],
];
}
class Status {
private $climate;
public function __construct(CLImate $climate) {
public function __construct(CLImate $climate)
{
$this->climate = $climate;
}
public function execute(Manager $args): Promise {
public function execute(Manager $args): Promise
{
return call(function () use ($args) {
$server = \Kelunik\AcmeClient\resolveServer($args->get('server'));
$keyName = \Kelunik\AcmeClient\serverToKeyname($server);
$server = resolveServer($args->get('server'));
$keyName = serverToKeyname($server);
$storage = \Kelunik\AcmeClient\normalizePath($args->get('storage'));
$storage = normalizePath($args->get('storage'));
try {
$keyStore = new KeyStore($storage);
@@ -44,15 +64,16 @@ class Status {
$certificateStore = new CertificateStore($storage . "/certs/{$keyName}");
/** @var array $domains */
$domains = yield File\scandir($storage . "/certs/{$keyName}");
$domains = yield File\listFiles($storage . "/certs/{$keyName}");
foreach ($domains as $domain) {
$pem = yield $certificateStore->get($domain);
$cert = new Certificate($pem);
$symbol = \time() > $cert->getValidTo() ? '<red> ✗ </red>' : '<green> ✓ </green>';
$validTo = $cert->getValidTo();
$symbol = \time() > $validTo ? '<red> ✗ </red>' : '<green> ✓ </green>';
if (\time() < $cert->getValidTo() && \time() + $args->get('ttl') * 24 * 60 * 60 > $cert->getValidTo()) {
if (\time() < $validTo && \time() + $args->get('ttl') * 24 * 60 * 60 > $validTo) {
$symbol = '<yellow> ⭮ </yellow>';
}
@@ -63,17 +84,4 @@ class Status {
}
});
}
public static function getDefinition(): array {
return [
'server' => getArgumentDescription('server'),
'storage' => getArgumentDescription('storage'),
'ttl' => [
'longPrefix' => 'ttl',
'description' => 'Minimum valid time in days, shows ⭮ if renewal is required.',
'defaultValue' => 30,
'castTo' => 'int',
],
];
}
}

View File

@@ -6,15 +6,30 @@ use Amp\Promise;
use Amp\Success;
use League\CLImate\Argument\Manager;
use League\CLImate\CLImate;
use function Kelunik\AcmeClient\ellipsis;
class Version implements Command
{
public static function getDefinition(): array
{
return [
'deps' => [
'longPrefix' => 'deps',
'description' => 'Show also the bundled dependency versions.',
'noValue' => true,
],
];
}
class Version implements Command {
private $climate;
public function __construct(CLImate $climate) {
public function __construct(CLImate $climate)
{
$this->climate = $climate;
}
public function execute(Manager $args): Promise {
public function execute(Manager $args): Promise
{
$version = $this->getVersion();
$buildTime = $this->readFileOr('info/build.time', \time());
@@ -41,13 +56,15 @@ class Version implements Command {
return new Success;
}
private function getDescription($package) {
return \Kelunik\AcmeClient\ellipsis($package->description ?? '');
private function getDescription($package): string
{
return ellipsis($package->description ?? '');
}
private function getVersion() {
private function getVersion(): string
{
if (\file_exists(__DIR__ . '/../../.git')) {
$version = `git describe --tags`;
$version = \shell_exec("git describe --tags");
} else {
$version = $this->readFileOr('info/build.version', '-unknown');
}
@@ -55,7 +72,8 @@ class Version implements Command {
return \substr(\trim($version), 1);
}
private function readFileOr($file, $default = '') {
private function readFileOr(string $file, $default = '')
{
if (\file_exists(__DIR__ . '/../../' . $file)) {
return \file_get_contents(__DIR__ . '/../../' . $file);
}
@@ -66,14 +84,4 @@ class Version implements Command {
return $default;
}
public static function getDefinition(): array {
return [
'deps' => [
'longPrefix' => 'deps',
'description' => 'Show also the bundled dependency versions.',
'noValue' => true,
],
];
}
}

View File

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

View File

@@ -7,26 +7,30 @@ use Amp\File\FilesystemException;
use Amp\Promise;
use Kelunik\Certificate\Certificate;
use function Amp\call;
use function Amp\Uri\isValidDnsName;
use function Amp\Dns\isValidName;
class CertificateStore {
class CertificateStore
{
private $root;
public function __construct(string $root) {
public function __construct(string $root)
{
$this->root = \rtrim(\str_replace("\\", '/', $root), '/');
}
public function get(string $name): Promise {
public function get(string $name): Promise
{
return call(function () use ($name) {
try {
return yield File\get($this->root . '/' . $name . '/cert.pem');
return yield File\read($this->root . '/' . $name . '/cert.pem');
} catch (FilesystemException $e) {
throw new CertificateStoreException('Failed to load certificate.', 0, $e);
}
});
}
public function put(array $certificates): Promise {
public function put(array $certificates): Promise
{
return call(function () use ($certificates) {
if (empty($certificates)) {
throw new \Error('Empty array not allowed');
@@ -39,7 +43,7 @@ class CertificateStore {
throw new CertificateStoreException("Certificate doesn't have a common name.");
}
if (!isValidDnsName($commonName)) {
if (!isValidName($commonName)) {
throw new CertificateStoreException("Invalid common name: '{$commonName}'");
}
@@ -47,38 +51,33 @@ class CertificateStore {
$chain = \array_slice($certificates, 1);
$path = $this->root . '/' . $commonName;
if (!yield File\isdir($path)) {
yield File\mkdir($path, 0755, true);
yield File\createDirectoryRecursively($path, 0755);
if (!yield File\isdir($path)) {
throw new FilesystemException("Couldn't create certificate directory: '{$path}'");
}
}
yield File\write($path . '/cert.pem', $certificates[0]);
yield File\changePermissions($path . '/cert.pem', 0644);
yield File\put($path . '/cert.pem', $certificates[0]);
yield File\chmod($path . '/cert.pem', 0644);
yield File\write($path . '/fullchain.pem', \implode("\n", $certificates));
yield File\changePermissions($path . '/fullchain.pem', 0644);
yield File\put($path . '/fullchain.pem', \implode("\n", $certificates));
yield File\chmod($path . '/fullchain.pem', 0644);
yield File\put($path . '/chain.pem', \implode("\n", $chain));
yield File\chmod($path . '/chain.pem', 0644);
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(string $name): Promise {
public function delete(string $name): Promise
{
return call(function () use ($name) {
/** @var array $files */
$files = yield File\scandir($this->root . '/' . $name);
$files = yield File\listFiles($this->root . '/' . $name);
foreach ($files as $file) {
yield File\unlink($this->root . '/' . $name . '/' . $file);
yield File\deleteFile($this->root . '/' . $name . '/' . $file);
}
yield File\rmdir($this->root . '/' . $name);
yield File\deleteDirectory($this->root . '/' . $name);
});
}
}

View File

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

View File

@@ -6,14 +6,17 @@ use Amp\File;
use Amp\Promise;
use function Amp\call;
class ChallengeStore {
class ChallengeStore
{
private $docroot;
public function __construct(string $docroot) {
public function __construct(string $docroot)
{
$this->docroot = \rtrim(\str_replace("\\", '/', $docroot), '/');
}
public function put(string $token, string $payload, string $user = null): Promise {
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;
@@ -22,39 +25,34 @@ class ChallengeStore {
throw new ChallengeStoreException("Document root doesn't exist: '{$this->docroot}'");
}
if (!yield File\isdir($path)) {
yield File\mkdir($path, 0755, true);
if (!yield File\isdir($path)) {
throw new ChallengeStoreException("Couldn't create key directory: '{$path}'");
}
}
yield File\createDirectoryRecursively($path, 0755);
if ($user && !$userInfo = \posix_getpwnam($user)) {
throw new ChallengeStoreException("Unknown user: '{$user}'");
}
if ($userInfo !== null) {
yield File\chown($this->docroot . '/.well-known', $userInfo['uid'], -1);
yield File\chown($this->docroot . '/.well-known/acme-challenge', $userInfo['uid'], -1);
yield File\changeOwner($this->docroot . '/.well-known', $userInfo['uid']);
yield File\changeOwner($this->docroot . '/.well-known/acme-challenge', $userInfo['uid']);
}
yield File\put("{$path}/{$token}", $payload);
yield File\write("{$path}/{$token}", $payload);
if ($userInfo !== null) {
yield File\chown("{$path}/{$token}", $userInfo['uid'], -1);
yield File\changeOwner("{$path}/{$token}", $userInfo['uid']);
}
yield File\chmod("{$path}/{$token}", 0644);
yield File\changePermissions("{$path}/{$token}", 0644);
});
}
public function delete(string $token): Promise {
public function delete(string $token): Promise
{
return call(function () use ($token) {
$path = $this->docroot . "/.well-known/acme-challenge/{$token}";
if (yield File\exists($path)) {
yield File\unlink($path);
yield File\deleteFile($path);
}
});
}

View File

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

View File

@@ -8,19 +8,22 @@ use Amp\Promise;
use Kelunik\Acme\Crypto\PrivateKey;
use function Amp\call;
class KeyStore {
class KeyStore
{
private $root;
public function __construct(string $root = '') {
public function __construct(string $root = '')
{
$this->root = \rtrim(\str_replace("\\", '/', $root), '/');
}
public function get(string $path): Promise {
public function get(string $path): Promise
{
return call(function () use ($path) {
$file = $this->root . '/' . $path;
try {
$privateKey = yield File\get($file);
$privateKey = yield File\read($file);
// Check key here to be valid, PrivateKey doesn't do that, we fail early here
$res = \openssl_pkey_get_private($privateKey);
@@ -36,23 +39,17 @@ class KeyStore {
});
}
public function put(string $path, PrivateKey $key): Promise {
public function put(string $path, PrivateKey $key): Promise
{
return call(function () use ($path, $key) {
$file = $this->root . '/' . $path;
try {
$dir = \dirname($file);
if (!yield File\isdir($dir)) {
yield File\mkdir($dir, 0755, true);
if (!yield File\isdir($dir)) {
throw new FilesystemException("Couldn't create key directory: '{$dir}'");
}
}
yield File\put($file, $key->toPem());
yield File\chmod($file, 0600);
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);
}

View File

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

View File

@@ -12,7 +12,8 @@ use Symfony\Component\Yaml\Yaml;
use function Amp\call;
use function Amp\coroutine;
function concurrentMap(int $concurrency, array $values, callable $functor): array {
function concurrentMap(int $concurrency, array $values, callable $functor): array
{
$semaphore = new LocalSemaphore($concurrency);
return \array_map(coroutine(function ($value, $key) use ($semaphore, $functor) {
@@ -36,7 +37,8 @@ function concurrentMap(int $concurrency, array $values, callable $functor): arra
*
* @return string suggestion or empty string if no command is similar enough
*/
function suggestCommand(string $badCommand, array $commands, int $suggestThreshold = 70): string {
function suggestCommand(string $badCommand, array $commands, int $suggestThreshold = 70): string
{
$badCommand = \strtolower($badCommand);
$bestMatch = '';
@@ -63,11 +65,12 @@ function suggestCommand(string $badCommand, array $commands, int $suggestThresho
*
* @return string resolved URI
*/
function resolveServer(string $uri): string {
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])) {
@@ -94,7 +97,8 @@ function resolveServer(string $uri): string {
*
* @return string identifier usable as file name
*/
function serverToKeyname(string $server): string {
function serverToKeyname(string $server): string
{
$server = \substr($server, \strpos($server, '://') + 3);
$keyFile = \str_replace('/', '.', $server);
@@ -109,7 +113,8 @@ function serverToKeyname(string $server): string {
*
* @return bool {@code true} if running as Phar, {@code false} otherwise
*/
function isPhar(): bool {
function isPhar(): bool
{
if (!\class_exists('Phar')) {
return false;
}
@@ -124,7 +129,8 @@ function isPhar(): bool {
*
* @return string normalized path
*/
function normalizePath(string $path): string {
function normalizePath(string $path): string
{
return \rtrim(\str_replace("\\", '/', $path), '/');
}
@@ -133,7 +139,8 @@ function normalizePath(string $path): string {
*
* @return string|null Resolves to the config path or null.
*/
function getConfigPath() {
function getConfigPath(): ?string
{
$paths = isPhar() ? [\substr(\dirname(Phar::running()), \strlen('phar://')) . '/acme-client.yml'] : [];
if (0 !== \stripos(PHP_OS, 'WIN')) {
@@ -164,7 +171,8 @@ function getConfigPath() {
* @throws AcmeException if the provided acme-client.yml file is invalid
* @throws ConfigException if the provided configuration file is invalid
*/
function getArgumentDescription($argument): array {
function getArgumentDescription(string $argument): array
{
$config = [];
if ($configPath = getConfigPath()) {
@@ -229,7 +237,8 @@ function getArgumentDescription($argument): array {
*
* @return string binary callable, shortened based on PATH and CWD
*/
function getBinary(): string {
function getBinary(): string
{
$binary = 'bin/acme';
if (isPhar()) {
@@ -265,7 +274,8 @@ function getBinary(): string {
*
* @return string shortened string
*/
function ellipsis($text, $max = 70, $append = '…'): string {
function ellipsis(string $text, int $max = 70, string $append = '…'): string
{
if (\strlen($text) <= $max) {
return $text;
}

View File

@@ -4,25 +4,39 @@ namespace Kelunik\AcmeClient;
use PHPUnit\Framework\TestCase;
class FunctionsTest extends TestCase {
public function testResolveServer() {
$this->assertSame('https://acme-v01.api.letsencrypt.org/directory', resolveServer('letsencrypt'));
$this->assertSame('https://acme-v01.api.letsencrypt.org/directory', resolveServer('letsencrypt:production'));
$this->assertSame('https://acme-staging.api.letsencrypt.org/directory', resolveServer('letsencrypt:staging'));
$this->assertSame('https://acme-v01.api.letsencrypt.org/directory', resolveServer('acme-v01.api.letsencrypt.org/directory'));
$this->assertSame('https://acme-v01.api.letsencrypt.org/directory', resolveServer('https://acme-v01.api.letsencrypt.org/directory'));
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() {
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() {
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/'));