20 Commits

Author SHA1 Message Date
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
18 changed files with 3560 additions and 213 deletions

1
.gitignore vendored
View File

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

View File

@@ -13,6 +13,7 @@ cache:
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

158
README.md
View File

@@ -1,154 +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 5.5+ with OpenSSL
* Works on Unix and Windows
## Installation
## Documentation
**Requirements**
* PHP 5.5+
* Composer
**Instructions using the Phar**
```bash
# Go to https://github.com/kelunik/acme-client/releases/latest
# Download the latest release archive
# Run it.
chmod +x acme-client.phar
./acme-client.phar
# Or install it globally
mv ./acme-client.phar /usr/local/bin/acme-client
```
If you want to update, just replace the old phar with a new one.
All commands require an additional `--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`.
**Instructions using Composer**
```bash
# Clone repository
git clone https://github.com/kelunik/acme-client && cd acme-client
# Checkout latest release
git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
# Install dependencies
composer install --no-dev
```
## Migration from 0.1.x to 0.2.x
```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
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.
```
## 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 -s letsencrypt
```
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 ...
```
You can also use a more advanced script to automatically reload the server as well.
```bash
#!/usr/bin/env bash
cd /git/kelunik/acme-client
bin/acme check --name example.com --ttl 30 -s letsencrypt
if [ $? -eq 1 ]; then
bin/acme issue -d example.com:www.example.com -p /var/www -s letsencrypt
if [ $? -eq 0 ]; then
nginx -t -q
if [ $? -eq 0 ]; then
nginx -s reload
fi
fi
fi
```
* [Installation](./doc/installation.md)
* [Usage](./doc/usage.md)
* [Migration guide for 0.1.x → 0.2.x](./doc/migrations/0.2.0.md)

109
bin/acme
View File

