58 Commits

Author SHA1 Message Date
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
Niklas Keller
4355e82afd Update documentation to new changes 2016-03-12 18:11:08 +01:00
Niklas Keller
118f34ac87 Fix revocation of certificates 2016-03-12 18:02:03 +01:00
Niklas Keller
c885593e68 Fix private key location for domain keys 2016-03-12 17:59:27 +01:00
Niklas Keller
2c8da1c161 Fix parameter order for strpos 2016-03-12 17:55:11 +01:00
Niklas Keller
4184b074c4 Fix exists check in KeyStore 2016-03-12 17:52:03 +01:00
Niklas Keller
8782c1e0b1 Better error message for missing account key 2016-03-12 17:50:25 +01:00
Niklas Keller
fa81d66686 Strip protocol from server key name 2016-03-12 17:46:57 +01:00
Niklas Keller
50bf9137c3 Save certificates in account subfolder 2016-03-12 17:45:16 +01:00
Niklas Keller
7b2cf6679f Use bounded version constraint for PHP 2016-03-12 17:39:51 +01:00
Niklas Keller
afba80cc23 Require certificate common name instead of full path on check 2016-03-12 17:30:44 +01:00
Niklas Keller
fc8907b984 Fix payload generation for issuance 2016-03-12 17:22:09 +01:00
Niklas Keller
73b88e3e9e Remove CHOWN call in KeyStore 2016-03-12 17:16:03 +01:00
Niklas Keller
a82d601e85 Resolve server correctly 2016-03-12 17:14:52 +01:00
Niklas Keller
057e42d96a Create folder in KeyStore only if it not exists 2016-03-12 17:11:28 +01:00
Niklas Keller
b07ab9471a Fix regex delimiters, add logo to initial composer help 2016-03-12 17:09:30 +01:00
Niklas Keller
5b96cedea1 Add composer install check 2016-03-12 16:30:37 +01:00
Niklas Keller
68afa4e0e9 Remove renew and replace with check mechanism, remove sudo requirement, add multiple accounts again 2016-03-12 16:26:49 +01:00
Niklas Keller
37d054975c Fix user requirement in setup, cleanup. 2015-12-21 00:01:25 +01:00
Niklas Keller
5a4ab4a410 Make PHP 5.5 compat 2015-12-20 21:49:23 +01:00
Niklas Keller
7f0869150f Major update, add renew command, rename register to setup 2015-12-09 20:10:21 +01:00
27 changed files with 1240 additions and 490 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
/build/
/data/
/vendor/
/composer.lock

15
.php_cs Normal file
View File

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

28
.travis.yml Normal file
View File

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

View File

@@ -1,55 +1,9 @@
# 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.
## Documentation
> **Warning**: This software is under heavy development. Use at your own risk.
## 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.
```
sudo bin/acme register \
--server acme-v01.api.letsencrypt.org/directory \
--email me@example.com
```
After a successful registration you're able to issue certificates.
This client assumes you have a HTTP server setup and running.
You must have a document root setup in order to use this client.
```
sudo bin/acme issue \
--server acme-v01.api.letsencrypt.org/directory \
--domains example.com,www.example.com \
--path /var/www/example.com
```
For renewal, just run this command again.
To revoke a certificate, you need a valid account key currently, just like for issuance.
```
sudo bin/acme revoke \
--server acme-v01.api.letsencrypt.org/directory \
--cert data/live/example.com/cert.pem
```
* [Installation](./doc/installation.md)
* [Usage](./doc/usage.md)
* [Migration guide for 0.1.x → 0.2.x](./doc/migrations/0.2.0.md)

132
bin/acme
View File

