4 Commits

Author SHA1 Message Date
Niklas Keller
9b34d667d0 Merge pull request #14 from ekohl/fixes
Permission fixes and rewrite to kelunik/acme 0.3.0-dev
2016-02-24 15:45:51 +01:00
Ewoud Kohl van Wijngaarden
8ed17841b8 Rewrite to kelunik/acme version 0.3.0-dev 2016-02-24 15:33:38 +01:00
Ewoud Kohl van Wijngaarden
1fcd437aaf Remove the requirement to run as root 2016-02-24 15:33:34 +01:00
Ewoud Kohl van Wijngaarden
0db38e9d95 Correct permissions on files 2016-02-23 16:20:11 +01:00
32 changed files with 572 additions and 8996 deletions

View File

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

View File

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

6
.gitignore vendored
View File

@@ -1,7 +1,3 @@
/build/
/data/
/info/
/vendor/
/config.test.yml
/.php_cs.cache
/.phpunit.result.cache
/composer.lock

View File

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

View File

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

260
README.md
View File

@@ -1,257 +1,55 @@
![`kelunik/acme-client`](./res/logo.png)
# acme
`kelunik/acme-client` is a command-line ACME client implemented in PHP, enabling the issuance and renewal of certificates via the ACME protocol used by [Let's Encrypt](https://letsencrypt.org). It supports PHP 8.1+ with OpenSSL and runs on Unix-like systems and Windows.
![unstable](https://img.shields.io/badge/api-unstable-orange.svg?style=flat-square)
![MIT license](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)
`kelunik/acme-client` is a standalone ACME client written in PHP.
It's an alternative for the [official client](https://github.com/letsencrypt/letsencrypt) which is written in python.
> **Warning**: This software is under heavy development. Use at your own risk.
## Installation
### Requirements
* PHP 8.1+ with OpenSSL
* Unix-like system or Windows
### Installation using PHAR
This is the preferred installation method for usage on a production system. You can download `acme-client.phar` in the [release section](https://github.com/kelunik/acme-client/releases).
#### Instructions
```bash
# Go to https://github.com/kelunik/acme-client/releases/latest
# Download the latest release archive.
# Make it executable.
chmod +x acme-client.phar
# Run it.
./acme-client.phar
# Or install it globally.
mv ./acme-client.phar /usr/local/bin/acme-client
acme-client
```
If you want to update, just replace the old `.phar` with a new one.
All commands require a `--storage` argument when using the PHAR. That's the path where your keys and certificates will be stored.
On Unix you could use something like `--storage /etc/acme`.
You can add a file named `acme-client.yml` next to the `.phar` with the two keys `storage` and `server`.
These values will be used as default if you don't specify them, but you can still use another server by explicitly adding it as argument.
```yml
# Sample YAML configuration.
# Storage directory for certificates and keys.
storage: /etc/acme
# Server to use. Available shortcuts: letsencrypt, letsencrypt:staging
# You can also use full URLs to the directory resource of an ACME server
server: letsencrypt
```
### Installation using Composer
If you plan to actively develop this client, you don't want the PHAR but install the dependencies using [Composer](https://getcomposer.org/).
#### Instructions
```bash
# Clone repository
git clone https://github.com/kelunik/acme-client && cd acme-client
# Install dependencies
git clone https://github.com/kelunik/acme-client
cd acme-client
composer install
```
You can use `./bin/acme` as script instead of the PHAR. Please note, that all data will be stored in `./data` as long as you don't provide the `--storage` argument.
## Usage
The client stores your account keys, domain keys and certificates in a single directory. If you're using the PHAR,
you usually configure the storage in the configuration file. If you're using it with Composer, all data is stored in `./data`.
> **Note**: This client stores all data in `./data`, be sure to backup this folder regularly.
> It contains your account keys, domain keys and certificates.
**Be sure to backup that directory regularly.**
Before you can issue certificates, you have to register an account first and read and understand the terms of service of the ACME CA you're using.
For Let's Encrypt there's a [subscriber agreement](https://letsencrypt.org/repository/) you have to accept.
Before you can issue certificates, you have to register an account. You have to read and understand the terms of service
of the certificate authority you're using. For the Let's Encrypt certificate authority, there's a
[subscriber agreement](https://letsencrypt.org/repository/) you have to accept.
By using this client you agree to any agreement and any further updates by continued usage. You're responsible to react
to updates and stop the automation if you no longer agree with the terms of service.
These usage instructions assume you have installed the client globally as a PHAR. If you are using the PHAR,
but don't have it globally, replace `acme-client` with the location to your PHAR or add that path to your `$PATH` variable.
### Configuration
The client can be configured using a (global) configuration file. The client takes the first available of
`./acme-client.yml` (if running as PHAR), `$HOME/.acme-client.yml`, `/etc/acme-client.yml` (if not on Windows).
The configuration file has the following format:
```yml
# 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 $path/.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
```
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
determine the certificates to request. It will store certificates in the configured storage in a sub directory called `./certs`.
If everything has been successful, you'll see a message for each issued certificate. If nothing has to be renewed,
the script will be quiet to be cron friendly. If an error occurs, the script will dump all available information.
You should execute `acme-client auto` as a daily cron. It's recommended to setup e-mail notifications for all output of
that script.
Create a new script, e.g. in `/usr/local/bin/acme-renew`. The `PATH` might need to be modified to suit your system.
```bash
#!/usr/bin/env bash
export PATH='/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
acme-client auto
RC=$?
if [ $RC = 4 ] || [ $RC = 5 ]; then
service nginx reload
fi
```
```sh
# Cron Job Configuration
0 0 * * * /usr/local/bin/acme-renew
```
| Exit Code | Description |
|-----------|-------------|
| 0 | Nothing to do, all certificates still valid. |
| 1 | Config file invalid. |
| 2 | Issue during account setup. |
| 3 | Error during issuance. |
| 4 | Error during issuance, but some certificates could be renewed. |
| 5 | Everything fine, new certificates have been issued. |
Exit codes `4` and `5` usually need a server reload, to reload the new certificates. It's already handled in the recommended
cron setup.
If you want a more fine grained control or revoke certificates, you can have a look at the [advanced usage](./advanced-usage.md) document. The client allows to handle setup / issuance / revocation and other commands
separately from `acme-client auto`.
## Advanced Usage
Most users should use the `auto` command described above.
### Register an Account
By using this client you agree to any agreement and any further updates by continued usage.
You're responsible to react to updates and stop the automation if you no longer agree with the terms of service.
```
acme-client setup --agree-terms --email me@example.com
sudo bin/acme register \
--server acme-v01.api.letsencrypt.org/directory \
--email me@example.com
```
After a successful registration you're able to issue certificates.
This client assumes you have a HTTP server setup and running.
You must have a document root setup in order to use this client.
### Issue a Certificate
```
acme-client issue -d example.com:www.example.com -p /var/www/example.com
sudo bin/acme issue \
--server acme-v01.api.letsencrypt.org/directory \
--domains example.com,www.example.com \
--path /var/www/example.com
```
You can separate multiple domains (`-d`) with `,`, `:` or `;`. You can separate multiple document roots (`-p`) with your system's path separator:
* Colon (`:`) for Unix
* Semicolon (`;`) for Windows
For renewal, just run this command again.
If you specify less paths than domains, the last one will be used for the remaining domains.
Please note that Let's Encrypt has rate limits. Currently it's five certificates per domain per seven days. If you combine multiple subdomains in a single certificate, they count as just one certificate. If you just want to test things out, you can use their staging server, which has way higher rate limits by appending `--server letsencrypt:staging`.
### Revoke a Certificate
To revoke a certificate, you need a valid account key, just like for issuance.
To revoke a certificate, you need a valid account key currently, just like for issuance.
```
acme-client revoke --name example.com
```
`--name` is the common name of the certificate that you want to revoke.
### Renew a Certificate
For renewal, there's the `acme-client check` subcommand.
It exists with a non-zero exit code, if the certificate is going to expire soon.
Default check time is 30 days, but you can use `--ttl` to customize it.
You may use this as daily cron:
```
acme-client check --name example.com || acme-client issue ...
```
You can also use a more advanced script to automatically reload the server as well. For this example we assume you're using Nginx.
Something similar should work for Apache. But usually you shouldn't need any script, see [basic usage](./usage.md).
```bash
#!/usr/bin/env bash
acme-client check --name example.com --ttl 30
if [ $? -eq 1 ]; then
acme-client issue -d example.com:www.example.com -p /var/www
if [ $? -eq 0 ]; then
nginx -t -q
if [ $? -eq 0 ]; then
nginx -s reload
fi
fi
fi
```
sudo bin/acme revoke \
--server acme-v01.api.letsencrypt.org/directory \
--cert data/live/example.com/cert.pem
```

185
bin/acme
View File

@@ -1,176 +1,101 @@
#!/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 Bramus\Monolog\Formatter\ColoredLineFormatter;
use Kelunik\AcmeClient\LoggerColorScheme;
use League\CLImate\CLImate;
use function Kelunik\AcmeClient\getBinary;
use function Kelunik\AcmeClient\suggestCommand;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
$logo = <<<LOGO
____ __________ ___ ___
/ __ `/ ___/ __ `__ \/ _ \
/ /_/ / /__/ / / / / / __/
\__,_/\___/_/ /_/ /_/\___/
LOGO;
if (!file_exists(__DIR__ . '/../vendor/autoload.php')) {
echo $logo;
echo <<<HELP
You need to install the composer dependencies.
composer install --no-dev
HELP;
exit(-1);
}
if (!function_exists('openssl_pkey_get_private')) {
echo $logo;
echo <<<HELP
You need to enable OpenSSL in your php.ini
HELP;
exit(-2);
}
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.',
];
$binary = getBinary();
$help = implode(PHP_EOL, array_map(function ($command) use ($commands) {
$help = " <green>{$command}</green>\n";
$help .= " └─ {$commands[$command]}\n";
return $help;
}, array_keys($commands)));
require __DIR__ . "/../vendor/autoload.php";
$help = <<<EOT
<yellow>Usage:</yellow>
bin/acme [command] [--args]
____ __________ ___ ___
/ __ `/ ___/ __ `__ \/ _ \
/ /_/ / /__/ / / / / / __/
\__,_/\___/_/ /_/ /_/\___/
<yellow>Options:</yellow>
<green>-h, --help</green>
└─ Print this help information.
Usage: bin/acme command --args
Available Commands:
bin/acme register
bin/acme issue
bin/acme revoke
Get more help by appending --help to specific commands.
<yellow>Available commands:</yellow>
{$help}
Get more help by appending <yellow>--help</yellow> to specific commands.
EOT;
$climate = new CLImate;
$commands = [
"issue" => "Kelunik\\AcmeClient\\Commands\\Issue",
"register" => "Kelunik\\AcmeClient\\Commands\\Register",
"revoke" => "Kelunik\\AcmeClient\\Commands\\Revoke",
];
if (!in_array(PHP_SAPI, ['cli', 'phpdbg'], true)) {
$climate->error('Please run this script on the command line!');
$climate = new CLImate;
$injector = new Injector;
if (!isset($argv)) {
$climate->error("\$argv is not defined");
exit(1);
}
if (PHP_VERSION_ID < 70400) {
$climate->yellow("You're using an older version of PHP which is no longer supported by this client. Have a look at https://php.net/supported-versions.php and upgrade at least to PHP 7.4!");
$climate->br(2);
}
if (count($argv) === 1 || in_array($argv[1], ['-h', 'help', '--help'], true)) {
$climate->out($logo . $help);
if (count($argv) === 1 || $argv[1] === "-h" || $argv[1] === "--help" || $argv[1] === "help") {
print $help;
exit(0);
}
if (!array_key_exists($argv[1], $commands)) {
$climate->error("Unknown command '{$argv[1]}'. Use --help for a list of available commands.");
$suggestion = suggestCommand($argv[1], array_keys($commands));
if ($suggestion) {
$climate->br()->out(" Did you mean '$suggestion'?");
}
$climate->br();
$climate->error("Unknown command: '{$argv[1]}'");
exit(1);
}
/** @var string|Command $class */
$class = "Kelunik\\AcmeClient\\Commands\\" . ucfirst($argv[1]);
$definition = $class::getDefinition();
try {
$args = $argv;
unset($args[1]);
$climate->arguments->add($definition);
if (count($argv) === 3 && in_array($argv[2], ['-h', '--help'], true)) {
$climate->usage(["{$binary} {$argv[1]}"]);
$climate->br();
$climate->arguments->add($commands[$argv[1]]::getDefinition());
$climate->arguments->parse();
} catch (Exception $e) {
if ($climate->arguments->defined("help")) {
print $help;
exit(0);
} else {
$climate->error($e->getMessage());
exit(1);
}
$climate->arguments->parse(array_values($args));
} catch (Exception $e) {
$climate->usage(["{$binary} {$argv[1]}"]);
$climate->br();
$climate->error($e->getMessage());
$climate->br();
exit(1);
}
$injector = new Injector;
$injector->share($climate);
$injector->share(new AcmeFactory);
$injector->share(HttpClientBuilder::buildDefault());
$handler = new StreamHandler("php://stdout", Logger::DEBUG);
$handler->setFormatter(new ColoredLineFormatter(new LoggerColorScheme, null, null, true, true));
$command = $injector->make($class);
$exitCode = 1;
$logger = new Logger("ACME");
$logger->pushHandler($handler);
Loop::run(function () use ($command, $climate, &$exitCode) {
$handler = function ($e) use ($climate) {
$injector->alias("Psr\\Log\\LoggerInterface", Logger::class);
$injector->share($logger);
$command = $injector->make($commands[$argv[1]]);
Amp\run(function () use ($command, $climate, $logger) {
try {
yield $command->execute($climate->arguments);
} catch (Throwable $e) {
$error = (string) $e;
$lines = explode("\n", $error);
$lines = array_filter($lines, function ($line) {
return $line !== '' && $line[0] !== '#' && $line !== 'Stack trace:';
return strlen($line) && $line[0] !== "#" && $line !== "Stack trace:";
});
foreach ($lines as $line) {
$climate->error($line)->br();
$logger->error($line);
}
exit(1);
};
try {
$exitCode = yield $command->execute($climate->arguments);
if ($exitCode === null) {
$exitCode = 0;
}
} catch (Throwable $e) {
$handler($e);
}
Loop::stop();
Amp\stop();
});
exit($exitCode);

View File

@@ -1,33 +1,17 @@
{
"name": "kelunik/acme-client",
"description": "Let's Encrypt / ACME client written in PHP for the CLI.",
"keywords": [
"ACME",
"letsencrypt",
"certificate",
"https",
"encryption",
"ssl",
"tls"
],
"description": "Standalone PHP ACME client.",
"require": {
"php": ">=7.2",
"php": ">=7.0.0",
"ext-posix": "*",
"ext-openssl": "*",
"amphp/process": "^1.1",
"amphp/parallel": "^1.4",
"kelunik/acme": "^1",
"bramus/monolog-colored-line-formatter": "^2",
"kelunik/acme": "^0.3",
"kelunik/certificate": "^1",
"league/climate": "^3.4",
"rdlowrey/auryn": "^1.4.4",
"webmozart/assert": "^1.3",
"symfony/yaml": "^5.3.2",
"amphp/log": "^1",
"ext-posix": "*"
},
"require-dev": {
"phpunit/phpunit": "^8 || ^9",
"amphp/php-cs-fixer-config": "dev-master",
"macfja/phar-builder": "^0.2.6"
"league/climate": "^3",
"monolog/monolog": "^1.17",
"psr/log": "^1",
"rdlowrey/auryn": "^1"
},
"license": "MIT",
"authors": [
@@ -36,45 +20,11 @@
"email": "me@kelunik.com"
}
],
"minimum-stability": "dev",
"prefer-stable": true,
"autoload": {
"psr-4": {
"Kelunik\\AcmeClient\\": "src"
},
"files": [
"src/functions.php"
]
},
"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"
],
"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",
"rm -rf vendor/amphp/file/travis",
"rm -rf vendor/amphp/parallel/travis"
],
"command.package.end": [
"rm -rf info",
"chmod +x build/acme-client.phar"
]
}
}
}
}

6704
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,29 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit backupGlobals="false"
backupStaticAttributes="false"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
processIsolation="false"
stopOnFailure="false"
syntaxCheck="false"
bootstrap="./vendor/autoload.php">
<php>
<ini name="memory_limit" value="1G"/>
<ini name="error_reporting" value="-1"/>
</php>
<testsuites>
<testsuite name="Tests">
<directory>./test</directory>
</testsuite>
</testsuites>
<filter>
<whitelist>
<directory>./src/</directory>
</whitelist>
</filter>
</phpunit>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -1,27 +0,0 @@
<?php
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
{
$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

@@ -1,361 +0,0 @@
<?php
namespace Kelunik\AcmeClient\Commands;
use Amp\File;
use Amp\File\FilesystemException;
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
{
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;
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)
{
$this->climate = $climate;
}
public function execute(Manager $args): Promise
{
return call(function () use ($args) {
$configPath = $args->get('config');
try {
/** @var array $config */
$config = Yaml::parse(
yield File\read($configPath)
);
} catch (FilesystemException $e) {
$this->climate->error("Config file ({$configPath}) not found.");
return self::EXIT_CONFIG_ERROR;
} catch (ParseException $e) {
$this->climate->error("Config file ({$configPath}) had an invalid format and couldn't be parsed.");
return self::EXIT_CONFIG_ERROR;
}
if ($args->defined('server')) {
$config['server'] = $args->get('server');
} elseif (!isset($config['server']) && $args->exists('server')) {
$config['server'] = $args->get('server');
}
if ($args->defined('storage')) {
$config['storage'] = $args->get('storage');
} elseif (!isset($config['storage']) && $args->exists('storage')) {
$config['storage'] = $args->get('storage');
}
if (!isset($config['server'])) {
$this->climate->error("Config file ({$configPath}) didn't have a 'server' set nor was it passed as command line argument.");
return self::EXIT_CONFIG_ERROR;
}
if (!isset($config['storage'])) {
$this->climate->error("Config file ({$configPath}) didn't have a 'storage' set nor was it passed as command line argument.");
return self::EXIT_CONFIG_ERROR;
}
if (!isset($config['email'])) {
$this->climate->error("Config file ({$configPath}) didn't have a 'email' set.");
return self::EXIT_CONFIG_ERROR;
}
if (!isset($config['certificates']) || !\is_array($config['certificates'])) {
$this->climate->error("Config file ({$configPath}) didn't have a 'certificates' section that's an array.");
return self::EXIT_CONFIG_ERROR;
}
if (isset($config['challenge-concurrency']) && !\is_numeric($config['challenge-concurrency'])) {
$this->climate->error("Config file ({$configPath}) defines an invalid 'challenge-concurrency' value.");
return self::EXIT_CONFIG_ERROR;
}
foreach ($config['certificates'] as $certificateConfig) {
if (isset($certificateConfig['rekey']) && !\is_bool($certificateConfig['rekey'])) {
$this->climate->error("Config file ({$configPath}) defines an invalid 'rekey' value.");
return self::EXIT_CONFIG_ERROR;
}
}
$concurrency = isset($config['challenge-concurrency']) ? (int) $config['challenge-concurrency'] : null;
$process = new Process([
PHP_BINARY,
$GLOBALS['argv'][0],
'setup',
'--server',
$config['server'],
'--storage',
$config['storage'],
'--email',
$config['email'],
]);
$process->start();
$exit = yield $process->join();
if ($exit !== 0) {
$this->climate->error("Registration failed ({$exit})");
$this->climate->br()->out(yield buffer($process->getStdout()));
$this->climate->br()->error(yield buffer($process->getStderr()));
return self::EXIT_SETUP_ERROR;
}
$errors = [];
$values = [];
foreach ($config['certificates'] as $i => $certificate) {
try {
$exit = yield call(function () use ($certificate, $config, $concurrency) {
return $this->checkAndIssue($certificate, $config['server'], $config['storage'], $concurrency);
});
$values[$i] = $exit;
} catch (\Exception $e) {
$errors[$i] = $e;
}
}
$status = [
'no_change' => \count(\array_filter($values, function ($value) {
return $value === self::STATUS_NO_CHANGE;
})),
'renewed' => \count(\array_filter($values, function ($value) {
return $value === self::STATUS_RENEWED;
})),
'failure' => \count($errors),
];
if ($status['renewed'] > 0) {
foreach ($values as $i => $value) {
if ($value === self::STATUS_RENEWED) {
$certificate = $config['certificates'][$i];
$this->climate->info('Certificate for ' . \implode(
', ',
\array_keys($this->toDomainPathMap($certificate['paths']))
) . ' successfully renewed.');
}
}
}
if ($status['failure'] > 0) {
foreach ($errors as $i => $error) {
$certificate = $config['certificates'][$i];
$this->climate->error('Issuance for the following domains failed: ' . \implode(
', ',
\array_keys($this->toDomainPathMap($certificate['paths']))
));
$this->climate->error("Reason: {$error}");
}
$exitCode = $status['renewed'] > 0
? self::EXIT_ISSUANCE_PARTIAL
: self::EXIT_ISSUANCE_ERROR;
return $exitCode;
}
if ($status['renewed'] > 0) {
return self::EXIT_ISSUANCE_OK;
}
});
}
/**
* @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,
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',
$server,
'--storage',
$storage,
'--name',
$commonName,
'--names',
\implode(',', $domains),
];
if ($certificate['rekey'] ?? false) {
$processArgs[] = '--rekey';
}
$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',
$server,
'--storage',
$storage,
'--domains',
\implode(',', $domains),
'--path',
\implode(PATH_SEPARATOR, \array_values($domainPathMap)),
];
if (isset($certificate['user'])) {
$args[] = '--user';
$args[] = $certificate['user'];
}
if (isset($certificate['bits'])) {
$args[] = '--bits';
$args[] = $certificate['bits'];
}
if ($concurrency) {
$args[] = '--challenge-concurrency';
$args[] = $concurrency;
}
$process = new Process($args);
$process->start();
$exit = yield $process->join();
if ($exit !== 0) {
// TODO: Print STDOUT and STDERR to file
throw new AcmeException("Unexpected exit code ({$exit}) for '{$process->getCommand()}'.");
}
return self::STATUS_RENEWED;
}
// TODO: Print STDOUT and STDERR to file
throw new AcmeException("Unexpected exit code ({$exit}) for '{$process->getCommand()}'.");
}
private function toDomainPathMap(array $paths)
{
$result = [];
foreach ($paths as $path => $domains) {
if (\is_numeric($path)) {
$message = <<<MESSAGE
Your configuration has the wrong format. Received a numeric value as path name.
This is most probably due to your "paths" value not being a map but a list instead.
If your configuration looks like this:
certificates:
- paths:
- /www/a: a.example.org
- /www/b: b.example.org
Rewrite it to the following format for a single certificate:
certificates:
- paths:
/www/a: a.example.org
/www/b: b.example.org
Rewrite it to the following format for two separate certificates:
certificates:
- paths:
/www/a: a.example.org
- paths:
/www/b: b.example.org
Documentation is available at https://github.com/kelunik/acme-client/blob/master/doc/usage.md#configuration
If this doesn't solve your issue, please reply to the following issue: https://github.com/kelunik/acme-client/issues/30
MESSAGE;
throw new ConfigException($message);
}
$domains = (array) $domains;
foreach ($domains as $domain) {
if (isset($result[$domain])) {
throw new ConfigException("Duplicate domain: {$domain}");
}
$result[$domain] = $path;
}
}
return $result;
}
}

View File

@@ -1,93 +0,0 @@
<?php
namespace Kelunik\AcmeClient\Commands;
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
{
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,
],
];
}
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

@@ -5,9 +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

@@ -2,294 +2,257 @@
namespace Kelunik\AcmeClient\Commands;
use Amp\Dns;
use Amp\Dns as dns;
use Amp\Dns\Record;
use Amp\Promise;
use Generator;
use Kelunik\Acme\AcmeClient;
use Kelunik\Acme\AcmeException;
use Kelunik\Acme\AcmeService;
use Kelunik\Acme\Crypto\Backend\OpensslBackend;
use Kelunik\Acme\Crypto\PrivateKey;
use Kelunik\Acme\Crypto\RsaKeyGenerator;
use Kelunik\Acme\Csr\OpensslCsrGenerator;
use Kelunik\Acme\Protocol\Authorization;
use Kelunik\Acme\Protocol\Challenge;
use Kelunik\Acme\Protocol\Order;
use Kelunik\Acme\Verifiers\Http01;
use Kelunik\AcmeClient;
use Kelunik\AcmeClient\AcmeFactory;
use Kelunik\AcmeClient\Stores\CertificateStore;
use Kelunik\AcmeClient\Stores\ChallengeStore;
use Kelunik\AcmeClient\Stores\KeyStore;
use Kelunik\AcmeClient\Stores\KeyStoreException;
use Kelunik\Acme\KeyPair;
use Kelunik\Acme\OpenSSLKeyGenerator;
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;
use Psr\Log\LoggerInterface;
use stdClass;
use Throwable;
use function Amp\all;
use function Amp\any;
use function Amp\resolve;
class Issue implements Command
{
public static function getDefinition(): array
{
class Issue implements Command {
private $logger;
public function __construct(LoggerInterface $logger) {
$this->logger = $logger;
}
public function execute(Manager $args): Promise {
return resolve($this->doExecute($args));
}
private function doExecute(Manager $args): Generator {
if (posix_geteuid() !== 0) {
$processUser = posix_getpwuid(posix_geteuid());
$currentUsername = $processUser['name'];
$user = $args->get("user") ?? $currentUsername;
if ($currentUsername !== $user) {
throw new AcmeException("Running this script with --user only works as root");
}
} else {
$user = $args->get("user") ?? "www-data";
}
$server = $args->get("server");
$protocol = substr($server, 0, strpos("://", $server));
if (!$protocol || $protocol === $server) {
$server = "https://" . $server;
} elseif ($protocol !== "https") {
throw new \InvalidArgumentException("Invalid server protocol, only HTTPS supported");
}
$domains = $args->get("domains");
$domains = array_map("trim", explode(",", $domains));
yield from $this->checkDnsRecords($domains);
$keyPair = $this->checkRegistration($args);
$acme = new AcmeService(new AcmeClient($server, $keyPair));
foreach ($domains as $domain) {
list($location, $challenges) = yield $acme->requestChallenges($domain);
$goodChallenges = $this->findSuitableCombination($challenges);
if (empty($goodChallenges)) {
throw new AcmeException("Couldn't find any combination of challenges which this server 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!");
}
$this->logger->debug("Generating payload...");
$payload = $acme->generateHttp01Payload($keyPair, $token);
$docRoot = rtrim($args->get("path") ?? __DIR__ . "/../../data/public", "/\\");
$path = $docRoot . "/.well-known/acme-challenge";
try {
if (!file_exists($docRoot)) {
throw new AcmeException("Document root doesn't exist: " . $docRoot);
}
if (!file_exists($path) && !@mkdir($path, 0770, true)) {
throw new AcmeException("Couldn't create public dir to serve the challenges: " . $path);
}
if (!$userInfo = posix_getpwnam($user)) {
throw new AcmeException("Unknown user: " . $user);
}
chown($docRoot . "/.well-known", $userInfo["uid"]);
chown($docRoot . "/.well-known/acme-challenge", $userInfo["uid"]);
$this->logger->info("Providing payload for {$domain} at {$path}/{$token}");
file_put_contents("{$path}/{$token}", $payload);
chown("{$path}/{$token}", $userInfo["uid"]);
chmod("{$path}/{$token}", 0664);
yield $acme->verifyHttp01Challenge($domain, $token, $payload);
$this->logger->info("Successfully self-verified challenge.");
yield $acme->answerChallenge($challenge->uri, $payload);
$this->logger->info("Answered challenge... waiting");
yield $acme->pollForChallenge($location);
$this->logger->info("Challenge successful. {$domain} is now authorized.");
@unlink("{$path}/{$token}");
} catch (Throwable $e) {
// no finally because generators...
@unlink("{$path}/{$token}");
throw $e;
}
}
$path = __DIR__ . "/../../data/live/" . reset($domains);
if (!file_exists($path) && !mkdir($path, 0700, true)) {
throw new AcmeException("Couldn't create directory: {$path}");
}
if (file_exists($path . "/private.pem") && file_exists($path . "/public.pem")) {
$private = file_get_contents($path . "/private.pem");
$public = file_get_contents($path . "/public.pem");
$this->logger->info("Using existing domain key found at {$path}");
$domainKeys = new KeyPair($private, $public);
} else {
$domainKeys = (new OpenSSLKeyGenerator)->generate(2048);
file_put_contents($path . "/private.pem", $domainKeys->getPrivate());
file_put_contents($path . "/public.pem", $domainKeys->getPublic());
$this->logger->info("Saved new domain key at {$path}");
chmod($path . "/private.pem", 0600);
chmod($path . "/public.pem", 0600);
}
$this->logger->info("Requesting certificate ...");
$location = yield $acme->requestCertificate($domainKeys, $domains);
$certificates = yield $acme->pollForCertificate($location);
$this->logger->info("Saving certificate ...");
file_put_contents($path . "/cert.pem", reset($certificates));
file_put_contents($path . "/fullchain.pem", implode("\n", $certificates));
array_shift($certificates);
file_put_contents($path . "/chain.pem", implode("\n", $certificates));
$this->logger->info("Successfully issued certificate.");
}
private function checkDnsRecords($domains): Generator {
$promises = [];
foreach ($domains as $domain) {
$promises[$domain] = dns\resolve($domain, [
"types" => [Record::A],
"hosts" => false,
]);
}
list($errors) = yield any($promises);
if (!empty($errors)) {
throw new AcmeException("Couldn't resolve the following domains to an IPv4 record: " . implode(array_keys($errors)));
}
$this->logger->info("Checked DNS records, all fine.");
}
private function checkRegistration(Manager $args) {
$server = $args->get("server");
$protocol = substr($server, 0, strpos("://", $server));
if (!$protocol || $protocol === $server) {
$server = "https://" . $server;
} elseif ($protocol !== "https") {
throw new \InvalidArgumentException("Invalid server protocol, only HTTPS supported");
}
$identity = str_replace(["/", "%"], "-", substr($server, 8));
$path = __DIR__ . "/../../data/accounts";
$pathPrivate = "{$path}/{$identity}.private.key";
$pathPublic = "{$path}/{$identity}.public.key";
if (file_exists($pathPrivate) && file_exists($pathPublic)) {
$private = file_get_contents($pathPrivate);
$public = file_get_contents($pathPublic);
$this->logger->info("Found account keys.");
return new KeyPair($private, $public);
}
throw new AcmeException("No registration found for server, please register first");
}
private function findSuitableCombination(stdClass $response): array {
$challenges = $response->challenges ?? [];
$combinations = $response->combinations ?? [];
$goodChallenges = [];
foreach ($challenges as $i => $challenge) {
if ($challenge->type === "http-01") {
$goodChallenges[] = $i;
}
}
foreach ($goodChallenges as $i => $challenge) {
if (!in_array([$challenge], $combinations)) {
unset($goodChallenges[$i]);
}
}
return $goodChallenges;
}
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,
"domains" => [
"prefix" => "d",
"longPrefix" => "domains",
"description" => "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,
"server" => [
"prefix" => "s",
"longPrefix" => "server",
"description" => "ACME server to use for authorization.",
"required" => true,
],
'user' => [
'prefix' => 'u',
'longPrefix' => 'user',
'description' => 'User running the web server.',
"user" => [
"prefix" => "s",
"longPrefix" => "user",
"description" => "User for the public directory.",
"required" => false,
],
'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,
"path" => [
"prefix" => "p",
"longPrefix" => "path",
"description" => "Path to the document root for ACME challenges.",
"required" => false,
],
];
}
private $climate;
private $acmeFactory;
public function __construct(CLImate $climate, AcmeFactory $acmeFactory)
{
$this->climate = $climate;
$this->acmeFactory = $acmeFactory;
}
public function execute(Manager $args): Promise
{
return call(function () use ($args) {
$user = null;
if (0 !== \stripos(PHP_OS, 'WIN')) {
if (\posix_geteuid() !== 0) {
$processUser = \posix_getpwnam(\posix_geteuid());
$currentUsername = $processUser['name'];
$user = $args->get('user') ?: $currentUsername;
if ($currentUsername !== $user) {
throw new AcmeException('Running this script with --user only works as root!');
}
} else {
$user = $args->get('user') ?: 'www-data';
}
}
$domains = \array_map('trim', \explode(':', \str_replace([',', ';'], ':', $args->get('domains'))));
yield from $this->checkDnsRecords($domains);
$docRoots = \explode(PATH_SEPARATOR, \str_replace("\\", '/', $args->get('path')));
$docRoots = \array_map(function ($root) {
return \rtrim($root, '/');
}, $docRoots);
if (\count($domains) < \count($docRoots)) {
throw new AcmeException('Specified more document roots than domains.');
}
if (\count($domains) > \count($docRoots)) {
$docRoots = \array_merge(
$docRoots,
\array_fill(\count($docRoots), \count($domains) - \count($docRoots), \end($docRoots))
);
}
$keyStore = new KeyStore(normalizePath($args->get('storage')));
$server = resolveServer($args->get('server'));
$keyFile = serverToKeyname($server);
try {
$key = yield $keyStore->get("accounts/{$keyFile}.pem");
} catch (KeyStoreException $e) {
throw new AcmeException("Account key not found, did you run 'bin/acme setup'?", 0, $e);
}
$this->climate->br();
$acme = $this->acmeFactory->build($server, $key);
$concurrency = \min(20, \max($args->get('challenge-concurrency'), 1));
/** @var Order $order */
$order = yield $acme->newOrder($domains);
/** @var \Throwable[] $errors */
[$errors] = yield AcmeClient\concurrentMap(
$concurrency,
$order->getAuthorizationUrls(),
function ($authorizationUrl) 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) {
$this->climate->error($error->getMessage());
}
throw new AcmeException('Issuance failed, not all challenges could be solved.');
}
yield $acme->pollForOrderReady($order->getUrl());
$keyPath = 'certs/' . $keyFile . '/' . \reset($domains) . '/key.pem';
$bits = $args->get('bits');
$regenerateKey = $args->get('rekey');
try {
$key = yield $keyStore->get($keyPath);
} catch (KeyStoreException $e) {
$regenerateKey = true;
}
if ($regenerateKey) {
$this->climate->whisper(' Generating new key pair ...');
$key = (new RsaKeyGenerator($bits))->generateKey();
}
$this->climate->br();
$this->climate->whisper(' Requesting certificate ...');
$csr = yield (new OpensslCsrGenerator)->generateCsr($key, $domains);
yield $acme->finalizeOrder($order->getFinalizationUrl(), $csr);
yield $acme->pollForOrderValid($order->getUrl());
/** @var Order $order */
$order = yield $acme->getOrder($order->getUrl());
$certificates = yield $acme->downloadCertificates($order->getCertificateUrl());
$path = normalizePath($args->get('storage')) . '/certs/' . $keyFile;
$certificateStore = new CertificateStore($path);
yield $keyStore->put($keyPath, $key);
yield $certificateStore->put($certificates);
$this->climate->info(' Successfully issued certificate.');
$this->climate->info(" See {$path}/" . \reset($domains));
$this->climate->br();
return 0;
});
}
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!");
}
$token = $httpChallenge->getToken();
if (!\preg_match('#^[a-zA-Z0-9-_]+$#', $token)) {
throw new AcmeException('Protocol violation: Invalid 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, $user);
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);
}
}
private function checkDnsRecords(array $domains): \Generator
{
$promises = AcmeClient\concurrentMap(10, $domains, function (string $domain): Promise {
return Dns\resolve($domain);
});
[$errors] = yield Promise\any($promises);
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 findHttpChallenge(Authorization $authorization): ?Challenge
{
$challenges = $authorization->getChallenges();
foreach ($challenges as $challenge) {
if ($challenge->getType() === 'http-01') {
return $challenge;
}
}
return null;
}
}
}

111
src/Commands/Register.php Normal file
View File

@@ -0,0 +1,111 @@
<?php
namespace Kelunik\AcmeClient\Commands;
use Amp\Dns\Record;
use Amp\Dns\ResolutionException;
use Amp\Promise;
use Generator;
use Kelunik\Acme\AcmeClient;
use Kelunik\Acme\AcmeException;
use Kelunik\Acme\AcmeService;
use Kelunik\Acme\KeyPair;
use Kelunik\Acme\OpenSSLKeyGenerator;
use Kelunik\Acme\Registration;
use League\CLImate\Argument\Manager;
use Psr\Log\LoggerInterface;
use function Amp\File\exists;
use function Amp\resolve;
class Register implements Command {
private $logger;
public function __construct(LoggerInterface $logger) {
$this->logger = $logger;
}
public function execute(Manager $args): Promise {
return resolve($this->doExecute($args));
}
public function doExecute(Manager $args): Generator {
$email = $args->get("email");
yield resolve($this->checkEmail($email));
$server = $args->get("server");
$protocol = substr($server, 0, strpos("://", $server));
if (!$protocol || $protocol === $server) {
$server = "https://" . $server;
} elseif ($protocol !== "https") {
throw new \InvalidArgumentException("Invalid server protocol, only HTTPS supported");
}
$identity = str_replace(["/", "%"], "-", substr($server, 8));
$path = __DIR__ . "/../../data/accounts";
$pathPrivate = "{$path}/{$identity}.private.key";
$pathPublic = "{$path}/{$identity}.public.key";
if ((yield exists($pathPrivate)) && (yield exists($pathPublic))) {
$this->logger->info("Loading existing keys ...");
$private = file_get_contents($pathPrivate);
$public = file_get_contents($pathPublic);
$keyPair = new KeyPair($private, $public);
} else {
$this->logger->info("Generating key keys ...");
$keyPair = (new OpenSSLKeyGenerator)->generate(4096);
if (!mkdir($path, 0700, true)) {
throw new AcmeException("Couldn't create account directory");
}
file_put_contents($pathPrivate, $keyPair->getPrivate());
file_put_contents($pathPublic, $keyPair->getPublic());
chmod($pathPrivate, 0600);
}
$acme = new AcmeService(new AcmeClient($server, $keyPair));
$this->logger->info("Registering with ACME server " . substr($server, 8) . " ...");
/** @var Registration $registration */
$registration = yield $acme->register($email);
$this->logger->notice("Registration successful with contact " . json_encode($registration->getContact()));
}
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);
} catch (ResolutionException $e) {
throw new AcmeException("No MX record defined for '{$host}'");
}
}
public static function getDefinition(): array {
return [
"server" => [
"prefix" => "s",
"longPrefix" => "server",
"description" => "ACME server to register for.",
"required" => true,
],
"email" => [
"longPrefix" => "email",
"description" => "Email to be notified about important account issues.",
"required" => true,
],
];
}
}

View File

@@ -2,81 +2,102 @@
namespace Kelunik\AcmeClient\Commands;
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 Generator;
use Kelunik\Acme\AcmeClient;
use Kelunik\Acme\AcmeException;
use Kelunik\Acme\AcmeService;
use Kelunik\Acme\KeyPair;
use Kelunik\Certificate\Certificate;
use League\CLImate\Argument\Manager;
use League\CLImate\CLImate;
use function Amp\call;
use Psr\Log\LoggerInterface;
use function Amp\File\exists;
use function Amp\File\get;
use function Amp\resolve;
class Revoke implements Command
{
public static function getDefinition(): array
{
class Revoke implements Command {
private $logger;
public function __construct(LoggerInterface $logger) {
$this->logger = $logger;
}
public function execute(Manager $args): Promise {
return resolve($this->doExecute($args));
}
private function doExecute(Manager $args): Generator {
$server = $args->get("server");
$protocol = substr($server, 0, strpos("://", $server));
if (!$protocol || $protocol === $server) {
$server = "https://" . $server;
} elseif ($protocol !== "https") {
throw new \InvalidArgumentException("Invalid server protocol, only HTTPS supported");
}
$keyPair = $this->checkRegistration($args);
$acme = new AcmeService(new AcmeClient($server, $keyPair));
$this->logger->info("Revoking certificate ...");
$pem = yield get($args->get("cert"));
$cert = new Certificate($pem);
if ($cert->getValidTo() < time()) {
$this->logger->warning("Certificate did already expire, no need to revoke it.");
return;
}
$this->logger->info("Certificate was valid for: " . implode(", ", $cert->getNames()));
yield $acme->revokeCertificate($pem);
$this->logger->info("Certificate has been revoked.");
}
private function checkRegistration(Manager $args) {
$server = $args->get("server");
$protocol = substr($server, 0, strpos("://", $server));
if (!$protocol || $protocol === $server) {
$server = "https://" . $server;
} elseif ($protocol !== "https") {
throw new \InvalidArgumentException("Invalid server protocol, only HTTPS supported");
}
$identity = str_replace(["/", "%"], "-", substr($server, 8));
$path = __DIR__ . "/../../data/accounts";
$pathPrivate = "{$path}/{$identity}.private.key";
$pathPublic = "{$path}/{$identity}.public.key";
if (file_exists($pathPrivate) && file_exists($pathPublic)) {
$private = file_get_contents($pathPrivate);
$public = file_get_contents($pathPublic);
$this->logger->info("Found account keys.");
return new KeyPair($private, $public);
}
throw new AcmeException("No registration found for server, please register first");
}
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,
"cert" => [
"prefix" => "c",
"longPrefix" => "cert",
"description" => "Certificate to be revoked.",
"required" => true,
],
"server" => [
"prefix" => "s",
"longPrefix" => "server",
"description" => "ACME server to use for authorization.",
"required" => true,
],
];
}
private $climate;
private $acmeFactory;
public function __construct(CLImate $climate, AcmeFactory $acmeFactory)
{
$this->climate = $climate;
$this->acmeFactory = $acmeFactory;
}
public function execute(Manager $args): Promise
{
return call(function () use ($args) {
$keyStore = new KeyStore(AcmeClient\normalizePath($args->get('storage')));
$server = AcmeClient\resolveServer($args->get('server'));
$keyFile = AcmeClient\serverToKeyname($server);
$keyPair = yield $keyStore->get("accounts/{$keyFile}.pem");
$acme = $this->acmeFactory->build($server, $keyPair);
$this->climate->br();
$this->climate->whisper(' Revoking certificate ...');
$path = AcmeClient\normalizePath($args->get('storage')) . '/certs/' . $keyFile . '/' . $args->get('name') . '/cert.pem';
try {
$pem = yield File\read($path);
$cert = new Certificate($pem);
} catch (FilesystemException $e) {
throw new \RuntimeException("There's no such certificate (" . $path . ')');
}
if ($cert->getValidTo() < \time()) {
$this->climate->comment(' Certificate did already expire, no need to revoke it.');
}
$names = $cert->getNames();
$this->climate->whisper(' Certificate was valid for ' . \count($names) . ' domains.');
$this->climate->whisper(' - ' . \implode(PHP_EOL . ' - ', $names) . PHP_EOL);
yield $acme->revokeCertificate($pem);
$this->climate->br();
$this->climate->info(' Certificate has been revoked.');
yield (new CertificateStore(AcmeClient\normalizePath($args->get('storage')) . '/certs/' . $keyFile))->delete($args->get('name'));
return 0;
});
}
}
}

View File

@@ -1,129 +0,0 @@
<?php
namespace Kelunik\AcmeClient\Commands;
use Amp\Dns;
use Amp\Dns\NoRecordException;
use Amp\Dns\Record;
use Amp\Promise;
use Kelunik\Acme\AcmeException;
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;
}
private $climate;
private $acmeFactory;
public function __construct(CLImate $climate, AcmeFactory $acmeFactory)
{
$this->climate = $climate;
$this->acmeFactory = $acmeFactory;
}
public function execute(Manager $args): Promise
{
return call(function () use ($args) {
$email = $args->get('email');
yield from $this->checkEmail($email);
$server = resolveServer($args->get('server'));
$keyFile = serverToKeyname($server);
$path = "accounts/{$keyFile}.pem";
$bits = 4096;
$keyStore = new KeyStore(normalizePath($args->get('storage')));
$this->climate->br();
try {
$keyPair = yield $keyStore->get($path);
$this->climate->whisper(' Using existing private key ...');
} catch (KeyStoreException $e) {
$this->climate->whisper(' No private key found, generating new one ...');
$keyPair = (new RsaKeyGenerator($bits))->generateKey();
$keyPair = yield $keyStore->put($path, $keyPair);
$this->climate->whisper(" Generated new private key with {$bits} bits.");
}
$acme = $this->acmeFactory->build($server, $keyPair);
$this->climate->whisper(' Registering with ' . \substr($server, 8) . ' ...');
/** @var Account $account */
$account = yield $acme->register($email, $args->get('agree-terms'));
$contacts = \implode(', ', \array_map("strval", $account->getContacts()));
$this->climate->info(' Registration successful. Contacts: ' . $contacts);
$this->climate->br();
return 0;
});
}
private function checkEmail(string $email): \Generator
{
$host = \substr($email, \strrpos($email, '@') + 1);
if (!$host) {
throw new AcmeException("Invalid contact email: '{$email}'");
}
try {
yield Dns\query($host, Record::MX);
} catch (NoRecordException $e) {
throw new AcmeException("No MX record defined for '{$host}'");
} catch (Dns\DnsException $e) {
throw new AcmeException(
"Dns query for an MX record on '{$host}' failed for the following reason: " . $e->getMessage(),
null,
$e
);
}
}
}

View File

@@ -1,87 +0,0 @@
<?php
namespace Kelunik\AcmeClient\Commands;
use Amp\File;
use Amp\Promise;
use Kelunik\AcmeClient\Stores\CertificateStore;
use Kelunik\AcmeClient\Stores\KeyStore;
use Kelunik\AcmeClient\Stores\KeyStoreException;
use Kelunik\Certificate\Certificate;
use League\CLImate\Argument\Manager;
use League\CLImate\CLImate;
use function Amp\call;
use function Kelunik\AcmeClient\getArgumentDescription;
use function Kelunik\AcmeClient\normalizePath;
use function Kelunik\AcmeClient\resolveServer;
use function Kelunik\AcmeClient\serverToKeyname;
class Status
{
public static function getDefinition(): array
{
return [
'server' => getArgumentDescription('server'),
'storage' => getArgumentDescription('storage'),
'ttl' => [
'longPrefix' => 'ttl',
'description' => 'Minimum valid time in days, shows ⭮ if renewal is required.',
'defaultValue' => 30,
'castTo' => 'int',
],
];
}
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

@@ -1,87 +0,0 @@
<?php
namespace Kelunik\AcmeClient\Commands;
use Amp\Promise;
use Amp\Success;
use League\CLImate\Argument\Manager;
use League\CLImate\CLImate;
use function Kelunik\AcmeClient\ellipsis;
class Version implements Command
{
public static function getDefinition(): array
{
return [
'deps' => [
'longPrefix' => 'deps',
'description' => 'Show also the bundled dependency versions.',
'noValue' => true,
],
];
}
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

@@ -1,7 +0,0 @@
<?php
namespace Kelunik\AcmeClient;
class ConfigException extends \Exception
{
}

29
src/LoggerColorScheme.php Normal file
View File

@@ -0,0 +1,29 @@
<?php
namespace Kelunik\AcmeClient;
use Bramus\Ansi\ControlSequences\EscapeSequences\Enums\SGR;
use Bramus\Monolog\Formatter\ColorSchemes\ColorSchemeInterface;
use Bramus\Monolog\Formatter\ColorSchemes\ColorSchemeTrait;
use Monolog\Logger;
class LoggerColorScheme implements ColorSchemeInterface {
use ColorSchemeTrait {
ColorSchemeTrait::__construct as private __constructTrait;
}
public function __construct() {
$this->__constructTrait();
$this->setColorizeArray([
Logger::DEBUG => $this->ansi->color(SGR::COLOR_FG_WHITE)->get(),
Logger::INFO => $this->ansi->color(SGR::COLOR_FG_WHITE_BRIGHT)->get(),
Logger::NOTICE => $this->ansi->color(SGR::COLOR_FG_GREEN)->get(),
Logger::WARNING => $this->ansi->color(SGR::COLOR_FG_YELLOW)->get(),
Logger::ERROR => $this->ansi->color(SGR::COLOR_FG_RED)->get(),
Logger::CRITICAL => $this->ansi->color(SGR::COLOR_FG_RED)->get(),
Logger::ALERT => $this->ansi->color(SGR::COLOR_FG_RED)->get(),
Logger::EMERGENCY => $this->ansi->color(SGR::COLOR_FG_RED)->get(),
]);
}
}

View File

@@ -1,83 +0,0 @@
<?php
namespace Kelunik\AcmeClient\Stores;
use Amp\File;
use Amp\File\FilesystemException;
use Amp\Promise;
use Kelunik\Certificate\Certificate;
use function Amp\call;
use function Amp\Dns\isValidName;
class CertificateStore
{
private $root;
public function __construct(string $root)
{
$this->root = \rtrim(\str_replace("\\", '/', $root), '/');
}
public function get(string $name): Promise
{
return call(function () use ($name) {
try {
return yield File\read($this->root . '/' . $name . '/cert.pem');
} catch (FilesystemException $e) {
throw new CertificateStoreException('Failed to load certificate.', 0, $e);
}
});
}
public function put(array $certificates): Promise
{
return call(function () use ($certificates) {
if (empty($certificates)) {
throw new \Error('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.");
}
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(string $name): Promise
{
return call(function () use ($name) {
/** @var array $files */
$files = yield File\listFiles($this->root . '/' . $name);
foreach ($files as $file) {
yield File\deleteFile($this->root . '/' . $name . '/' . $file);
}
yield File\deleteDirectory($this->root . '/' . $name);
});
}
}

View File

@@ -1,7 +0,0 @@
<?php
namespace Kelunik\AcmeClient\Stores;
class CertificateStoreException extends \Exception
{
}

View File

@@ -1,59 +0,0 @@
<?php
namespace Kelunik\AcmeClient\Stores;
use Amp\File;
use Amp\Promise;
use function Amp\call;
class ChallengeStore
{
private $docroot;
public function __construct(string $docroot)
{
$this->docroot = \rtrim(\str_replace("\\", '/', $docroot), '/');
}
public function put(string $token, string $payload, string $user = null): Promise
{
return call(function () use ($token, $payload, $user) {
$path = $this->docroot . '/.well-known/acme-challenge';
$userInfo = null;
if (!yield File\exists($this->docroot)) {
throw new ChallengeStoreException("Document root doesn't exist: '{$this->docroot}'");
}
yield File\createDirectoryRecursively($path, 0755);
if ($user && !$userInfo = \posix_getpwnam($user)) {
throw new ChallengeStoreException("Unknown user: '{$user}'");
}
if ($userInfo !== null) {
yield File\changeOwner($this->docroot . '/.well-known', $userInfo['uid']);
yield File\changeOwner($this->docroot . '/.well-known/acme-challenge', $userInfo['uid']);
}
yield File\write("{$path}/{$token}", $payload);
if ($userInfo !== null) {
yield File\changeOwner("{$path}/{$token}", $userInfo['uid']);
}
yield File\changePermissions("{$path}/{$token}", 0644);
});
}
public function delete(string $token): Promise
{
return call(function () use ($token) {
$path = $this->docroot . "/.well-known/acme-challenge/{$token}";
if (yield File\exists($path)) {
yield File\deleteFile($path);
}
});
}
}

View File

@@ -1,7 +0,0 @@
<?php
namespace Kelunik\AcmeClient\Stores;
class ChallengeStoreException extends \Exception
{
}

View File

@@ -1,60 +0,0 @@
<?php
namespace Kelunik\AcmeClient\Stores;
use Amp\File;
use Amp\File\FilesystemException;
use Amp\Promise;
use Kelunik\Acme\Crypto\PrivateKey;
use function Amp\call;
class KeyStore
{
private $root;
public function __construct(string $root = '')
{
$this->root = \rtrim(\str_replace("\\", '/', $root), '/');
}
public function get(string $path): Promise
{
return call(function () use ($path) {
$file = $this->root . '/' . $path;
try {
$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);
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);
}
return $key;
});
}
}

View File

@@ -1,7 +0,0 @@
<?php
namespace Kelunik\AcmeClient\Stores;
class KeyStoreException extends \Exception
{
}

View File

@@ -1,290 +0,0 @@
<?php
namespace Kelunik\AcmeClient;
use Amp\Sync\LocalSemaphore;
use Amp\Sync\Lock;
use InvalidArgumentException;
use Kelunik\Acme\AcmeException;
use Phar;
use Symfony\Component\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Yaml;
use function Amp\call;
use function Amp\coroutine;
function concurrentMap(int $concurrency, array $values, callable $functor): array
{
$semaphore = new LocalSemaphore($concurrency);
return \array_map(coroutine(function ($value, $key) use ($semaphore, $functor) {
/** @var Lock $lock */
$lock = yield $semaphore->acquire();
try {
return yield call($functor, $value, $key);
} finally {
$lock->release();
}
}), $values, \array_keys($values));
}
/**
* Suggests a command based on similarity in a list of available commands.
*
* @param string $badCommand invalid command
* @param array $commands list of available commands
* @param int $suggestThreshold similarity threshold
*
* @return string suggestion or empty string if no command is similar enough
*/
function suggestCommand(string $badCommand, array $commands, int $suggestThreshold = 70): string
{
$badCommand = \strtolower($badCommand);
$bestMatch = '';
$bestMatchPercentage = 0;
$byRefPercentage = 0;
foreach ($commands as $command) {
\similar_text($badCommand, \strtolower($command), $byRefPercentage);
if ($byRefPercentage > $bestMatchPercentage) {
$bestMatchPercentage = $byRefPercentage;
$bestMatch = $command;
}
}
return $bestMatchPercentage >= $suggestThreshold ? $bestMatch : '';
}
/**
* Resolves a server to a valid URI. If a valid shortcut is passed, it's resolved to the defined URI. If a URI without
* protocol is passed, it will default to HTTPS.
*
* @param string $uri URI to resolve
*
* @return string resolved URI
*/
function resolveServer(string $uri): string
{
$shortcuts = [
'letsencrypt' => 'https://acme-v02.api.letsencrypt.org/directory',
'letsencrypt:production' => 'https://acme-v02.api.letsencrypt.org/directory',
'letsencrypt:staging' => 'https://acme-staging-v02.api.letsencrypt.org/directory',
];
if (isset($shortcuts[$uri])) {
return $shortcuts[$uri];
}
if (\strpos($uri, '/') === false) {
throw new InvalidArgumentException('Invalid server URI: ' . $uri);
}
$protocol = \substr($uri, 0, \strpos($uri, '://'));
if (!$protocol || $protocol === $uri) {
return "https://{$uri}";
}
return $uri;
}
/**
* Transforms a directory URI to a valid filename for usage as key file name.
*
* @param string $server URI to the directory
*
* @return string identifier usable as file name
*/
function serverToKeyname(string $server): string
{
$server = \substr($server, \strpos($server, '://') + 3);
$keyFile = \str_replace('/', '.', $server);
$keyFile = \preg_replace('@[^a-z0-9._-]@', '', $keyFile);
$keyFile = \preg_replace("@\\.+@", '.', $keyFile);
return $keyFile;
}
/**
* Checks whether the application is currently running as Phar.
*
* @return bool {@code true} if running as Phar, {@code false} otherwise
*/
function isPhar(): bool
{
if (!\class_exists('Phar')) {
return false;
}
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(string $path): string
{
return \rtrim(\str_replace("\\", '/', $path), '/');
}
/**
* Gets the most appropriate config path to use.
*
* @return string|null Resolves to the config path or null.
*/
function getConfigPath(): ?string
{
$paths = isPhar() ? [\substr(\dirname(Phar::running()), \strlen('phar://')) . '/acme-client.yml'] : [];
if (0 !== \stripos(PHP_OS, 'WIN')) {
if ($home = \getenv('HOME')) {
$paths[] = $home . '/.acme-client.yml';
}
$paths[] = '/etc/acme-client.yml';
}
do {
$path = \array_shift($paths);
if (\file_exists($path)) {
return $path;
}
} while (\count($paths));
return null;
}
/**
* Returns a consistent argument description for CLIMate. Valid arguments are "server" and "storage".
*
* @param string $argument argument name
*
* @return array CLIMate argument description
* @throws AcmeException if the provided acme-client.yml file is invalid
* @throws ConfigException if the provided configuration file is invalid
*/
function getArgumentDescription(string $argument): array
{
$config = [];
if ($configPath = getConfigPath()) {
$configContent = \file_get_contents($configPath);
try {
$config = Yaml::parse($configContent);
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'])) {
throw new ConfigException("'storage' set, but not a string.");
}
} catch (ParseException $e) {
throw new AcmeException("Unable to parse the configuration ({$configPath}): " . $e->getMessage());
}
}
switch ($argument) {
case 'server':
$desc = [
'prefix' => 's',
'longPrefix' => 'server',
'description' => 'ACME server to use for registration and issuance of certificates.',
'required' => true,
];
if (isset($config['server'])) {
$desc['required'] = false;
$desc['defaultValue'] = $config['server'];
}
return $desc;
case 'storage':
$isPhar = isPhar();
$desc = [
'longPrefix' => 'storage',
'description' => 'Storage directory for account keys and certificates.',
'required' => $isPhar,
];
if (!$isPhar) {
$desc['defaultValue'] = \dirname(__DIR__) . '/data';
} elseif (isset($config['storage'])) {
$desc['required'] = false;
$desc['defaultValue'] = $config['storage'];
}
return $desc;
default:
throw new InvalidArgumentException('Unknown argument: ' . $argument);
}
}
/**
* Returns the binary that currently runs. Can be included in help texts about other commands.
*
* @return string binary callable, shortened based on PATH and CWD
*/
function getBinary(): string
{
$binary = 'bin/acme';
if (isPhar()) {
$binary = \substr(Phar::running(), \strlen('phar://'));
$path = \getenv('PATH');
$locations = \explode(PATH_SEPARATOR, $path);
$binaryPath = \dirname($binary);
foreach ($locations as $location) {
if ($location === $binaryPath) {
return \substr($binary, \strlen($binaryPath) + 1);
}
}
$cwd = \getcwd();
if ($cwd && \strpos($binary, $cwd) === 0) {
$binary = '.' . \substr($binary, \strlen($cwd));
}
}
return $binary;
}
/**
* Cuts a text to a certain length and appends an ellipsis if necessary.
*
* @param string $text text to shorten
* @param int $max maximum length
* @param string $append appendix when too long
*
* @return string shortened string
*/
function ellipsis(string $text, int $max = 70, string $append = '…'): string
{
if (\strlen($text) <= $max) {
return $text;
}
$out = \substr($text, 0, $max);
if (\strpos($text, ' ') === false) {
return $out . $append;
}
return \preg_replace("/\\w+$/", '', $out) . $append;
}

View File

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