126 Commits

Author SHA1 Message Date
Niklas Keller
361a06ce26 Upgrade dependencies 2021-10-25 22:20:11 +02:00
Niklas Keller
74aa1b82fb Cleanup default parameter in changeOwner 2021-08-10 00:30:37 +02:00
Niklas Keller
b464b52a85 Fix changeOwner → changePermissions 2021-08-10 00:30:37 +02:00
Niklas Keller
82c053fa02 Update usage.md 2021-07-08 23:40:18 +02:00
Niklas Keller
5a8a6d471c Update installation.md 2021-07-08 23:37:01 +02:00
Niklas Keller
1210f0b7fc Update README.md 2021-07-08 23:36:06 +02:00
Niklas Keller
e9b382128d Upgrade to ACME RFC (v2) 2021-07-04 12:40:11 +02:00
Niklas Keller
6bfa43dad9 Update dependencies and code style 2021-06-30 00:28:56 +02:00
Pieter Hordijk
5b112cba3e Fixed required PHP version to run the client (#77) 2019-03-01 19:44:17 +01:00
Niklas Keller
35070bb70a Add logging support for debugging 2018-06-09 19:00:02 +02:00
Niklas Keller
6da46ddaf6 Upgrade dependencies 2018-06-06 21:50:52 +02:00
Niklas Keller
51acff5bd3 Implement --rekey option
Closes #65.
Closes #19.
2018-04-15 19:14:36 +02:00
Niklas Keller
ea3e9dc68c Update LICENSE date 2018-04-15 18:27:00 +02:00
Niklas Keller
6f01055884 Upgrade to amphp/process v0.3.x 2018-04-15 18:19:40 +02:00
Niklas Keller
297e1aa9b1 Update dependencies 2018-04-15 18:12:32 +02:00
Niklas Keller
b7cfe3c0f1 Update to amphp/parallel v0.2.5
This fixes running as PHAR if the PHAR doesn't end with '.phar'.
2018-03-21 15:52:05 +01:00
Niklas Keller
4053094860 Update .travis.yml 2018-03-21 13:06:30 +01:00
Niklas Keller
a80b7b8497 Remove BlockingDriver usage, as parallel has been fixed 2018-03-21 12:55:25 +01:00
Niklas Keller
f13b0856c7 Update dependencies 2018-03-21 12:54:21 +01:00
Niklas Keller
d4f2009315 Fix build on nightly 2018-01-11 17:18:29 +01:00
Niklas Keller
2b4a200263 Fix CSR generation 2018-01-11 17:15:04 +01:00
Niklas Keller
256aa76011 Fix directory permissions 2018-01-11 17:11:28 +01:00
Niklas Keller
69bc88daf1 Refactor directory creation 2018-01-11 17:00:27 +01:00
Niklas Keller
19f6550e33 Fix key store path in exception message 2018-01-11 16:53:01 +01:00
Niklas Keller
ed3da3c98d Fix DNS lookups 2018-01-11 16:43:32 +01:00
Niklas Keller
56955155fe Work around https://bugs.php.net/bug.php?id=75396 2018-01-11 10:36:45 +01:00
Niklas Keller
e3d7723da3 Fix bugs in stores not yielding the correct things 2018-01-11 10:32:17 +01:00
Niklas Keller
0ae207fce3 Downgrade dependencies to be compatible with PHP 7.0 2018-01-09 19:30:34 +01:00
Niklas Keller
bb7e25704c Work around issue with amphp/parallel and PHARs 2018-01-08 18:44:33 +01:00
Niklas Keller
246a02b5cf Update dependencies 2018-01-08 18:43:52 +01:00
Niklas Keller
2dba4a852a Fix code style 2017-12-29 19:23:48 +01:00
Niklas Keller
d8a93a273c Update meta files 2017-12-29 19:20:59 +01:00
Niklas Keller
9a9a243807 Upgrade to Amp v2 2017-12-29 19:15:47 +01:00
Ayman Nedjmeddine
c71b07ef03 Add sample configuration file 2017-12-16 12:53:09 +01:00
Niklas Keller
f4cabf755b Update dependencies, ignore cookies 2017-05-10 10:22:51 +02:00
Niklas Keller
d78b465739 Add link to phar download 2017-03-18 08:52:18 +01:00
Niklas Keller
aa8186471c Issue certificates sequential, allow changing the challenge concurrency 2017-01-30 19:15:33 +01:00
Niklas Keller
888588cf00 Separate script for cron 2017-01-13 09:00:39 +01:00
Niklas Keller
44f218d8c2 Update dependencies 2017-01-05 02:20:47 +01:00
Niklas Keller
af44670353 Improve error message for failed domain DNS 2017-01-02 00:07:23 +01:00
Niklas Keller
0bd3525938 Use 'composer update' instead of 'composer install'
We need another PHPUnit version for PHP 5.5.
2016-12-15 10:53:03 +01:00
Niklas Keller
c3f8424785 Fix tests 2016-12-15 10:35:24 +01:00
Niklas Keller
040aebe993 Improve error message on timed out MX query
Any error, not only NoRecordExceptions, resulted in a MX record not found
error message. The previous message is now only shown if there's really no
record. Otherwise a more generic message is shown now.

Fixes #43.
2016-12-14 19:21:41 +01:00
Niklas Keller
8944aee552 Fall back to global config for server and storage if possible 2016-10-22 12:21:09 +02:00
Niklas Keller
5b2c47c30f Args::exists → Args::defined 2016-10-22 12:15:07 +02:00
Niklas Keller
ecb46af5c0 Pass right variables to checkAndIssue 2016-10-22 12:07:10 +02:00
Niklas Keller
253d3f476b Renew if not all names are covered
Renew a certificate if not all names are covered by the current certificate yet.
Adds a new `--names` option to `check` that makes `check` fail if not all names are covered.
Resolves #34.
2016-10-22 11:41:34 +02:00
Niklas Keller
c6d9c2016c Make 'server' and 'storage' optional for 'auto'
Resolves #35. Takes the value from the global config file as default argument.
If it doesn't exist, but something in the config file exists, it takes that.
If a command line argument is provided, it always takes precedence.
2016-10-22 11:27:52 +02:00
Niklas Keller
b4d4da7a51 Update dependencies 2016-10-22 11:27:25 +02:00
Niklas Keller
0d61689da1 Wait for challenge to be written to disk before continuing
This also makes exceptions visible now. This commit resolves #37.
2016-10-22 11:10:01 +02:00
Niklas Keller
349a12aae6 Merge pull request #41 from spikyjt/patch-1
Correct brackets
2016-10-22 11:01:32 +02:00
JT
28ae97f135 Correct brackets
I'm sure I did this properly the first time. Must have lost concentration!
2016-10-22 09:55:36 +01:00
Niklas Keller
0fc0e45e57 Merge pull request #40 from spikyjt/patch-1
POSIX compliant cron example
2016-10-21 14:15:12 +02:00
JT
9356a060e7 POSIX compliant cron example
Changed the cron auto example to be POSIX compliant and use full paths.
Changed `exit` variable to `RC` (commonly used in system scripts for "return code") as `exit` is a shell builtin.
Added note about setting the full path as $PATH may not be set.
2016-10-21 11:56:29 +01:00
Niklas Keller
b4a722c0a9 Update dependencies
Also update php-cs from fabpot to friendsofphp.
2016-08-04 16:13:04 +02:00
Niklas Keller
2f73c15287 Update dependencies 2016-08-04 16:10:17 +02:00
Niklas Keller
05a6f6d861 Merge pull request #36 from izzlazz/master
Add support for IPv6-only host names
2016-07-29 09:09:15 +02:00
René Højbjerg Larsen
d5fdc1a3c0 Add support for IPv6-only host names 2016-07-27 22:28:42 +02:00
Niklas Keller
cc76a6f52c Warn if PHP 5.5 is used as it's EOL 2016-07-11 09:19:18 +02:00
Niklas Keller
74b275cf07 Chunk DNS lookups as well 2016-06-28 22:05:36 +02:00
Niklas Keller
e9d2a59eca Chunk authorization requests into groups of 10 2016-06-28 10:27:46 +02:00
Niklas Keller
b9d79bbbe7 Improve documentation for the auto command, closes #31 2016-06-20 15:55:23 +02:00
Niklas Keller
f0c09881ea Update dependencies to include latest amphp/socket v0.9.8 release 2016-06-19 22:21:07 +02:00
Niklas Keller
07f9a03702 Check success for directory creation in key store
Resolves #28.
2016-06-09 16:50:41 +02:00
Niklas Keller
d04e758598 Update dependencies 2016-06-09 16:39:26 +02:00
Niklas Keller
029f4c533a Add check to catch #30 early with a helpful message 2016-06-09 16:32:37 +02:00
Niklas Keller
c02e758a21 Merge pull request #26 from kelunik/automation
Basic working 'auto' command
2016-06-07 09:44:27 +02:00
Niklas Keller
e1ea62b5e7 Doc typo and format fixes 2016-06-07 09:42:52 +02:00
Niklas Keller
a090e99a19 Update documentation for new auto command 2016-06-04 21:47:08 +02:00
Niklas Keller
de3b82da1d Fix output variables for external process outputs 2016-06-04 20:46:57 +02:00
Niklas Keller
791b250742 Show renewals in auto command, fix reference notice, correct exit code for renewed certs 2016-06-04 20:41:48 +02:00
Niklas Keller
9f849691c2 Fix config load path 2016-06-04 19:51:02 +02:00
Niklas Keller
c4d15e2e26 Better exit codes and error messages for auto command 2016-06-04 19:25:46 +02:00
Niklas Keller
0722e104d4 Update deps, use email in setup from config if present 2016-06-03 23:46:38 +02:00
Niklas Keller
583318fa0b Scan for config in /etc and ~, improve automation command 2016-06-03 19:38:47 +02:00
Niklas Keller
3472bd1b3c Basic working 'auto' command 2016-06-03 18:16:29 +02:00
Niklas Keller
6d31cec6ad Update dependencies 2016-06-02 21:39:30 +02:00
Niklas Keller
8cbb3d02a8 Update dependencies 2016-05-27 16:49:35 +02:00
Niklas Keller
d7b71dab24 Merge pull request #27 from kelunik/status
Implement 'status' command
2016-03-29 10:56:14 +02:00
Niklas Keller
944adf0c06 Implement 'status' command 2016-03-28 19:48:39 +02:00
Niklas Keller
a1d65c1483 Don't show help with 'acme-client subcommand h / help', just with '-h' / '--help' flags 2016-03-28 12:35:21 +02:00
Niklas Keller
fb0509ae7e Show help when --help is included, not only if parsing fails and then --help is included 2016-03-28 12:27:02 +02:00
Niklas Keller
866b172c5f Implement version command and better help 2016-03-28 12:26:17 +02:00
Niklas Keller
8d085347b9 Fixup error message for issues with IPv4 resolving 2016-03-26 10:08:24 +01:00
Niklas Keller
fc3b7e948f Document all functions in functions.php 2016-03-25 20:16:43 +01:00
Niklas Keller
2b2daee8bb Refactor AcmeService creation into Factory 2016-03-25 20:04:58 +01:00
Niklas Keller
e4b9203537 Unignore composer.lock 2016-03-25 18:04:12 +01:00
Niklas Keller
c94d9b4795 Add requirements to README 2016-03-25 15:48:34 +01:00
Niklas Keller
7cfcb575fa Show helpful error message if OpenSSL is missing 2016-03-25 15:19:05 +01:00
Niklas Keller
fffaec6d84 Bundle CA bundle into Phar 2016-03-25 15:18:41 +01:00
Niklas Keller
1bc25c738c Show correct binary file in help texts 2016-03-25 13:29:37 +01:00
Niklas Keller
9c8d67b2e9 Add logo to README 2016-03-25 12:36:32 +01:00
Niklas Keller
251a47ebaa Update usage instructions and separate docs from README 2016-03-24 17:27:43 +01:00
Niklas Keller
a33ac65a77 Add sample YAML configuration 2016-03-24 11:53:42 +01:00
Niklas Keller
746bf84adb Fix config file loader 2016-03-24 11:51:12 +01:00
Niklas Keller
ff6e14e2de Fix travis builds 2016-03-24 10:37:36 +01:00
Niklas Keller
9f917ec3ec Add instructions for using it as phar 2016-03-24 09:33:43 +01:00
Niklas Keller
5381587bbf Improve more output 2016-03-24 00:01:17 +01:00
Niklas Keller
266cc06746 Improve output for various commands 2016-03-23 23:58:20 +01:00
Niklas Keller
dd34937e96 Improve check output 2016-03-23 23:45:30 +01:00
Niklas Keller
d34b6eb09d Merge pull request #18 from kelunik/issue-17
Provide possibility to specify storage directory
2016-03-23 23:39:27 +01:00
Niklas Keller
1c4a2387e9 Fix exit codes on PHP 5 2016-03-23 23:35:35 +01:00
Niklas Keller
8549ff9e46 Fix documentation bug: Missing server on revocation 2016-03-23 23:35:07 +01:00
Niklas Keller
93c94d1a9b Fix exit codes 2016-03-23 23:32:13 +01:00
Niklas Keller
6173b779e1 Fix phar creation 2016-03-23 23:31:54 +01:00
Niklas Keller
e8f35811fb Separate paths on Windows with ; instead of : 2016-03-23 23:31:54 +01:00
Niklas Keller
c36ced9b7c Ensure consistent server and storage arguments, fix storage path for setup 2016-03-23 23:31:39 +01:00
Niklas Keller
9ea97f18e1 Implement storage defaulting to the old one and required when using as PHAR 2016-03-23 23:30:39 +01:00
Niklas Keller
1a06a5cadf Replace logger with CLIMate output 2016-03-23 23:29:26 +01:00
Niklas Keller
3e6be4abf1 Better output when parsing of arguments doesn't work 2016-03-23 23:09:45 +01:00
Niklas Keller
67e4fab090 Better output when required parameters are missing 2016-03-23 23:01:18 +01:00
Niklas Keller
5e0d126834 Add syntax checking and PHPUnit to Travis 2016-03-23 11:21:21 +01:00
Niklas Keller
e47eb9c636 Add sample script for renewal 2016-03-20 18:21:30 +01:00
Niklas Keller
88629772c4 Refactor challenge solving so it can run in parallel 2016-03-20 15:26:04 +01:00
Niklas Keller
82578e4370 Fix multi-docroot feature 2016-03-20 15:18:57 +01:00
Niklas Keller
063eebf76d Implement multi docroot feature which is already documented, but has never been commited 2016-03-20 15:12:15 +01:00
Niklas Keller
9348c2f4ad Add note about webserver configuration and automation reconfiguration 2016-03-20 15:02:23 +01:00
Niklas Keller
2629d2bfc7 Accept also commas as separator for domains 2016-03-20 15:00:55 +01:00
Niklas Keller
9265ed6464 Further improve the README 2016-03-20 14:52:33 +01:00
Niklas Keller
a9c29ac6de Fix README formatting 2016-03-20 14:51:11 +01:00
Niklas Keller
fe0454ab44 Use tagged dependency of macfja/phar-builder 2016-03-20 14:49:36 +01:00
Niklas Keller
27bcd0d1c2 Improve installation instructions 2016-03-20 14:46:37 +01:00
Niklas Keller
3318b227ab Add migration guide for 0.1.x → 0.2.x 2016-03-20 14:45:21 +01:00
Niklas Keller
6bfa882aa0 Fix PHP 5 compat in CertificateStore 2016-03-15 13:21:44 +01:00
Niklas Keller
19bc2af47d Add certificate keyword 2016-03-13 19:23:41 +01:00
Niklas Keller
61b463ad70 Add keywords to composer.json 2016-03-13 18:26:34 +01:00
35 changed files with 8837 additions and 776 deletions

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

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

6
.gitignore vendored
View File

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

10
.php_cs.dist Normal file
View File

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

View File

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

View File

@@ -1,62 +1,14 @@
# acme
![`kelunik/acme-client`](./res/logo.png)
![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 an ACME client written in PHP. ACME is the protocol that powers the [Let's Encrypt](https://letsencrypt.org) certificate authority.
`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.
## Requirements
> **Warning**: This software is under development. Use at your own risk.
* PHP 7.4+ with OpenSSL
* Works on Unix-like systems and Windows
The client has been updated on Mar 12th in a non-backwards compatible manner. Please review the changes or use a new clone.
## Documentation
## Installation
```
git clone https://github.com/kelunik/acme-client
cd acme-client
composer install
```
## Usage
> **Note**: This client stores all data in `./data`, be sure to backup this folder regularly.
> It contains your account keys, domain keys and certificates.
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.
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.
```
bin/acme setup -s letsencrypt --email me@example.com
```
`-s` / `--server` can either be a URI or a shortcut. Available shortcuts:
* `letsencrypt` / `letsencrypt:production`
* `letsencrypt:staging`
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.
```
bin/acme issue -s letsencrypt -d example.com:www.example.com -p /var/www/example.com
```
To revoke a certificate, you need a valid account key currently, just like for issuance.
```
bin/acme revoke --name example.com
```
For renewal, there's the `bin/acme 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:
```
bin/acme check --name example.com --ttl 30 -s letsencrypt || bin/acme issue ...
```
* [Installation](./doc/installation.md)
* [Usage](./doc/usage.md)
* [Migration guide for 0.1.x → 0.2.x](./doc/migrations/0.2.0.md)

162
bin/acme
View File

@@ -1,23 +1,28 @@
#!/usr/bin/env php
<?php
use Amp\Http\Client\HttpClientBuilder;
use Amp\Loop;
use Auryn\Injector;
use Bramus\Monolog\Formatter\ColoredLineFormatter;
use Kelunik\AcmeClient\LoggerColorScheme;
use Kelunik\AcmeClient\AcmeFactory;
use Kelunik\AcmeClient\Commands\Command;
use League\CLImate\CLImate;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use Psr\Log\LoggerInterface;
use function Kelunik\AcmeClient\getBinary;
use function Kelunik\AcmeClient\suggestCommand;
if (!file_exists(__DIR__ . "/../vendor/autoload.php")) {
$logo = <<<LOGO
____ __________ ___ ___
/ __ `/ ___/ __ `__ \/ _ \
/ /_/ / /__/ / / / / / __/
\__,_/\___/_/ /_/ /_/\___/
LOGO;
if (!file_exists(__DIR__ . '/../vendor/autoload.php')) {
echo $logo;
echo <<<HELP
____ __________ ___ ___
/ __ `/ ___/ __ `__ \/ _ \
/ /_/ / /__/ / / / / / __/
\__,_/\___/_/ /_/ /_/\___/
You need to install the composer dependencies.
You need to install the composer dependencies.
composer install --no-dev
@@ -26,62 +31,85 @@ HELP;
exit(-1);
}
require __DIR__ . "/../vendor/autoload.php";
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 = [
"setup",
"issue",
"check",
"revoke",
'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.',
];
$help = implode("\n ", array_map(function($command) {
return "bin/acme {$command}";
}, $commands));
$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)));
$help = <<<EOT
____ __________ ___ ___
/ __ `/ ___/ __ `__ \/ _ \
/ /_/ / /__/ / / / / / __/
\__,_/\___/_/ /_/ /_/\___/
<yellow>Usage:</yellow>
bin/acme [command] [--args]
Usage: bin/acme command --args
Available Commands:
{$help}
Get more help by appending --help to specific commands.
<yellow>Options:</yellow>
<green>-h, --help</green>
└─ Print this help information.
<yellow>Available commands:</yellow>
{$help}
Get more help by appending <yellow>--help</yellow> to specific commands.
EOT;
$climate = new CLImate;
$injector = new Injector;
if (!in_array(PHP_SAPI, ["cli", "phpdbg"], true)) {
$climate->error("Please run this script via CLI!");
if (!in_array(PHP_SAPI, ['cli', 'phpdbg'], true)) {
$climate->error('Please run this script on the command line!');
exit(1);
}
if (count($argv) === 1 || in_array($argv[1], ["h", "-h", "help", "--help"], true)) {
print $help;
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);
exit(0);
}
if (!in_array($argv[1], $commands)) {
if (!array_key_exists($argv[1], $commands)) {
$climate->error("Unknown command '{$argv[1]}'. Use --help for a list of available commands.");
$suggestion = \Kelunik\AcmeClient\suggestCommand($argv[1], $commands);
$suggestion = suggestCommand($argv[1], array_keys($commands));
if ($suggestion) {
$climate->br()->info(" Did you mean '$suggestion'?")->br();
$climate->br()->out(" Did you mean '$suggestion'?");
}
$climate->br();
exit(1);
}
/** @var \Kelunik\AcmeClient\Commands\Command $class */
/** @var string|Command $class */
$class = "Kelunik\\AcmeClient\\Commands\\" . ucfirst($argv[1]);
$definition = $class::getDefinition();
@@ -90,51 +118,59 @@ try {
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();
exit(0);
}
$climate->arguments->parse(array_values($args));
} catch (Exception $e) {
if (count($argv) === 3 && in_array($argv[2], ["h", "-h", "--help", "help"], true)) {
$climate->usage(["bin/acme {$argv[1]}"]);
$climate->br();
exit(0);
} else {
$climate->error($e->getMessage());
exit(1);
}
$climate->usage(["{$binary} {$argv[1]}"]);
$climate->br();
$climate->error($e->getMessage());
$climate->br();
exit(1);
}
$handler = new StreamHandler("php://stdout", Logger::DEBUG);
$handler->setFormatter(new ColoredLineFormatter(new LoggerColorScheme, null, null, true, true));
$logger = new Logger("ACME");
$logger->pushHandler($handler);
$injector->alias(LoggerInterface::class, Logger::class);
$injector->share($logger);
$injector = new Injector;
$injector->share($climate);
$injector->share(new AcmeFactory);
$injector->share(HttpClientBuilder::buildDefault());
$command = $injector->make($class);
$exitCode = 1;
Amp\run(function () use ($command, $climate, $logger) {
$handler = function($e) use ($logger) {
Loop::run(function () use ($command, $climate, &$exitCode) {
$handler = function ($e) use ($climate) {
$error = (string) $e;
$lines = explode("\n", $error);
$lines = array_filter($lines, function ($line) {
return strlen($line) && $line[0] !== "#" && $line !== "Stack trace:";
return $line !== '' && $line[0] !== '#' && $line !== 'Stack trace:';
});
foreach ($lines as $line) {
$logger->error($line);
$climate->error($line)->br();
}
exit(1);
};
try {
yield $command->execute($climate->arguments);
$exitCode = yield $command->execute($climate->arguments);
if ($exitCode === null) {
$exitCode = 0;
}
} catch (Throwable $e) {
$handler($e);
} catch (Exception $e) {
$handler($e);
}
Amp\stop();
Loop::stop();
});
exit($exitCode);

View File

@@ -1,19 +1,33 @@
{
"name": "kelunik/acme-client",
"description": "Standalone PHP ACME client.",
"description": "Let's Encrypt / ACME client written in PHP for the CLI.",
"keywords": [
"ACME",
"letsencrypt",
"certificate",
"https",
"encryption",
"ssl",
"tls"
],
"require": {
"amphp/process": "^0.1.1",
"bramus/monolog-colored-line-formatter": "^2",
"ext-posix": "*",
"php": ">=7.2",
"ext-openssl": "*",
"kelunik/acme": "^0.3",
"amphp/process": "^1.1",
"amphp/parallel": "^1.4",
"kelunik/acme": "^1",
"kelunik/certificate": "^1",
"league/climate": "^3",
"monolog/monolog": "^1.17",
"php": "^5.5|^7",
"psr/log": "^1",
"rdlowrey/auryn": "^1",
"webmozart/assert": "^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"
},
"license": "MIT",
"authors": [
@@ -22,8 +36,6 @@
"email": "me@kelunik.com"
}
],
"minimum-stability": "dev",
"prefer-stable": true,
"autoload": {
"psr-4": {
"Kelunik\\AcmeClient\\": "src"
@@ -32,7 +44,37 @@
"src/functions.php"
]
},
"require-dev": {
"phpunit/phpunit": "^5"
"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"
]
}
}
}
}

