33 Commits

Author SHA1 Message Date
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
19 changed files with 896 additions and 388 deletions

1
.gitignore vendored
View File

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

View File

@@ -6,14 +6,67 @@
`kelunik/acme-client` is a standalone ACME client written in PHP.
It's an alternative for the [official client](https://github.com/letsencrypt/letsencrypt) which is written in python.
> **Warning**: This software is under heavy development. Use at your own risk.
> **Warning**: This software is under development. Use at your own risk.
## Installation
**Requirements**
* PHP 5.5+
* Composer
**Instructions**
```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
```
git clone https://github.com/kelunik/acme-client
cd acme-client
composer install
## 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
@@ -28,28 +81,33 @@ By using this client you agree to any agreement and any further updates by conti
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
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.
```
sudo bin/acme issue \
--server acme-v01.api.letsencrypt.org/directory \
--domains example.com,www.example.com \
--path /var/www/example.com
bin/acme issue -s letsencrypt -d example.com:www.example.com -p /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
bin/acme revoke --name example.com
```
For renewal, there's the `bin/acme check` subcommand.
It exists with a non-zero exit code, if the certificate is going to expire soon.
Default check time is 30 days, but you can use `--ttl` to customize it.
You may use this as daily cron:
```
bin/acme check --name example.com --ttl 30 -s letsencrypt || bin/acme issue ...
```

View File