@@ -2,17 +2,22 @@
<?php
use Auryn\Injector;
use Kelunik\AcmeClient\AcmeFactory;
use League\CLImate\CLImate;
$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
@@ -21,56 +26,71 @@ HELP;
exit(-1);
}
if (!function_exists("openssl_pkey_get_private")) {
echo $logo;
echo <<<HELP
You need to enable OpenSSL in your php.ini
HELP;
exit(-2);
}
require __DIR__ . "/../vendor/autoload.php";
$commands = [
"setup",
"issue",
"check",
"revoke",
"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 = \Kelunik\AcmeClient\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!");
$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)) {
$climate->out($help);
if (count($argv) === 1 || in_array($argv[1], ["-h", "help", "--help"], true)) {
$climate->out($logo . $help);
exit(0);
}
if (!in_array($argv[1], $commands)) {
$climate->br()->error(" Unknown command '{$argv[1]}'. Use --help for a list of available commands.");
if (!in_array($argv[1], array_keys($commands))) {
$climate->error("Unknown command '{$argv[1]}'. Use --help for a list of available commands.");
$suggestion = \Kelunik\AcmeClient\suggestCommand($argv[1], $commands);
$suggestion = \Kelunik\AcmeClient\suggestCommand($argv[1], array_keys($commands));
if ($suggestion) {
$climate->br()->out(" Did you mean '$suggestion'?");
$climate->br()->out(" Did you mean '$suggestion'?");
}
$climate->br();
@@ -87,25 +107,28 @@ try {
unset($args[1]);
$climate->arguments->add($definition);
$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]}"]);
if (count($argv) === 3 && in_array($argv[2], ["-h", "--help"], true)) {
$climate->usage(["{$binary} {$argv[1]}"]);
$climate->br();
exit(0);
} else {
$climate->usage(["bin/acme {$argv[1]}"]);
$climate->br();
$climate->error($e->getMessage());
$climate->br();
exit(1);
$climate->arguments->parse(array_values($args));
}
} catch (Exception $e) {
$climate->usage(["{$binary} {$argv[1]}"]);
$climate->br();
$climate->error($e->getMessage());
$climate->br();
exit(1);
}
$injector = new Injector;
$injector->share($climate);
$injector->share(new AcmeFactory);
$command = $injector->make($class);
@@ -128,7 +151,7 @@ Amp\run(function () use ($command, $climate) {
$exitCode = (yield $command->execute($climate->arguments));
if ($exitCode === null) {
$logger->warning("Invalid exit code: null, falling back to 0. Please consider reporting this as bug.");
exit(0);
}
exit($exitCode);

View File

@@ -1,6 +1,6 @@
{
"name": "kelunik/acme-client",
"description": "Standalone PHP ACME client.",
"description": "Let's Encrypt / ACME client written in PHP for the CLI.",
"keywords": [
"ACME",
"letsencrypt",
@@ -18,12 +18,13 @@
"kelunik/certificate": "^1",
"league/climate": "^3",
"rdlowrey/auryn": "^1",
"webmozart/assert": "^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"
"macfja/phar-builder": "dev-events-dev-files"
},
"license": "MIT",
"authors": [
@@ -42,13 +43,30 @@
"src/functions.php"
]
},
"repositories": [
{
"type": "vcs",
"url": "https://github.com/kelunik/pharbuilder"
}
],
"extra": {
"phar-builder": {
"compression": "GZip",
"name": "acme-client.phar",
"output-dir": "build",
"include": ["src", "vendor/kelunik/acme/res"],
"entry-point": "bin/acme"
"include": ["info", "src", "vendor/kelunik/acme/res", "vendor/amphp/socket/var"],
"entry-point": "bin/acme",
"events": {
"command.package.start": [
"mkdir -p info",
"git describe --tags > info/build.version",
"php -r 'echo time();' > info/build.time"
],
"command.package.end": [
"rm -rf info",
"chmod +x build/acme-client.phar"
]
}
}
}
}

2941
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

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
```

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

16
src/AcmeFactory.php Normal file
View File

@@ -0,0 +1,16 @@
<?php
namespace Kelunik\AcmeClient;
use Kelunik\Acme\AcmeClient;
use Kelunik\Acme\AcmeService;
use Kelunik\Acme\KeyPair;
use Webmozart\Assert\Assert;
class AcmeFactory {
public function build($directory, KeyPair $keyPair) {
Assert::string($directory);
return new AcmeService(new AcmeClient($directory, $keyPair));
}
}

View File

@@ -5,11 +5,11 @@ namespace Kelunik\AcmeClient\Commands;
use Amp\CoroutineResult;
use Amp\Dns\Record;
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\AcmeFactory;
use Kelunik\AcmeClient\Stores\CertificateStore;
use Kelunik\AcmeClient\Stores\ChallengeStore;
use Kelunik\AcmeClient\Stores\KeyStore;
@@ -21,9 +21,11 @@ use Throwable;
class Issue implements Command {
private $climate;
private $acmeFactory;
public function __construct(CLImate $climate) {
public function __construct(CLImate $climate, AcmeFactory $acmeFactory) {
$this->climate = $climate;
$this->acmeFactory = $acmeFactory;
}
public function execute(Manager $args) {
@@ -77,7 +79,7 @@ class Issue implements Command {
$this->climate->br();
$acme = new AcmeService(new AcmeClient($server, $keyPair));
$acme = $this->acmeFactory->build($server, $keyPair);
$promises = [];
foreach ($domains as $i => $domain) {
@@ -176,7 +178,7 @@ class Issue implements Command {
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)));
throw new AcmeException("Couldn't resolve the following domains to an IPv4 record: " . implode(", ", array_keys($errors)));
}
}

View File

@@ -4,8 +4,7 @@ namespace Kelunik\AcmeClient\Commands;
use Amp\CoroutineResult;
use Amp\File\FilesystemException;
use Kelunik\Acme\AcmeClient;
use Kelunik\Acme\AcmeService;
use Kelunik\AcmeClient\AcmeFactory;
use Kelunik\AcmeClient\Stores\CertificateStore;
use Kelunik\AcmeClient\Stores\KeyStore;
use Kelunik\Certificate\Certificate;
@@ -14,9 +13,11 @@ use League\CLImate\CLImate;
class Revoke implements Command {
private $climate;
private $acmeFactory;
public function __construct(CLImate $climate) {
public function __construct(CLImate $climate, AcmeFactory $acmeFactory) {
$this->climate = $climate;
$this->acmeFactory = $acmeFactory;
}
public function execute(Manager $args) {
@@ -30,7 +31,7 @@ class Revoke implements Command {
$keyFile = \Kelunik\AcmeClient\serverToKeyname($server);
$keyPair = (yield $keyStore->get("accounts/{$keyFile}.pem"));
$acme = new AcmeService(new AcmeClient($server, $keyPair), $keyPair);
$acme = $this->acmeFactory->build($server, $keyPair);
$this->climate->br();
$this->climate->whisper(" Revoking certificate ...");
@@ -57,7 +58,7 @@ class Revoke implements Command {
$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 CertificateStore(\Kelunik\AcmeClient\normalizePath($args->get("storage")) . "/certs/" . $keyFile))->delete($args->get("name"));
yield new CoroutineResult(0);
}

View File

@@ -6,11 +6,10 @@ 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\AcmeFactory;
use Kelunik\AcmeClient\Stores\KeyStore;
use Kelunik\AcmeClient\Stores\KeyStoreException;
use League\CLImate\Argument\Manager;
@@ -18,9 +17,11 @@ use League\CLImate\CLImate;
class Setup implements Command {
private $climate;
private $acmeFactory;
public function __construct(CLImate $climate) {
public function __construct(CLImate $climate, AcmeFactory $acmeFactory) {
$this->climate = $climate;
$this->acmeFactory = $acmeFactory;
}
public function execute(Manager $args) {
@@ -53,7 +54,7 @@ class Setup implements Command {
$this->climate->whisper(" Generated new private key with {$bits} bits.");
}
$acme = new AcmeService(new AcmeClient($server, $keyPair), $keyPair);
$acme = $this->acmeFactory->build($server, $keyPair);
$this->climate->whisper(" Registering with " . substr($server, 8) . " ...");

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

@@ -0,0 +1,80 @@
<?php
namespace Kelunik\AcmeClient\Commands;
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;
class Status {
private $climate;
public function __construct(CLImate $climate) {
$this->climate = $climate;
}
public function execute(Manager $args) {
return \Amp\resolve($this->doExecute($args));
}
/**
* @param Manager $args
* @return \Generator
*/
private function doExecute(Manager $args) {
$server = \Kelunik\AcmeClient\resolveServer($args->get("server"));
$keyName = \Kelunik\AcmeClient\serverToKeyname($server);
$storage = \Kelunik\AcmeClient\normalizePath($args->get("storage"));
try {
$keyStore = new KeyStore($storage);
yield $keyStore->get("accounts/{$keyName}.pem");
$setup = true;
} catch (KeyStoreException $e) {
$setup = false;
}
$this->climate->br();
$this->climate->out(" [" . ($setup ? "<green> ✓ </green>" : "<red> ✗ </red>") . "] " . ($setup ? "Registered on " : "Not yet registered on ") . $server);
$this->climate->br();
if (yield \Amp\File\exists($storage . "/certs/{$keyName}")) {
$certificateStore = new CertificateStore($storage . "/certs/{$keyName}");
$domains = (yield \Amp\File\scandir($storage . "/certs/{$keyName}"));
foreach ($domains as $domain) {
$pem = (yield $certificateStore->get($domain));
$cert = new Certificate($pem);
$symbol = time() > $cert->getValidTo() ? "<red> ✗ </red>" : "<green> ✓ </green>";
if (time() < $cert->getValidTo() && time() + $args->get("ttl") * 24 * 60 * 60 > $cert->getValidTo()) {
$symbol = "<yellow> ⭮ </yellow>";
}
$this->climate->out(" [" . $symbol . "] " . implode(", ", $cert->getNames()));
}
$this->climate->br();
}
}
public static function getDefinition() {
return [
"server" => \Kelunik\AcmeClient\getArgumentDescription("server"),
"storage" => \Kelunik\AcmeClient\getArgumentDescription("storage"),
"ttl" => [
"longPrefix" => "ttl",
"description" => "Minimum valid time in days, shows ⭮ if renewal is required.",
"defaultValue" => 30,
"castTo" => "int",
],
];
}
}

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

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

View File

@@ -2,9 +2,21 @@
namespace Kelunik\AcmeClient;
use InvalidArgumentException;
use Kelunik\Acme\AcmeException;
use Phar;
use Symfony\Component\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Yaml;
use Webmozart\Assert\Assert;
/**
* 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($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");
@@ -27,6 +39,13 @@ function suggestCommand($badCommand, array $commands, $suggestThreshold = 70) {
return $bestMatchPercentage >= $suggestThreshold ? $bestMatch : "";
}
/**
* Resolves a server to a valid URI. If a valid shortcut is passed, it's resolved to the defined URI. If a URI without
* protocol is passed, it will default to HTTPS.
*
* @param string $uri URI to resolve
* @return string resolved URI
*/
function resolveServer($uri) {
Assert::string($uri, "URI must be a string. Got: %s");
@@ -40,6 +59,10 @@ function resolveServer($uri) {
return $shortcuts[$uri];
}
if (strpos($uri, "/") === false) {
throw new InvalidArgumentException("Invalid server URI: " . $uri);
}
$protocol = substr($uri, 0, strpos($uri, "://"));
if (!$protocol || $protocol === $uri) {
@@ -49,6 +72,12 @@ function resolveServer($uri) {
}
}
/**
* Transforms a directory URI to a valid filename for usage as key file name.
*
* @param string $server URI to the directory
* @return string identifier usable as file name
*/
function serverToKeyname($server) {
$server = substr($server, strpos($server, "://") + 3);
@@ -59,6 +88,11 @@ function serverToKeyname($server) {
return $keyFile;
}
/**
* Checks whether the application is currently running as Phar.
*
* @return bool {@code true} if running as Phar, {@code false} otherwise
*/
function isPhar() {
if (!class_exists("Phar")) {
return false;
@@ -67,22 +101,72 @@ function isPhar() {
return Phar::running(true) !== "";
}
/**
* Normalizes a path. Replaces all backslashes with slashes and removes trailing slashes.
*
* @param string $path path to normalize
* @return string normalized path
*/
function normalizePath($path) {
return rtrim(str_replace("\\", "/", $path), "/");
}
/**
* 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
*/
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":
return [
$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",
@@ -92,11 +176,68 @@ function getArgumentDescription($argument) {
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);
throw new InvalidArgumentException("Unknown argument: " . $argument);
}
}
/**
* Returns the binary that currently runs. Can be included in help texts about other commands.
*
* @return string binary callable, shortened based on PATH and CWD
*/
function getBinary() {
$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;
}
/**
* 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($text, $max = 70, $append = "") {
if (strlen($text) <= $max) {
return $text;
}
$out = substr($text, 0, $max);
if (strpos($text, " ") === false) {
return $out . $append;
}
return preg_replace("/\\w+$/", "", $out) . $append;
}