@@ -2,14 +2,55 @@
<?php
use Auryn\Injector;
use Bramus\Monolog\Formatter\ColoredLineFormatter;
use Kelunik\AcmeClient\LoggerColorScheme;
use League\CLImate\CLImate;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
if (!file_exists(__DIR__ . "/../vendor/autoload.php")) {
echo <<<HELP
____ __________ ___ ___
/ __ `/ ___/ __ `__ \/ _ \
/ /_/ / /__/ / / / / / __/
\__,_/\___/_/ /_/ /_/\___/
You need to install the composer dependencies.
composer install --no-dev
HELP;
exit(-1);
}
if (!function_exists("openssl_pkey_get_private")) {
echo <<<HELP
____ __________ ___ ___
/ __ `/ ___/ __ `__ \/ _ \
/ /_/ / /__/ / / / / / __/
\__,_/\___/_/ /_/ /_/\___/
You need to enable OpenSSL in your php.ini
HELP;
exit(-2);
}
require __DIR__ . "/../vendor/autoload.php";
$commands = [
"setup",
"issue",
"check",
"revoke",
];
$binary = \Kelunik\AcmeClient\getBinary();
$help = implode("\n ", array_map(function ($command) use ($binary) {
return "{$binary} {$command}";
}, $commands));
$help = <<<EOT
____ __________ ___ ___
@@ -20,70 +61,73 @@ $help = <<<EOT
Usage: bin/acme command --args
Available Commands:
bin/acme register
bin/acme issue
bin/acme revoke
{$help}
Get more help by appending --help to specific commands.
EOT;
$commands = [
"issue" => "Kelunik\\AcmeClient\\Commands\\Issue",
"register" => "Kelunik\\AcmeClient\\Commands\\Register",
"revoke" => "Kelunik\\AcmeClient\\Commands\\Revoke",
];
$climate = new CLImate;
$injector = new Injector;
if (!isset($argv)) {
$climate->error("\$argv is not defined");
if (!in_array(PHP_SAPI, ["cli", "phpdbg"], true)) {
$climate->error("Please run this script via CLI!");
exit(1);
}
if (count($argv) === 1 || $argv[1] === "-h" || $argv[1] === "--help" || $argv[1] === "help") {
print $help;
if (count($argv) === 1 || in_array($argv[1], ["h", "-h", "help", "--help"], true)) {
$climate->out($help);
exit(0);
}
if (!array_key_exists($argv[1], $commands)) {
$climate->error("Unknown command: '{$argv[1]}'");
if (!in_array($argv[1], $commands)) {
$climate->br()->error(" Unknown command '{$argv[1]}'. Use --help for a list of available commands.");
$suggestion = \Kelunik\AcmeClient\suggestCommand($argv[1], $commands);
if ($suggestion) {
$climate->br()->out(" Did you mean '$suggestion'?");
}
$climate->br();
exit(1);
}
/** @var \Kelunik\AcmeClient\Commands\Command $class */
$class = "Kelunik\\AcmeClient\\Commands\\" . ucfirst($argv[1]);
$definition = $class::getDefinition();
try {
$climate->arguments->add($commands[$argv[1]]::getDefinition());
$climate->arguments->parse();
$args = $argv;
unset($args[1]);
$climate->arguments->add($definition);
$climate->arguments->parse(array_values($args));
} catch (Exception $e) {
if ($climate->arguments->defined("help")) {
print $help;
if (count($argv) === 3 && in_array($argv[2], ["h", "-h", "--help", "help"], true)) {
$climate->usage(["{$binary} {$argv[1]}"]);
$climate->br();
exit(0);
} else {
$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));
$injector->share($climate);
$logger = new Logger("ACME");
$logger->pushHandler($handler);
$command = $injector->make($class);
$injector->alias("Psr\\Log\\LoggerInterface", Logger::class);
$injector->share($logger);
$command = $injector->make($commands[$argv[1]]);
Amp\run(function () use ($command, $climate, $logger) {
try {
yield $command->execute($climate->arguments);
} catch (Throwable $e) {
Amp\run(function () use ($command, $climate) {
$handler = function ($e) use ($climate) {
$error = (string) $e;
$lines = explode("\n", $error);
$lines = array_filter($lines, function ($line) {
@@ -91,10 +135,24 @@ Amp\run(function () use ($command, $climate, $logger) {
});
foreach ($lines as $line) {
$logger->error($line);
$climate->error($line)->br();
}
exit(1);
};
try {
$exitCode = (yield $command->execute($climate->arguments));
if ($exitCode === null) {
exit(0);
}
exit($exitCode);
} catch (Throwable $e) {
$handler($e);
} catch (Exception $e) {
$handler($e);
}
Amp\stop();

View File

@@ -1,17 +1,30 @@
{
"name": "kelunik/acme-client",
"description": "Standalone PHP ACME client.",
"keywords": [
"ACME",
"letsencrypt",
"certificate",
"https",
"encryption",
"ssl",
"tls"
],
"require": {
"php": ">=7.0.0",
"ext-posix": "*",
"php": "^5.5|^7",
"ext-openssl": "*",
"bramus/monolog-colored-line-formatter": "^2",
"kelunik/acme": "dev-master",
"kelunik/certificate": "dev-master",
"amphp/process": "^0.1.1",
"kelunik/acme": "^0.3",
"kelunik/certificate": "^1",
"league/climate": "^3",
"monolog/monolog": "^1.17",
"psr/log": "^1",
"rdlowrey/auryn": "^1"
"rdlowrey/auryn": "^1",
"webmozart/assert": "^1",
"symfony/yaml": "^3.0"
},
"require-dev": {
"phpunit/phpunit": "^5",
"fabpot/php-cs-fixer": "^1.9",
"macfja/phar-builder": "dev-master#a2db582eab26ef7b15144c013408749a79fae361"
},
"license": "MIT",
"authors": [
@@ -25,6 +38,18 @@
"autoload": {
"psr-4": {
"Kelunik\\AcmeClient\\": "src"
},
"files": [
"src/functions.php"
]
},
"extra": {
"phar-builder": {
"compression": "GZip",
"name": "acme-client.phar",
"output-dir": "build",
"include": ["src", "vendor/kelunik/acme/res", "vendor/amphp/socket/var"],
"entry-point": "bin/acme"
}
}
}

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.
### Requirements
* PHP 5.5+
### 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 5.5+
* [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.
```

79
doc/usage.md Normal file
View File

@@ -0,0 +1,79 @@
# Usage
**The client stores all data in `./data` if you're using the Composer installation method, otherwise in the directory you configured. 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 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.
If you're using the client with Composer, replace `acme-client` with `bin/acme`. You have to specify the server with `-s` / `--server`, because there's currently no config file support for this installation method.
## 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 `--s 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.
## Renewing 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.
```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
```

29
phpunit.xml Normal file
View File

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

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

75
src/Commands/Check.php Normal file
View File

@@ -0,0 +1,75 @@
<?php
namespace Kelunik\AcmeClient\Commands;
use Amp\CoroutineResult;
use Kelunik\AcmeClient\Stores\CertificateStore;
use Kelunik\AcmeClient\Stores\CertificateStoreException;
use Kelunik\Certificate\Certificate;
use League\CLImate\Argument\Manager;
use League\CLImate\CLImate;
class Check implements Command {
private $climate;
public function __construct(CLImate $climate) {
$this->climate = $climate;
}
public function execute(Manager $args) {
return \Amp\resolve($this->doExecute($args));
}
/**
* @param Manager $args
* @return \Generator
*/
private function doExecute(Manager $args) {
$server = \Kelunik\AcmeClient\resolveServer($args->get("server"));
$server = \Kelunik\AcmeClient\serverToKeyname($server);
$path = \Kelunik\AcmeClient\normalizePath($args->get("storage")) . "/certs/" . $server;
$certificateStore = new CertificateStore($path);
try {
$pem = (yield $certificateStore->get($args->get("name")));
} catch (CertificateStoreException $e) {
$this->climate->br()->error(" Certificate not found.")->br();
yield new CoroutineResult(1);
return;
}
$cert = new Certificate($pem);
$this->climate->br();
$this->climate->whisper(" Certificate is valid until " . date("d.m.Y", $cert->getValidTo()))->br();
if ($cert->getValidTo() > time() + $args->get("ttl") * 24 * 60 * 60) {
yield new CoroutineResult(0);
return;
}
$this->climate->comment(" Certificate is going to expire within the specified " . $args->get("ttl") . " days.")->br();
yield new CoroutineResult(1);
}
public static function getDefinition() {
return [
"server" => \Kelunik\AcmeClient\getArgumentDescription("server"),
"storage" => \Kelunik\AcmeClient\getArgumentDescription("storage"),
"name" => [
"longPrefix" => "name",
"description" => "Common name of the certificate to check.",
"required" => true,
],
"ttl" => [
"longPrefix" => "ttl",
"description" => "Minimum valid time in days.",
"defaultValue" => 30,
"castTo" => "int",
],
];
}
}

View File

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

View File

@@ -2,209 +2,187 @@
namespace Kelunik\AcmeClient\Commands;
use Amp\Dns as dns;
use Amp\CoroutineResult;
use Amp\Dns\Record;
use Amp\Promise;
use Generator;
use Exception;
use Kelunik\Acme\AcmeClient;
use Kelunik\Acme\AcmeException;
use Kelunik\Acme\AcmeService;
use Kelunik\Acme\KeyPair;
use Kelunik\Acme\OpenSSLKeyGenerator;
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 League\CLImate\CLImate;
use stdClass;
use Throwable;
use function Amp\all;
use function Amp\any;
use function Amp\resolve;
class Issue implements Command {
private $logger;
private $climate;
public function __construct(LoggerInterface $logger) {
$this->logger = $logger;
public function __construct(CLImate $climate) {
$this->climate = $climate;
}
public function execute(Manager $args): Promise {
return resolve($this->doExecute($args));
public function execute(Manager $args) {
return \Amp\resolve($this->doExecute($args));
}
private function doExecute(Manager $args): Generator {
if (posix_geteuid() !== 0) {
throw new AcmeException("Please run this script as root!");
}
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;
$user = $args->get("user") ?? "www-data";
$server = $args->get("server");
$protocol = substr($server, 0, strpos("://", $server));
if (!$protocol || $protocol === $server) {
$server = "https://" . $server;
} elseif ($protocol !== "https") {
throw new \InvalidArgumentException("Invalid server protocol, only HTTPS supported");
}
$domains = $args->get("domains");
$domains = array_map("trim", explode(",", $domains));
yield from $this->checkDnsRecords($domains);
$keyPair = $this->checkRegistration($args);
$acme = new AcmeService(new AcmeClient($server, $keyPair), $keyPair);
foreach ($domains as $domain) {
list($location, $challenges) = yield $acme->requestChallenges($domain);
$goodChallenges = $this->findSuitableCombination($challenges);
if (empty($goodChallenges)) {
throw new AcmeException("Couldn't find any combination of challenges which this server can solve!");
}
$challenge = $challenges->challenges[reset($goodChallenges)];
$token = $challenge->token;
if (!preg_match("#^[a-zA-Z0-9-_]+$#", $token)) {
throw new AcmeException("Protocol Violation: Invalid Token!");
}
$this->logger->debug("Generating payload...");
$payload = $acme->generateHttp01Payload($token);
$docRoot = rtrim($args->get("path") ?? __DIR__ . "/../../data/public", "/\\");
$path = $docRoot . "/.well-known/acme-challenge";
try {
if (!file_exists($docRoot)) {
throw new AcmeException("Document root doesn't exist: " . $docRoot);
if ($currentUsername !== $user) {
throw new AcmeException("Running this script with --user only works as root!");
}
if (!file_exists($path) && !@mkdir($path, 0770, true)) {
throw new AcmeException("Couldn't create public dir to serve the challenges: " . $path);
}
if (!$userInfo = posix_getpwnam($user)) {
throw new AcmeException("Unknown user: " . $user);
}
chown($docRoot . "/.well-known", $userInfo["uid"]);
chown($docRoot . "/.well-known/acme-challenge", $userInfo["uid"]);
$this->logger->info("Providing payload for {$domain} at {$path}/{$token}");
file_put_contents("{$path}/{$token}", $payload);
chown("{$path}/{$token}", $userInfo["uid"]);
chmod("{$path}/{$token}", 0660);
yield $acme->selfVerify($domain, $token, $payload);
$this->logger->info("Successfully self-verified challenge.");
yield $acme->answerChallenge($challenge->uri, $payload);
$this->logger->info("Answered challenge... waiting");
yield $acme->pollForChallenge($location);
$this->logger->info("Challenge successful. {$domain} is now authorized.");
@unlink("{$path}/{$token}");
} catch (Throwable $e) {
// no finally because generators...
@unlink("{$path}/{$token}");
throw $e;
} else {
$user = $args->get("user") ?: "www-data";
}
}
$path = __DIR__ . "/../../data/live/" . reset($domains);
$domains = array_map("trim", explode(":", str_replace([",", ";"], ":", $args->get("domains"))));
yield \Amp\resolve($this->checkDnsRecords($domains));
if (!file_exists($path) && !mkdir($path, 0700, true)) {
throw new AcmeException("Couldn't create directory: {$path}");
$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 (file_exists($path . "/private.pem") && file_exists($path . "/public.pem")) {
$private = file_get_contents($path . "/private.pem");
$public = file_get_contents($path . "/public.pem");
$this->logger->info("Using existing domain key found at {$path}");
$domainKeys = new KeyPair($private, $public);
} else {
$domainKeys = (new OpenSSLKeyGenerator)->generate(2048);
file_put_contents($path . "/private.pem", $domainKeys->getPrivate());
file_put_contents($path . "/public.pem", $domainKeys->getPublic());
$this->logger->info("Saved new domain key at {$path}");
chmod($path . "/private.pem", 0600);
chmod($path . "/public.pem", 0600);
if (count($domains) > count($docRoots)) {
$docRoots = array_merge(
$docRoots,
array_fill(count($docRoots), count($domains) - count($docRoots), end($docRoots))
);
}
$this->logger->info("Requesting certificate ...");
$keyStore = new KeyStore(\Kelunik\AcmeClient\normalizePath($args->get("storage")));
$location = yield $acme->requestCertificate($domainKeys, $domains);
$certificates = yield $acme->pollForCertificate($location);
$server = \Kelunik\AcmeClient\resolveServer($args->get("server"));
$keyFile = \Kelunik\AcmeClient\serverToKeyname($server);
$this->logger->info("Saving certificate ...");
try {
$keyPair = (yield $keyStore->get("accounts/{$keyFile}.pem"));
} catch (KeyStoreException $e) {
throw new AcmeException("Account key not found, did you run 'bin/acme setup'?", 0, $e);
}
file_put_contents($path . "/cert.pem", reset($certificates));
file_put_contents($path . "/fullchain.pem", implode("\n", $certificates));
$this->climate->br();
array_shift($certificates);
file_put_contents($path . "/chain.pem", implode("\n", $certificates));
$acme = new AcmeService(new AcmeClient($server, $keyPair));
$promises = [];
$this->logger->info("Successfully issued certificate.");
foreach ($domains as $i => $domain) {
$promises[] = \Amp\resolve($this->solveChallenge($acme, $keyPair, $domain, $docRoots[$i]));
}
list($errors) = (yield \Amp\any($promises));
if (!empty($errors)) {
foreach ($errors as $error) {
$this->climate->error($error->getMessage());
}
throw new AcmeException("Issuance failed, not all challenges could be solved.");
}
$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->climate->br();
$this->climate->whisper(" Requesting certificate ...");
$location = (yield $acme->requestCertificate($keyPair, $domains));
$certificates = (yield $acme->pollForCertificate($location));
$path = \Kelunik\AcmeClient\normalizePath($args->get("storage")) . "/certs/" . $keyFile;
$certificateStore = new CertificateStore($path);
yield $certificateStore->put($certificates);
$this->climate->info(" Successfully issued certificate.");
$this->climate->info(" See {$path}/" . reset($domains));
$this->climate->br();
yield new CoroutineResult(0);
}
private function checkDnsRecords($domains): Generator {
private function solveChallenge(AcmeService $acme, KeyPair $keyPair, $domain, $path) {
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!");
}
$payload = $acme->generateHttp01Payload($keyPair, $token);
$this->climate->whisper(" Providing payload at http://{$domain}/.well-known/acme-challenge/{$token}");
$challengeStore = new ChallengeStore($path);
try {
$challengeStore->put($token, $payload, isset($user) ? $user : null);
yield $acme->verifyHttp01Challenge($domain, $token, $payload);
yield $acme->answerChallenge($challenge->uri, $payload);
yield $acme->pollForChallenge($location);
$this->climate->comment(" {$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;
}
}
private function checkDnsRecords($domains) {
$promises = [];
foreach ($domains as $domain) {
$promises[$domain] = dns\resolve($domain, [
$promises[$domain] = \Amp\Dns\resolve($domain, [
"types" => [Record::A],
"hosts" => false,
]);
}
list($errors) = yield any($promises);
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 checkRegistration(Manager $args) {
$server = $args->get("server");
$protocol = substr($server, 0, strpos("://", $server));
if (!$protocol || $protocol === $server) {
$server = "https://" . $server;
} elseif ($protocol !== "https") {
throw new \InvalidArgumentException("Invalid server protocol, only HTTPS supported");
}
$identity = str_replace(["/", "%"], "-", substr($server, 8));
$path = __DIR__ . "/../../data/accounts";
$pathPrivate = "{$path}/{$identity}.private.key";
$pathPublic = "{$path}/{$identity}.public.key";
if (file_exists($pathPrivate) && file_exists($pathPublic)) {
$private = file_get_contents($pathPrivate);
$public = file_get_contents($pathPublic);
$this->logger->info("Found account keys.");
return new KeyPair($private, $public);
}
throw new AcmeException("No registration found for server, please register first");
}
private function findSuitableCombination(stdClass $response): array {
$challenges = $response->challenges ?? [];
$combinations = $response->combinations ?? [];
private function findSuitableCombination(stdClass $response) {
$challenges = isset($response->challenges) ? $response->challenges : [];
$combinations = isset($response->combinations) ? $response->combinations : [];
$goodChallenges = [];
foreach ($challenges as $i => $challenge) {
@@ -222,31 +200,32 @@ class Issue implements Command {
return $goodChallenges;
}
public static function getDefinition(): array {
public static function getDefinition() {
return [
"server" => \Kelunik\AcmeClient\getArgumentDescription("server"),
"storage" => \Kelunik\AcmeClient\getArgumentDescription("storage"),
"domains" => [
"prefix" => "d",
"longPrefix" => "domains",
"description" => "Domains to request a certificate for.",
"description" => "Colon / Semicolon / Comma separated list of domains to request a certificate for.",
"required" => true,
],
"server" => [
"prefix" => "s",
"longPrefix" => "server",
"description" => "ACME server to use for authorization.",
"required" => true,
],
"user" => [
"prefix" => "s",
"longPrefix" => "user",
"description" => "User for the public directory.",
"required" => false,
],
"path" => [
"prefix" => "p",
"longPrefix" => "path",
"description" => "Path to the document root for ACME challenges.",
"required" => false,
"description" => "Colon (Unix) / Semicolon (Windows) separated list of paths to the document roots. The last one will be used for all remaining ones if fewer than the amount of domains is given.",
"required" => true,
],
"user" => [
"prefix" => "u",
"longPrefix" => "user",
"description" => "User running the web server.",
],
"bits" => [
"longPrefix" => "bits",
"description" => "Length of the private key in bit.",
"defaultValue" => 2048,
"castTo" => "int",
],
];
}

View File

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

View File

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

99
src/Commands/Setup.php Normal file
View File

@@ -0,0 +1,99 @@
<?php
namespace Kelunik\AcmeClient\Commands;
use Amp\CoroutineResult;
use Amp\Dns\Record;
use Amp\Dns\ResolutionException;
use InvalidArgumentException;
use Kelunik\Acme\AcmeClient;
use Kelunik\Acme\AcmeException;
use Kelunik\Acme\AcmeService;
use Kelunik\Acme\OpenSSLKeyGenerator;
use Kelunik\Acme\Registration;
use Kelunik\AcmeClient\Stores\KeyStore;
use Kelunik\AcmeClient\Stores\KeyStoreException;
use League\CLImate\Argument\Manager;
use League\CLImate\CLImate;
class Setup implements Command {
private $climate;
public function __construct(CLImate $climate) {
$this->climate = $climate;
}
public function execute(Manager $args) {
return \Amp\resolve($this->doExecute($args));
}
public function doExecute(Manager $args) {
$email = $args->get("email");
yield \Amp\resolve($this->checkEmail($email));
$server = \Kelunik\AcmeClient\resolveServer($args->get("server"));
$keyFile = \Kelunik\AcmeClient\serverToKeyname($server);
$path = "accounts/{$keyFile}.pem";
$bits = 4096;
$keyStore = new KeyStore(\Kelunik\AcmeClient\normalizePath($args->get("storage")));
$this->climate->br();
try {
$keyPair = (yield $keyStore->get($path));
$this->climate->whisper(" Using existing private key ...");
} catch (KeyStoreException $e) {
$this->climate->whisper(" No private key found, generating new one ...");
$keyPair = (new OpenSSLKeyGenerator)->generate($bits);
$keyPair = (yield $keyStore->put($path, $keyPair));
$this->climate->whisper(" Generated new private key with {$bits} bits.");
}
$acme = new AcmeService(new AcmeClient($server, $keyPair), $keyPair);
$this->climate->whisper(" Registering with " . substr($server, 8) . " ...");
/** @var Registration $registration */
$registration = (yield $acme->register($email));
$this->climate->info(" Registration successful. Contacts: " . implode(", ", $registration->getContact()));
$this->climate->br();
yield new CoroutineResult(0);
}
private function checkEmail($email) {
if (!is_string($email)) {
throw new InvalidArgumentException(sprintf("\$email must be of type string, %s given.", gettype($email)));
}
$host = substr($email, strrpos($email, "@") + 1);
if (!$host) {
throw new AcmeException("Invalid contact email: '{$email}'");
}
try {
yield \Amp\Dns\query($host, Record::MX);
} catch (ResolutionException $e) {
throw new AcmeException("No MX record defined for '{$host}'");
}
}
public static function getDefinition() {
return [
"server" => \Kelunik\AcmeClient\getArgumentDescription("server"),
"storage" => \Kelunik\AcmeClient\getArgumentDescription("storage"),
"email" => [
"longPrefix" => "email",
"description" => "E-mail for important issues, will be sent to the ACME server.",
"required" => true,
],
];
}
}

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

@@ -0,0 +1,93 @@
<?php
namespace Kelunik\AcmeClient\Stores;
use Amp\CoroutineResult;
use Amp\File\FilesystemException;
use InvalidArgumentException;
use Kelunik\Certificate\Certificate;
use Webmozart\Assert\Assert;
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 get($name) {
return \Amp\resolve($this->doGet($name));
}
private function doGet($name) {
Assert::string($name, "Name must be a string. Got: %s");
try {
$contents = (yield \Amp\File\get($this->root . "/" . $name . "/cert.pem"));
yield new CoroutineResult($contents);
} catch (FilesystemException $e) {
throw new CertificateStoreException("Failed to load certificate.", 0, $e);
}
}
public function put(array $certificates) {
return \Amp\resolve($this->doPut($certificates));
}
private function doPut(array $certificates) {
if (empty($certificates)) {
throw new InvalidArgumentException("Empty array not allowed");
}
$cert = new Certificate($certificates[0]);
$commonName = $cert->getSubject()->getCommonName();
if (!$commonName) {
throw new CertificateStoreException("Certificate doesn't have a common name.");
}
// See https://github.com/amphp/dns/blob/4c4d450d4af26fc55dc56dcf45ec7977373a38bf/lib/functions.php#L83
if (isset($commonName[253]) || !preg_match("~^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9]){0,1})(?:\\.[a-z0-9][a-z0-9-]{0,61}[a-z0-9])*$~i", $commonName)) {
throw new CertificateStoreException("Invalid common name: '{$commonName}'");
}
try {
$chain = array_slice($certificates, 1);
$path = $this->root . "/" . $commonName;
$realpath = realpath($path);
if (!$realpath && !mkdir($path, 0775, true)) {
throw new FilesystemException("Couldn't create certificate directory: '{$path}'");
}
yield \Amp\File\put($path . "/cert.pem", $certificates[0]);
yield \Amp\File\chmod($path . "/cert.pem", 0644);
yield \Amp\File\put($path . "/fullchain.pem", implode("\n", $certificates));
yield \Amp\File\chmod($path . "/fullchain.pem", 0644);
yield \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);
}
}
public function delete($name) {
return \Amp\resolve($this->doDelete($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

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

View File

@@ -0,0 +1,73 @@
<?php
namespace Kelunik\AcmeClient\Stores;
use InvalidArgumentException;
use Webmozart\Assert\Assert;
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 put($token, $payload, $user = null) {
return \Amp\resolve($this->doPut($token, $payload, $user));
}
private function doPut($token, $payload, $user = null) {
Assert::string($token, "Token must be a string. Got: %s");
Assert::string($payload, "Payload must be a string. Got: %s");
Assert::nullOrString($user, "User must be a string or null. Got: %s");
$path = $this->docroot . "/.well-known/acme-challenge";
$realpath = realpath($path);
if (!realpath($this->docroot)) {
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)) {
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);
}
yield \Amp\File\put("{$path}/{$token}", $payload);
if (isset($userInfo)) {
yield \Amp\File\chown("{$path}/{$token}", $userInfo["uid"], -1);
}
yield \Amp\File\chmod("{$path}/{$token}", 0644);
}
public function delete($token) {
return \Amp\resolve($this->doDelete($token));
}
private function doDelete($token) {
Assert::string($token, "Token must be a string. Got: %s");
$path = $this->docroot . "/.well-known/acme-challenge/{$token}";
$realpath = realpath($path);
if ($realpath) {
yield \Amp\File\unlink($realpath);
}
}
}

View File

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

82
src/Stores/KeyStore.php Normal file
View File

@@ -0,0 +1,82 @@
<?php
namespace Kelunik\AcmeClient\Stores;
use Amp\CoroutineResult;
use Amp\File\FilesystemException;
use InvalidArgumentException;
use Kelunik\Acme\KeyPair;
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 get($path) {
if (!is_string($path)) {
throw new InvalidArgumentException(sprintf("\$root must be of type string, %s given.", gettype($path)));
}
return \Amp\resolve($this->doGet($path));
}
private function doGet($path) {
if (!is_string($path)) {
throw new InvalidArgumentException(sprintf("\$root must be of type string, %s given.", gettype($path)));
}
$file = $this->root . "/" . $path;
$realpath = realpath($file);
if (!$realpath) {
throw new KeyStoreException("File not found: '{$file}'");
}
$privateKey = (yield \Amp\File\get($realpath));
$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 \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);
}
}

View File

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

172
src/functions.php Normal file
View File

@@ -0,0 +1,172 @@
<?php
namespace Kelunik\AcmeClient;
use Kelunik\Acme\AcmeException;
use Phar;
use Symfony\Component\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Yaml;
use Webmozart\Assert\Assert;
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");
$badCommand = strtolower($badCommand);
$bestMatch = "";
$bestMatchPercentage = 0;
$byRefPercentage = 0;
foreach ($commands as $command) {
\similar_text($badCommand, strtolower($command), $byRefPercentage);
if ($byRefPercentage > $bestMatchPercentage) {
$bestMatchPercentage = $byRefPercentage;
$bestMatch = $command;
}
}
return $bestMatchPercentage >= $suggestThreshold ? $bestMatch : "";
}
function resolveServer($uri) {
Assert::string($uri, "URI must be a string. Got: %s");
$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",
];
if (isset($shortcuts[$uri])) {
return $shortcuts[$uri];
}
$protocol = substr($uri, 0, strpos($uri, "://"));
if (!$protocol || $protocol === $uri) {
return "https://{$uri}";
} else {
return $uri;
}
}
function serverToKeyname($server) {
$server = substr($server, strpos($server, "://") + 3);
$keyFile = str_replace("/", ".", $server);
$keyFile = preg_replace("@[^a-z0-9._-]@", "", $keyFile);
$keyFile = preg_replace("@\\.+@", ".", $keyFile);
return $keyFile;
}
function isPhar() {
if (!class_exists("Phar")) {
return false;
}
return Phar::running(true) !== "";
}
function normalizePath($path) {
return rtrim(str_replace("\\", "/", $path), "/");
}
function getArgumentDescription($argument) {
$isPhar = \Kelunik\AcmeClient\isPhar();
$config = [];
if ($isPhar) {
$configPath = substr(dirname(Phar::running(true)), strlen("phar://")) . "/acme-client.yml";
if (file_exists($configPath)) {
$configContent = file_get_contents($configPath);
try {
$value = Yaml::parse($configContent);
if (isset($value["server"]) && is_string($value["server"])) {
$config["server"] = $value["server"];
unset($value["server"]);
}
if (isset($value["storage"]) && is_string($value["storage"])) {
$config["storage"] = $value["storage"];
unset($value["storage"]);
}
if (!empty($value)) {
throw new AcmeException("Provided YAML file had unknown options: " . implode(", ", array_keys($value)));
}
} catch (ParseException $e) {
throw new AcmeException("Unable to parse the YAML file ({$configPath}): " . $e->getMessage());
}
}
}
switch ($argument) {
case "server":
$argument = [
"prefix" => "s",
"longPrefix" => "server",
"description" => "ACME server to use for registration and issuance of certificates.",
"required" => true,
];
if (isset($config["server"])) {
$argument["required"] = false;
$argument["defaultValue"] = $config["server"];
}
return $argument;
case "storage":
$argument = [
"longPrefix" => "storage",
"description" => "Storage directory for account keys and certificates.",
"required" => $isPhar,
];
if (!$isPhar) {
$argument["defaultValue"] = dirname(__DIR__) . "/data";
} else if (isset($config["storage"])) {
$argument["required"] = false;
$argument["defaultValue"] = $config["storage"];
}
return $argument;
default:
throw new \InvalidArgumentException("Unknown argument: " . $argument);
}
}
function getBinary() {
$binary = "bin/acme";
if (isPhar()) {
$binary = substr(Phar::running(true), strlen("phar://"));
$path = getenv("PATH");
$locations = explode(PATH_SEPARATOR, $path);
$binaryPath = dirname($binary);
foreach ($locations as $location) {
if ($location === $binaryPath) {
return substr($binary, strlen($binaryPath) + 1);
}
}
$cwd = getcwd();
if ($cwd && strpos($binary, $cwd) === 0) {
$binary = "." . substr($binary, strlen($cwd));
}
}
return $binary;
}

29
test/FunctionsTest.php Normal file
View File

@@ -0,0 +1,29 @@
<?php
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"));
}
public function testSuggestCommand() {
$this->assertSame("acme", suggestCommand("acme!", ["acme"]));
$this->assertSame("", suggestCommand("issue", ["acme"]));
}
public function testIsPhar() {
$this->assertFalse(isPhar());
}
public function testNormalizePath() {
$this->assertSame("/etc/foobar", normalizePath("/etc/foobar"));
$this->assertSame("/etc/foobar", normalizePath("/etc/foobar/"));
$this->assertSame("/etc/foobar", normalizePath("/etc/foobar/"));
$this->assertSame("C:/etc/foobar", normalizePath("C:\\etc\\foobar\\"));
}
}