@@ -7,9 +7,38 @@ use Kelunik\AcmeClient\LoggerColorScheme;
use League\CLImate\CLImate;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use Psr\Log\LoggerInterface;
if (!file_exists(__DIR__ . "/../vendor/autoload.php")) {
echo <<<HELP
____ __________ ___ ___
/ __ `/ ___/ __ `__ \/ _ \
/ /_/ / /__/ / / / / / __/
\__,_/\___/_/ /_/ /_/\___/
You need to install the composer dependencies.
composer install --no-dev
HELP;
exit(-1);
}
require __DIR__ . "/../vendor/autoload.php";
$commands = [
"setup",
"issue",
"check",
"revoke",
];
$help = implode("\n ", array_map(function($command) {
return "bin/acme {$command}";
}, $commands));
$help = <<<EOT
____ __________ ___ ___
@@ -20,51 +49,55 @@ $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") {
if (count($argv) === 1 || in_array($argv[1], ["h", "-h", "help", "--help"], true)) {
print $help;
exit(0);
}
if (!array_key_exists($argv[1], $commands)) {
$climate->error("Unknown command: '{$argv[1]}'");
if (!in_array($argv[1], $commands)) {
$climate->error("Unknown command '{$argv[1]}'. Use --help for a list of available commands.");
$suggestion = \Kelunik\AcmeClient\suggestCommand($argv[1], $commands);
if ($suggestion) {
$climate->br()->info(" Did you mean '$suggestion'?")->br();
}
exit(1);
}
try {
$climate->arguments->add($commands[$argv[1]]::getDefinition());
$climate->arguments->parse();
} catch (Exception $e) {
if ($climate->arguments->defined("help")) {
print $help;
/** @var \Kelunik\AcmeClient\Commands\Command $class */
$class = "Kelunik\\AcmeClient\\Commands\\" . ucfirst($argv[1]);
$definition = $class::getDefinition();
try {
$args = $argv;
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]}"]);
$climate->br();
exit(0);
} else {
$climate->error($e->getMessage());
exit(1);
}
}
@@ -75,15 +108,13 @@ $handler->setFormatter(new ColoredLineFormatter(new LoggerColorScheme, null, nul
$logger = new Logger("ACME");
$logger->pushHandler($handler);
$injector->alias("Psr\\Log\\LoggerInterface", Logger::class);
$injector->alias(LoggerInterface::class, Logger::class);
$injector->share($logger);
$command = $injector->make($commands[$argv[1]]);
$command = $injector->make($class);
Amp\run(function () use ($command, $climate, $logger) {
try {
yield $command->execute($climate->arguments);
} catch (Throwable $e) {
$handler = function($e) use ($logger) {
$error = (string) $e;
$lines = explode("\n", $error);
$lines = array_filter($lines, function ($line) {
@@ -95,6 +126,14 @@ Amp\run(function () use ($command, $climate, $logger) {
}
exit(1);
};
try {
yield $command->execute($climate->arguments);
} catch (Throwable $e) {
$handler($e);
} catch (Exception $e) {
$handler($e);
}
Amp\stop();

View File

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

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>

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

@@ -0,0 +1,67 @@
<?php
namespace Kelunik\AcmeClient\Commands;
use Kelunik\AcmeClient\Stores\CertificateStore;
use Kelunik\Certificate\Certificate;
use League\CLImate\Argument\Manager;
use Psr\Log\LoggerInterface;
class Check implements Command {
private $logger;
public function __construct(LoggerInterface $logger) {
$this->logger = $logger;
}
public function execute(Manager $args) {
return \Amp\resolve($this->doExecute($args));
}
/**
* @param Manager $args
* @return \Generator
*/
private function doExecute(Manager $args) {
$server = \Kelunik\AcmeClient\resolveServer($args->get("server"));
$server = \Kelunik\AcmeClient\serverToKeyname($server);
$path = dirname(dirname(__DIR__)) . "/data/certs/" . $server;
$certificateStore = new CertificateStore($path);
$pem = (yield $certificateStore->get($args->get("name")));
$cert = new Certificate($pem);
$this->logger->info("Certificate is valid until " . date("d.m.Y", $cert->getValidTo()));
if ($cert->getValidTo() > time() + $args->get("ttl") * 24 * 60 * 60) {
exit(0);
}
$this->logger->warning("Certificate is going to expire within the specified " . $args->get("ttl") . " days.");
exit(1);
}
public static function getDefinition() {
return [
"server" => [
"prefix" => "s",
"longPrefix" => "server",
"description" => "",
"required" => true,
],
"name" => [
"longPrefix" => "name",
"description" => "Common name of the certificate to check.",
"required" => true,
],
"ttl" => [
"longPrefix" => "ttl",
"description" => "Minimum valid time in days.",
"defaultValue" => 30,
"castTo" => "int",
],
];
}
}

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,22 +2,22 @@
namespace Kelunik\AcmeClient\Commands;
use Amp\Dns as dns;
use Amp\CombinatorException;
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 stdClass;
use Throwable;
use function Amp\all;
use function Amp\any;
use function Amp\resolve;
class Issue implements Command {
private $logger;
@@ -26,146 +26,155 @@ class Issue implements Command {
$this->logger = $logger;
}
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(":", 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");
if (count($domains) > count($docRoots)) {
$docRoots = array_merge(
$docRoots,
array_fill(count($docRoots), count($domains) - count($docRoots), end($docRoots))
);
}
$this->logger->info("Using existing domain key found at {$path}");
$keyStore = new KeyStore(dirname(dirname(__DIR__)) . "/data");
$domainKeys = new KeyPair($private, $public);
} else {
$domainKeys = (new OpenSSLKeyGenerator)->generate(2048);
$server = \Kelunik\AcmeClient\resolveServer($args->get("server"));
$keyFile = \Kelunik\AcmeClient\serverToKeyname($server);
file_put_contents($path . "/private.pem", $domainKeys->getPrivate());
file_put_contents($path . "/public.pem", $domainKeys->getPublic());
try {
$keyPair = (yield $keyStore->get("accounts/{$keyFile}.pem"));
} catch (KeyStoreException $e) {
$this->logger->error("Account key not found, did you run 'bin/acme setup'?");
$this->logger->info("Saved new domain key at {$path}");
exit(1);
}
chmod($path . "/private.pem", 0600);
chmod($path . "/public.pem", 0600);
$acme = new AcmeService(new AcmeClient($server, $keyPair));
$promises = [];
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->logger->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->logger->info("Requesting certificate ...");
$location = yield $acme->requestCertificate($domainKeys, $domains);
$certificates = yield $acme->pollForCertificate($location);
$location = (yield $acme->requestCertificate($keyPair, $domains));
$certificates = (yield $acme->pollForCertificate($location));
$this->logger->info("Saving certificate ...");
$path = dirname(dirname(__DIR__)) . "/data/certs/" . $keyFile;
$certificateStore = new CertificateStore($path);
yield $certificateStore->put($certificates);
file_put_contents($path . "/cert.pem", reset($certificates));
file_put_contents($path . "/fullchain.pem", implode("\n", $certificates));
array_shift($certificates);
file_put_contents($path . "/chain.pem", implode("\n", $certificates));
$this->logger->info("Successfully issued certificate.");
$this->logger->info("Successfully issued certificate, see {$path}/" . reset($domains));
}
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!");
}
$this->logger->debug("Generating payload...");
$payload = $acme->generateHttp01Payload($keyPair, $token);
$this->logger->info("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);
$this->logger->info("Successfully self-verified challenge.");
yield $acme->answerChallenge($challenge->uri, $payload);
$this->logger->info("Answered challenge... waiting");
yield $acme->pollForChallenge($location);
$this->logger->info("Challenge successful. {$domain} is now authorized.");
yield $challengeStore->delete($token);
} catch (Exception $e) {
// no finally because generators...
yield $challengeStore->delete($token);
throw $e;
} catch (Throwable $e) {
// no finally because generators...
yield $challengeStore->delete($token);
throw $e;
}
}
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)));
@@ -174,37 +183,9 @@ class Issue implements Command {
$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 +203,36 @@ class Issue implements Command {
return $goodChallenges;
}
public static function getDefinition(): array {
public static function getDefinition() {
return [
"domains" => [
"prefix" => "d",
"longPrefix" => "domains",
"description" => "Domains to request a certificate for.",
"required" => true,
],
"server" => [
"prefix" => "s",
"longPrefix" => "server",
"description" => "ACME server to use for authorization.",
"description" => "Server to use for issuance, see also 'bin/acme setup'.",
"required" => true,
],
"user" => [
"prefix" => "s",
"longPrefix" => "user",
"description" => "User for the public directory.",
"required" => false,
"domains" => [
"prefix" => "d",
"longPrefix" => "domains",
"description" => "Colon separated list of domains to request a certificate for.",
"required" => true,
],
"path" => [
"prefix" => "p",
"longPrefix" => "path",
"description" => "Path to the document root for ACME challenges.",
"required" => false,
"description" => "Colon separated list of paths to the document roots. The last one will be used for all remaining ones if fewer than the amount of domains is given.",
"required" => true,
],
"user" => [
"prefix" => "u",
"longPrefix" => "user",
"description" => "User running the web server.",
],
"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,18 +2,17 @@
namespace Kelunik\AcmeClient\Commands;
use Amp\File\FilesystemException;
use Amp\Promise;
use Generator;
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;
class Revoke implements Command {
private $logger;
@@ -22,84 +21,52 @@ class Revoke implements Command {
$this->logger = $logger;
}
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(dirname(dirname(__DIR__)) . "/data");
$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 ...");
$pem = yield get($args->get("cert"));
$cert = new Certificate($pem);
$path = dirname(dirname(__DIR__)) . "/data/certs/" . $keyFile . "/" . $args->get("name") . "/cert.pem";
try {
$pem = (yield \Amp\File\get($path));
$cert = new Certificate($pem);
} catch (FilesystemException $e) {
throw new \RuntimeException("There's no such certificate (" . $path . ")");
}
if ($cert->getValidTo() < time()) {
$this->logger->warning("Certificate did already expire, no need to revoke it.");
return;
}
$this->logger->info("Certificate was valid for: " . implode(", ", $cert->getNames()));
yield $acme->revokeCertificate($pem);
$this->logger->info("Certificate has been revoked.");
yield (new CertificateStore(dirname(dirname(__DIR__)) . "/data/certs/" . $keyFile))->delete($args->get("name"));
}
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.",
"description" => "",
"required" => true,
],
"name" => [
"longPrefix" => "name",
"description" => "Common name of the certificate to be revoked.",
"required" => true,
],
];

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

@@ -0,0 +1,100 @@
<?php
namespace Kelunik\AcmeClient\Commands;
use Amp\Dns\Record;
use Amp\Dns\ResolutionException;
use Amp\Promise;
use Generator;
use InvalidArgumentException;
use Kelunik\Acme\AcmeClient;
use Kelunik\Acme\AcmeException;
use Kelunik\Acme\AcmeService;
use Kelunik\Acme\OpenSSLKeyGenerator;
use Kelunik\Acme\Registration;
use Kelunik\AcmeClient\Stores\KeyStore;
use Kelunik\AcmeClient\Stores\KeyStoreException;
use League\CLImate\Argument\Manager;
use Psr\Log\LoggerInterface;
use RuntimeException;
class Setup implements Command {
private $logger;
public function __construct(LoggerInterface $logger) {
$this->logger = $logger;
}
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(dirname(dirname(__DIR__)) . "/data");
try {
$this->logger->info("Loading private key ...");
$keyPair = (yield $keyStore->get($path));
$this->logger->info("Existing private key successfully loaded.");
} catch (KeyStoreException $e) {
$this->logger->info("No existing private key found, generating new one ...");
$keyPair = (new OpenSSLKeyGenerator)->generate($bits);
$this->logger->info("Generated new private key with {$bits} bits.");
$this->logger->info("Saving new private key ...");
$keyPair = (yield $keyStore->put($path, $keyPair));
$this->logger->info("New private key successfully saved.");
}
$acme = new AcmeService(new AcmeClient($server, $keyPair), $keyPair);
$this->logger->info("Registering with ACME server " . substr($server, 8) . " ...");
/** @var Registration $registration */
$registration = (yield $acme->register($email));
$this->logger->notice("Registration successful with the following contact information: " . implode(", ", $registration->getContact()));
}
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" => [
"prefix" => "s",
"longPrefix" => "server",
"description" => "ACME server to use for registration and issuance of certificates.",
"required" => true,
],
"email" => [
"longPrefix" => "email",
"description" => "Email for important issues, will be sent to the ACME server.",
"required" => true,
],
];
}
}

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 {
}

59
src/functions.php Normal file
View File

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

18
test/FunctionsTest.php Normal file
View File

@@ -0,0 +1,18 @@
<?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"]));
}
}