6773
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

70
doc/advanced-usage.md Normal file
View File

@@ -0,0 +1,70 @@
## Advanced Usage
Please read the document about [basic usage](./usage.md) first.
## Register an Account
```
acme-client setup --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
```
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
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.
```
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
```

66
doc/installation.md Normal file
View File

@@ -0,0 +1,66 @@
# Installation
## 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).
### Requirements
* PHP 7.4+
### 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 probably don't want the Phar but install the dependencies using Composer.
### Requirements
* PHP 7.4+
* [Composer](https://getcomposer.org/)
### Instructions
```bash
# Clone repository
git clone https://github.com/kelunik/acme-client && cd acme-client
# Install dependencies
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.

43
doc/migrations/0.2.0.md Normal file
View File

@@ -0,0 +1,43 @@
# Migration from 0.1.x to 0.2.x
If you used this client before `0.2.0`, you have a different directory structure than the current one. If you want to upgrade, but keep all your data, here's a migration guide.
```bash
# Start in ./data
cd data
# Move your account key to new location:
mkdir accounts
mv account/key.pem accounts/acme-v01.api.letsencrypt.org.directory.pem
# or accounts/acme-staging.api.letsencrypt.org.directory.pem if it's a staging key
# account should now be empty or contain just a config.json, you can delete the folder then
rm -rf account
# Migrate certificates to new location:
cd certs
mkdir acme-v01.api.letsencrypt.org.directory
# Move all your certificate directories
# Repeat for all directories!
mv example.com acme-v01.api.letsencrypt.org.directory
# or acme-staging.api.letsencrypt.org.directory
# Delete all config.json files which may exist
find -name "config.json" | xargs rm
# Update to current version
# Alternatively have a look at the new installation instructions and use the Phar
git checkout master && git pull
# Check out latest release
git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
# Update dependencies
composer update --no-dev
# Reconfigure your webserver to use the new paths
# and check (and fix) your automation commands.
```

5
doc/migrations/0.3.0.md Normal file
View File

@@ -0,0 +1,5 @@
# Migration from 0.2.x to 0.3.x
If you used this client before `0.3.0` via the command line, nothing should change with this release. It is an internal rewrite to Amp v2, which is the underlying concurrency framework.
If you're depending on this package's internals, some things might have changed slightly. A detailed changelog can't be provided. My focus is the public command line API.

119
doc/usage.md Normal file
View File

@@ -0,0 +1,119 @@
# Basic 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`.
**Be sure to backup that directory regularly.**
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`.

BIN
res/logo-avatar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

BIN
res/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

28
src/AcmeFactory.php Normal file
View File

@@ -0,0 +1,28 @@
<?php
namespace Kelunik\AcmeClient;
use Amp\ByteStream\ResourceOutputStream;
use Amp\Log\ConsoleFormatter;
use Amp\Log\StreamHandler;
use Kelunik\Acme\AcmeClient;
use Kelunik\Acme\AcmeService;
use Kelunik\Acme\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);
}
}

361
src/Commands/Auto.php Normal file
View File

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

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

View File

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

View File

@@ -2,204 +2,288 @@
namespace Kelunik\AcmeClient\Commands;
use Amp\Dns\Record;
use Exception;
use Kelunik\Acme\AcmeClient;
use Amp\Dns;
use Amp\Promise;
use Kelunik\Acme\AcmeException;
use Kelunik\Acme\AcmeService;
use Kelunik\Acme\OpenSSLKeyGenerator;
use Kelunik\Acme\Crypto\Backend\OpensslBackend;
use Kelunik\Acme\Crypto\PrivateKey;
use Kelunik\Acme\Crypto\RsaKeyGenerator;
use Kelunik\Acme\Csr\OpensslCsrGenerator;
use Kelunik\Acme\Protocol\Authorization;
use Kelunik\Acme\Protocol\Challenge;
use Kelunik\Acme\Protocol\Order;
use Kelunik\Acme\Verifiers\Http01;
use Kelunik\AcmeClient;
use Kelunik\AcmeClient\AcmeFactory;
use Kelunik\AcmeClient\Stores\CertificateStore;
use Kelunik\AcmeClient\Stores\ChallengeStore;
use Kelunik\AcmeClient\Stores\KeyStore;
use Kelunik\AcmeClient\Stores\KeyStoreException;
use League\CLImate\Argument\Manager;
use Psr\Log\LoggerInterface;
use stdClass;
use Throwable;
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 {
private $logger;
public function __construct(LoggerInterface $logger) {
$this->logger = $logger;
}
public function execute(Manager $args) {
return \Amp\resolve($this->doExecute($args));
}
private function doExecute(Manager $args) {
if (strtoupper(substr(PHP_OS, 0, 3)) !== 'WIN') {
if (posix_geteuid() !== 0) {
$processUser = posix_getpwnam(posix_geteuid());
$currentUsername = $processUser["name"];
$user = $args->get("user") ?: $currentUsername;
if ($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(":", $args->get("domains")));
yield \Amp\resolve($this->checkDnsRecords($domains));
$keyStore = new KeyStore(dirname(dirname(__DIR__)) . "/data");
$server = \Kelunik\AcmeClient\resolveServer($args->get("server"));
$keyFile = \Kelunik\AcmeClient\serverToKeyname($server);
try {
$keyPair = (yield $keyStore->get("accounts/{$keyFile}.pem"));
} catch (KeyStoreException $e) {
$this->logger->error("Account key not found, did you run 'bin/acme setup'?");
exit(1);
}
$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 client can solve!");
}
$challenge = $challenges->challenges[reset($goodChallenges)];
$token = $challenge->token;
if (!preg_match("#^[a-zA-Z0-9-_]+$#", $token)) {
throw new AcmeException("Protocol violation: Invalid Token!");
}
$this->logger->debug("Generating payload...");
$payload = $acme->generateHttp01Payload($keyPair, $token);
$this->logger->info("Providing payload at http://{$domain}/.well-known/acme-challenge/{$token}");
$docRoot = rtrim(str_replace("\\", "/", $args->get("path")), "/");
$challengeStore = new ChallengeStore($docRoot);
try {
$challengeStore->put($token, $payload, isset($user) ? $user : null);
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.");
yield $challengeStore->delete($token);
} catch (Exception $e) {
// no finally because generators...
yield $challengeStore->delete($token);
throw $e;
} catch (Throwable $e) {
// no finally because generators...
yield $challengeStore->delete($token);
throw $e;
}
}
$path = "certs/" . $keyFile . "/" . reset($domains) . "/key.pem";
$bits = $args->get("bits");
try {
$keyPair = (yield $keyStore->get($path));
} catch (KeyStoreException $e) {
$keyPair = (new OpenSSLKeyGenerator)->generate($bits);
$keyPair = (yield $keyStore->put($path, $keyPair));
}
$this->logger->info("Requesting certificate ...");
$location = (yield $acme->requestCertificate($keyPair, $domains));
$certificates = (yield $acme->pollForCertificate($location));
$path = dirname(dirname(__DIR__)) . "/data/certs/" . $keyFile;
$certificateStore = new CertificateStore($path);
yield $certificateStore->put($certificates);
$this->logger->info("Successfully issued certificate, see {$path}/" . reset($domains));
}
private function checkDnsRecords($domains) {
$promises = [];
foreach ($domains as $domain) {
$promises[$domain] = \Amp\Dns\resolve($domain, [
"types" => [Record::A],
"hosts" => false,
]);
}
list($errors) = (yield \Amp\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 findSuitableCombination(stdClass $response) {
$challenges = isset($response->challenges) ? $response->challenges : [];
$combinations = isset($response->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() {
class Issue implements Command
{
public static function getDefinition(): array
{
return [
"server" => [
"prefix" => "s",
"longPrefix" => "server",
"description" => "Server to use for issuance, see also 'bin/acme setup'.",
"required" => true,
'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" => "Colon separated list of domains to request a certificate for.",
"required" => true,
'path' => [
'prefix' => 'p',
'longPrefix' => 'path',
'description' => 'Colon (Unix) / Semicolon (Windows) separated list of paths to the document roots. The last one will be used for all remaining ones if fewer than the amount of domains is given.',
'required' => true,
],
"path" => [
"prefix" => "p",
"longPrefix" => "path",
"description" => "Colon separated list of paths to the document roots. The last one will be used for all remaining ones if fewer than the amount of domains is given.",
"required" => true,
'user' => [
'prefix' => 'u',
'longPrefix' => 'user',
'description' => 'User running the web server.',
],
"user" => [
"prefix" => "u",
"longPrefix" => "user",
"description" => "User running the web server.",
'bits' => [
'longPrefix' => 'bits',
'description' => 'Length of the private key in bit.',
'defaultValue' => 2048,
'castTo' => 'int',
],
"bits" => [
"longPrefix" => "bits",
"description" => "Length of the private key in bit.",
"defaultValue" => 2048,
"castTo" => "int",
'challenge-concurrency' => [
'longPrefix' => 'challenge-concurrency',
'description' => 'Number of challenges to be solved concurrently.',
'defaultValue' => 10,
'castTo' => 'int',
],
'rekey' => [
'longPrefix' => 'rekey',
'description' => 'Regenerate the key pair even if a key pair already exists.',
'noValue' => 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) {
$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, $i) use ($acme, $key, $domains, $docRoots, $user) {
/** @var Authorization $authorization */
$authorization = yield $acme->getAuthorization($authorizationUrl);
if ($authorization->getIdentifier()->getType() !== 'dns') {
throw new AcmeException('Invalid identifier: ' . $authorization->getIdentifier()->getType());
}
$name = $authorization->getIdentifier()->getValue();
if ($authorization->isWildcard()) {
$name .= '*.';
}
$index = \array_search($name, $domains, true);
if ($index === false) {
throw new AcmeException('Unknown identifier returned: ' . $name);
}
return yield from $this->solveChallenge($acme, $key, $authorization, $domains[$i], $docRoots[$i],
$user);
}
);
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;
}
}

View File

@@ -2,73 +2,81 @@
namespace Kelunik\AcmeClient\Commands;
use Amp\File;
use Amp\File\FilesystemException;
use Amp\Promise;
use Generator;
use Kelunik\Acme\AcmeClient;
use Kelunik\Acme\AcmeException;
use Kelunik\Acme\AcmeService;
use Kelunik\AcmeClient;
use Kelunik\AcmeClient\AcmeFactory;
use Kelunik\AcmeClient\Stores\CertificateStore;
use Kelunik\AcmeClient\Stores\KeyStore;
use Kelunik\Certificate\Certificate;
use League\CLImate\Argument\Manager;
use Psr\Log\LoggerInterface;
use League\CLImate\CLImate;
use function Amp\call;
class Revoke implements Command {
private $logger;
public function __construct(LoggerInterface $logger) {
$this->logger = $logger;
}
public function execute(Manager $args) {
return \Amp\resolve($this->doExecute($args));
}
private function doExecute(Manager $args) {
$keyStore = new KeyStore(dirname(dirname(__DIR__)) . "/data");
$server = \Kelunik\AcmeClient\resolveServer($args->get("server"));
$keyFile = \Kelunik\AcmeClient\serverToKeyname($server);
$keyPair = (yield $keyStore->get("accounts/{$keyFile}.pem"));
$acme = new AcmeService(new AcmeClient($server, $keyPair), $keyPair);
$this->logger->info("Revoking certificate ...");
$path = dirname(dirname(__DIR__)) . "/data/certs/" . $keyFile . "/" . $args->get("name") . "/cert.pem";
try {
$pem = (yield \Amp\File\get($path));
$cert = new Certificate($pem);
} catch (FilesystemException $e) {
throw new \RuntimeException("There's no such certificate (" . $path . ")");
}
if ($cert->getValidTo() < time()) {
$this->logger->warning("Certificate did already expire, no need to revoke it.");
}
$this->logger->info("Certificate was valid for: " . implode(", ", $cert->getNames()));
yield $acme->revokeCertificate($pem);
$this->logger->info("Certificate has been revoked.");
yield (new CertificateStore(dirname(dirname(__DIR__)) . "/data/certs/" . $keyFile))->delete($args->get("name"));
}
public static function getDefinition() {
class Revoke implements Command
{
public static function getDefinition(): array
{
return [
"server" => [
"prefix" => "s",
"longPrefix" => "server",
"description" => "",
"required" => true,
],
"name" => [
"longPrefix" => "name",
"description" => "Common name of the certificate to be revoked.",
"required" => true,
'server' => AcmeClient\getArgumentDescription('server'),
'storage' => AcmeClient\getArgumentDescription('storage'),
'name' => [
'longPrefix' => 'name',
'description' => 'Common name of the certificate to be revoked.',
'required' => true,
],
];
}
}
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

@@ -2,99 +2,128 @@
namespace Kelunik\AcmeClient\Commands;
use Amp\Dns;
use Amp\Dns\NoRecordException;
use Amp\Dns\Record;
use Amp\Dns\ResolutionException;
use Amp\Promise;
use Generator;
use InvalidArgumentException;
use Kelunik\Acme\AcmeClient;
use Kelunik\Acme\AcmeException;
use Kelunik\Acme\AcmeService;
use Kelunik\Acme\OpenSSLKeyGenerator;
use Kelunik\Acme\Registration;
use Kelunik\Acme\Crypto\RsaKeyGenerator;
use Kelunik\Acme\Protocol\Account;
use Kelunik\AcmeClient;
use Kelunik\AcmeClient\AcmeFactory;
use Kelunik\AcmeClient\Stores\KeyStore;
use Kelunik\AcmeClient\Stores\KeyStoreException;
use League\CLImate\Argument\Manager;
use Psr\Log\LoggerInterface;
use RuntimeException;
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 {
private $logger;
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,
],
];
public function __construct(LoggerInterface $logger) {
$this->logger = $logger;
}
$configPath = AcmeClient\getConfigPath();
public function execute(Manager $args) {
return \Amp\resolve($this->doExecute($args));
}
if ($configPath) {
$config = Yaml::parse(\file_get_contents($configPath));
public function doExecute(Manager $args) {
$email = $args->get("email");
yield \Amp\resolve($this->checkEmail($email));
$server = \Kelunik\AcmeClient\resolveServer($args->get("server"));
$keyFile = \Kelunik\AcmeClient\serverToKeyname($server);
$path = "accounts/{$keyFile}.pem";
$bits = 4096;
$keyStore = new KeyStore(dirname(dirname(__DIR__)) . "/data");
try {
$this->logger->info("Loading private key ...");
$keyPair = (yield $keyStore->get($path));
$this->logger->info("Existing private key successfully loaded.");
} catch (KeyStoreException $e) {
$this->logger->info("No existing private key found, generating new one ...");
$keyPair = (new OpenSSLKeyGenerator)->generate($bits);
$this->logger->info("Generated new private key with {$bits} bits.");
$this->logger->info("Saving new private key ...");
$keyPair = (yield $keyStore->put($path, $keyPair));
$this->logger->info("New private key successfully saved.");
if (isset($config['email']) && \is_string($config['email'])) {
$args['email']['required'] = false;
$args['email']['defaultValue'] = $config['email'];
}
}
$acme = new AcmeService(new AcmeClient($server, $keyPair), $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 the following contact information: " . implode(", ", $registration->getContact()));
return $args;
}
private function checkEmail($email) {
if (!is_string($email)) {
throw new InvalidArgumentException(sprintf("\$email must be of type string, %s given.", gettype($email)));
}
private $climate;
private $acmeFactory;
$host = substr($email, strrpos($email, "@") + 1);
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 \Amp\Dns\query($host, Record::MX);
} catch (ResolutionException $e) {
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
);
}
}
public static function getDefinition() {
return [
"server" => [
"prefix" => "s",
"longPrefix" => "server",
"description" => "ACME server to use for registration and issuance of certificates.",
"required" => true,
],
"email" => [
"longPrefix" => "email",
"description" => "Email for important issues, will be sent to the ACME server.",
"required" => true,
],
];
}
}
}

87
src/Commands/Status.php Normal file
View File

@@ -0,0 +1,87 @@
<?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();
}
});
}
}

87
src/Commands/Version.php Normal file
View File

@@ -0,0 +1,87 @@
<?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;
}
}

7
src/ConfigException.php Normal file
View File

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

View File

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

@@ -2,90 +2,82 @@
namespace Kelunik\AcmeClient\Stores;
use Amp\File;
use Amp\File\FilesystemException;
use InvalidArgumentException;
use Amp\Promise;
use Kelunik\Certificate\Certificate;
use Webmozart\Assert\Assert;
use function Amp\call;
use function Amp\Dns\isValidName;
class CertificateStore {
class CertificateStore
{
private $root;
public function __construct($root) {
if (!is_string($root)) {
throw new InvalidArgumentException(sprintf("\$root must be of type string, %s given.", gettype($root)));
}
$this->root = rtrim(str_replace("\\", "/", $root), "/");
public function __construct(string $root)
{
$this->root = \rtrim(\str_replace("\\", '/', $root), '/');
}
public function get($name) {
return \Amp\resolve($this->doGet($name));
public function get(string $name): Promise
{
return call(function () use ($name) {
try {
return yield File\read($this->root . '/' . $name . '/cert.pem');
} catch (FilesystemException $e) {
throw new CertificateStoreException('Failed to load certificate.', 0, $e);
}
});
}
private function doGet($name) {
Assert::string($name, "Name must be a string. Got: %s");
try {
return yield \Amp\File\get($this->root . "/" . $name . "/cert.pem");
} catch (FilesystemException $e) {
throw new CertificateStoreException("Failed to load certificate.", 0, $e);
}
}
public function put(array $certificates) {
return \Amp\resolve($this->doPut($certificates));
}
private function doPut(array $certificates) {
if (empty($certificates)) {
throw new InvalidArgumentException("Empty array not allowed");
}
$cert = new Certificate($certificates[0]);
$commonName = $cert->getSubject()->getCommonName();
if (!$commonName) {
throw new CertificateStoreException("Certificate doesn't have a common name.");
}
// See https://github.com/amphp/dns/blob/4c4d450d4af26fc55dc56dcf45ec7977373a38bf/lib/functions.php#L83
if (isset($commonName[253]) || !preg_match("~^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9]){0,1})(?:\\.[a-z0-9][a-z0-9-]{0,61}[a-z0-9])*$~i", $commonName)) {
throw new CertificateStoreException("Invalid common name: '{$commonName}'");
}
try {
$chain = array_slice($certificates, 1);
$path = $this->root . "/" . $commonName;
$realpath = realpath($path);
if (!$realpath && !mkdir($path, 0775, true)) {
throw new FilesystemException("Couldn't create certificate directory: '{$path}'");
public function put(array $certificates): Promise
{
return call(function () use ($certificates) {
if (empty($certificates)) {
throw new \Error('Empty array not allowed');
}
yield \Amp\File\put($path . "/cert.pem", $certificates[0]);
yield \Amp\File\chmod($path . "/cert.pem", 0644);
$cert = new Certificate($certificates[0]);
$commonName = $cert->getSubject()->getCommonName();
yield \Amp\File\put($path . "/fullchain.pem", implode("\n", $certificates));
yield \Amp\File\chmod($path . "/fullchain.pem", 0644);
if (!$commonName) {
throw new CertificateStoreException("Certificate doesn't have a common name.");
}
yield \Amp\File\put($path . "/chain.pem", implode("\n", $chain));
yield \Amp\File\chmod($path . "/chain.pem", 0644);
} catch (FilesystemException $e) {
throw new CertificateStoreException("Couldn't save certificates for '{$commonName}'", 0, $e);
}
if (!isValidName($commonName)) {
throw new CertificateStoreException("Invalid common name: '{$commonName}'");
}
try {
$chain = \array_slice($certificates, 1);
$path = $this->root . '/' . $commonName;
yield File\createDirectoryRecursively($path, 0755);
yield File\write($path . '/cert.pem', $certificates[0]);
yield File\changePermissions($path . '/cert.pem', 0644);
yield File\write($path . '/fullchain.pem', \implode("\n", $certificates));
yield File\changePermissions($path . '/fullchain.pem', 0644);
yield File\write($path . '/chain.pem', \implode("\n", $chain));
yield File\changePermissions($path . '/chain.pem', 0644);
} catch (FilesystemException $e) {
throw new CertificateStoreException("Couldn't save certificates for '{$commonName}'", 0, $e);
}
});
}
public function delete($name) {
return \Amp\resolve($this->doDelete($name));
public function delete(string $name): Promise
{
return call(function () use ($name) {
/** @var array $files */
$files = yield File\listFiles($this->root . '/' . $name);
foreach ($files as $file) {
yield File\deleteFile($this->root . '/' . $name . '/' . $file);
}
yield File\deleteDirectory($this->root . '/' . $name);
});
}
private function doDelete($name) {
Assert::string($name, "Name must be a string. Got: %s");
foreach ((yield \Amp\File\scandir($this->root . "/" . $name)) as $file) {
yield \Amp\File\unlink($this->root . "/" . $name . "/" . $file);
}
yield \Amp\File\rmdir($this->root . "/" . $name);
}
}
}

View File

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

View File

@@ -2,72 +2,58 @@
namespace Kelunik\AcmeClient\Stores;
use InvalidArgumentException;
use Webmozart\Assert\Assert;
use Amp\File;
use Amp\Promise;
use function Amp\call;
class ChallengeStore {
class ChallengeStore
{
private $docroot;
public function __construct($docroot) {
if (!is_string($docroot)) {
throw new InvalidArgumentException(sprintf("\$docroot must be of type string, %s given.", gettype($docroot)));
}
$this->docroot = rtrim(str_replace("\\", "/", $docroot), "/");
public function __construct(string $docroot)
{
$this->docroot = \rtrim(\str_replace("\\", '/', $docroot), '/');
}
public function put($token, $payload, $user = null) {
return \Amp\resolve($this->doPut($token, $payload, $user));
}
public function put(string $token, string $payload, string $user = null): Promise
{
return call(function () use ($token, $payload, $user) {
$path = $this->docroot . '/.well-known/acme-challenge';
$userInfo = null;
private function doPut($token, $payload, $user = null) {
Assert::string($token, "Token must be a string. Got: %s");
Assert::string($payload, "Payload must be a string. Got: %s");
Assert::nullOrString($user, "User must be a string or null. Got: %s");
if (!yield File\exists($this->docroot)) {
throw new ChallengeStoreException("Document root doesn't exist: '{$this->docroot}'");
}
$path = $this->docroot . "/.well-known/acme-challenge";
$realpath = realpath($path);
yield File\createDirectoryRecursively($path, 0755);
if (!realpath($this->docroot)) {
throw new ChallengeStoreException("Document root doesn't exist: '{$this->docroot}'");
}
if (!$realpath && !@mkdir($path, 0755, true)) {
throw new ChallengeStoreException("Couldn't create public directory to serve the challenges: '{$path}'");
}
if ($user) {
if (!$userInfo = posix_getpwnam($user)) {
if ($user && !$userInfo = \posix_getpwnam($user)) {
throw new ChallengeStoreException("Unknown user: '{$user}'");
}
}
if (isset($userInfo)) {
yield \Amp\File\chown($this->docroot . "/.well-known", $userInfo["uid"], -1);
yield \Amp\File\chown($this->docroot . "/.well-known/acme-challenge", $userInfo["uid"], -1);
}
if ($userInfo !== null) {
yield File\changeOwner($this->docroot . '/.well-known', $userInfo['uid']);
yield File\changeOwner($this->docroot . '/.well-known/acme-challenge', $userInfo['uid']);
}
yield \Amp\File\put("{$path}/{$token}", $payload);
yield File\write("{$path}/{$token}", $payload);
if (isset($userInfo)) {
yield \Amp\File\chown("{$path}/{$token}", $userInfo["uid"], -1);
}
if ($userInfo !== null) {
yield File\changeOwner("{$path}/{$token}", $userInfo['uid']);
}
yield \Amp\File\chmod("{$path}/{$token}", 0644);
yield File\changePermissions("{$path}/{$token}", 0644);
});
}
public function delete($token) {
return \Amp\resolve($this->doDelete($token));
public function delete(string $token): Promise
{
return call(function () use ($token) {
$path = $this->docroot . "/.well-known/acme-challenge/{$token}";
if (yield File\exists($path)) {
yield File\deleteFile($path);
}
});
}
private function doDelete($token) {
Assert::string($token, "Token must be a string. Got: %s");
$path = $this->docroot . "/.well-known/acme-challenge/{$token}";
$realpath = realpath($path);
if ($realpath) {
yield \Amp\File\unlink($realpath);
}
}
}
}

View File

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

View File

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

View File

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

View File

@@ -2,20 +2,51 @@
namespace Kelunik\AcmeClient;
use Webmozart\Assert\Assert;
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 suggestCommand($badCommand, array $commands, $suggestThreshold = 70) {
Assert::string($badCommand, "Bad command must be a string. Got: %s");
Assert::integer($suggestThreshold, "Suggest threshold must be an integer. Got: %s");
function concurrentMap(int $concurrency, array $values, callable $functor): array
{
$semaphore = new LocalSemaphore($concurrency);
$badCommand = strtolower($badCommand);
return \array_map(coroutine(function ($value, $key) use ($semaphore, $functor) {
/** @var Lock $lock */
$lock = yield $semaphore->acquire();
$bestMatch = "";
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);
\similar_text($badCommand, \strtolower($command), $byRefPercentage);
if ($byRefPercentage > $bestMatchPercentage) {
$bestMatchPercentage = $byRefPercentage;
@@ -23,37 +54,237 @@ function suggestCommand($badCommand, array $commands, $suggestThreshold = 70) {
}
}
return $bestMatchPercentage >= $suggestThreshold ? $bestMatch : "";
return $bestMatchPercentage >= $suggestThreshold ? $bestMatch : '';
}
function resolveServer($uri) {
Assert::string($uri, "URI must be a string. Got: %s");
/**
* 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-v01.api.letsencrypt.org/directory",
"letsencrypt:production" => "https://acme-v01.api.letsencrypt.org/directory",
"letsencrypt:staging" => "https://acme-staging.api.letsencrypt.org/directory",
'letsencrypt' => 'https://acme-v02.api.letsencrypt.org/directory',
'letsencrypt:production' => 'https://acme-v02.api.letsencrypt.org/directory',
'letsencrypt:staging' => 'https://acme-staging-v02.api.letsencrypt.org/directory',
];
if (isset($shortcuts[$uri])) {
return $shortcuts[$uri];
}
$protocol = substr($uri, 0, strpos($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}";
} else {
return $uri;
}
return $uri;
}
/**
* Transforms a directory URI to a valid filename for usage as key file name.
*
* @param string $server URI to the directory
*
* @return string identifier usable as file name
*/
function serverToKeyname(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);
}
}
function serverToKeyname($server) {
$server = substr($server, strpos($server, "://") + 3);
/**
* 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';
$keyFile = str_replace("/", ".", $server);
$keyFile = preg_replace("@[^a-z0-9._-]@", "", $keyFile);
$keyFile = preg_replace("@\\.+@", ".", $keyFile);
if (isPhar()) {
$binary = \substr(Phar::running(), \strlen('phar://'));
return $keyFile;
}
$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

@@ -2,17 +2,44 @@
namespace Kelunik\AcmeClient;
class FunctionsTest extends \PHPUnit_Framework_TestCase {
public function testResolveServer() {
$this->assertSame("https://acme-v01.api.letsencrypt.org/directory", resolveServer("letsencrypt"));
$this->assertSame("https://acme-v01.api.letsencrypt.org/directory", resolveServer("letsencrypt:production"));
$this->assertSame("https://acme-staging.api.letsencrypt.org/directory", resolveServer("letsencrypt:staging"));
$this->assertSame("https://acme-v01.api.letsencrypt.org/directory", resolveServer("acme-v01.api.letsencrypt.org/directory"));
$this->assertSame("https://acme-v01.api.letsencrypt.org/directory", resolveServer("https://acme-v01.api.letsencrypt.org/directory"));
use PHPUnit\Framework\TestCase;
class FunctionsTest extends TestCase
{
public function testResolveServer(): void
{
$this->assertSame('https://acme-v02.api.letsencrypt.org/directory', resolveServer('letsencrypt'));
$this->assertSame('https://acme-v02.api.letsencrypt.org/directory', resolveServer('letsencrypt:production'));
$this->assertSame(
'https://acme-staging-v02.api.letsencrypt.org/directory',
resolveServer('letsencrypt:staging')
);
$this->assertSame(
'https://acme-v01.api.letsencrypt.org/directory',
resolveServer('acme-v01.api.letsencrypt.org/directory')
);
$this->assertSame(
'https://acme-v01.api.letsencrypt.org/directory',
resolveServer('https://acme-v01.api.letsencrypt.org/directory')
);
}
public function testSuggestCommand() {
$this->assertSame("acme", suggestCommand("acme!", ["acme"]));
$this->assertSame("", suggestCommand("issue", ["acme"]));
public function testSuggestCommand(): void
{
$this->assertSame('acme', suggestCommand('acme!', ['acme']));
$this->assertSame('', suggestCommand('issue', ['acme']));
}
}
public function testIsPhar(): 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\\"));
}
}