Major update, add renew command, rename register to setup

This commit is contained in:
Niklas Keller
2015-12-09 20:10:21 +01:00
parent b1e3839323
commit 7f0869150f
15 changed files with 517 additions and 231 deletions

View File

@@ -8,6 +8,8 @@ It's an alternative for the [official client](https://github.com/letsencrypt/let
> **Warning**: This software is under heavy development. Use at your own risk. > **Warning**: This software is under heavy development. Use at your own risk.
The client has been updated on Dec 9th in a non-backwards compatible manner. Please review the changes or use a new clone.
## Installation ## Installation
``` ```
@@ -28,7 +30,7 @@ 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. 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 \ sudo bin/acme setup \
--server acme-v01.api.letsencrypt.org/directory \ --server acme-v01.api.letsencrypt.org/directory \
--email me@example.com --email me@example.com
``` ```
@@ -39,17 +41,15 @@ You must have a document root setup in order to use this client.
``` ```
sudo bin/acme issue \ sudo bin/acme issue \
--server acme-v01.api.letsencrypt.org/directory \
--domains example.com,www.example.com \ --domains example.com,www.example.com \
--path /var/www/example.com --path /var/www/example.com
``` ```
For renewal, just run this command again. For renewal, just run this command again. If you want to automate this task, use `bin/acme renew` as your daily cron command.
It will renew certificates automatically when they're no longer than 30 days valid.
To revoke a certificate, you need a valid account key currently, just like for issuance. To revoke a certificate, you need a valid account key currently, just like for issuance.
``` ```
sudo bin/acme revoke \ sudo bin/acme revoke --name example.com
--server acme-v01.api.letsencrypt.org/directory \
--cert data/live/example.com/cert.pem
``` ```

View File

@@ -7,6 +7,8 @@ use Kelunik\AcmeClient\LoggerColorScheme;
use League\CLImate\CLImate; use League\CLImate\CLImate;
use Monolog\Handler\StreamHandler; use Monolog\Handler\StreamHandler;
use Monolog\Logger; use Monolog\Logger;
use Psr\Log\LoggerInterface;
use function Kelunik\AcmeClient\commandToClass;
require __DIR__ . "/../vendor/autoload.php"; require __DIR__ . "/../vendor/autoload.php";
@@ -20,26 +22,27 @@ $help = <<<EOT
Usage: bin/acme command --args Usage: bin/acme command --args
Available Commands: Available Commands:
bin/acme register bin/acme setup
bin/acme issue bin/acme issue
bin/acme revoke bin/acme revoke
bin/acme renew
Get more help by appending --help to specific commands. Get more help by appending --help to specific commands.
EOT; EOT;
$commands = [
"issue" => "Kelunik\\AcmeClient\\Commands\\Issue",
"register" => "Kelunik\\AcmeClient\\Commands\\Register",
"revoke" => "Kelunik\\AcmeClient\\Commands\\Revoke",
];
$climate = new CLImate; $climate = new CLImate;
$injector = new Injector; $injector = new Injector;
$commands = [
"setup",
"revoke",
"issue",
"renew",
];
if (!isset($argv)) { if (PHP_SAPI !== "phpdbg" && PHP_SAPI !== "cli") {
$climate->error("\$argv is not defined"); $climate->error("Please run this script as command line script!");
exit(1); exit(1);
} }
@@ -48,25 +51,35 @@ if (count($argv) === 1 || $argv[1] === "-h" || $argv[1] === "--help" || $argv[1]
exit(0); exit(0);
} }
if (!array_key_exists($argv[1], $commands)) { if (!in_array($argv[1], $commands)) {
$climate->error("Unknown command: '{$argv[1]}'"); $climate->error("Unknown command: '{$argv[1]}'");
exit(1); exit(1);
} }
// TODO: Implement subcommand help
$class = commandToClass($argv[1]);
$definition = $class::getDefinition();
try { try {
$climate->arguments->add($commands[$argv[1]]::getDefinition()); $climate->arguments->add($definition);
$climate->arguments->parse(); $climate->arguments->parse();
} catch (Exception $e) { } catch (Exception $e) {
if ($climate->arguments->defined("help")) { $climate->usage(["bin/acme {$argv[1]}"]);
print $help; $climate->br();
exit(0); if (count($argv) !== 3 || !in_array($argv[2], ["-h", "--help", "help"])) {
} else {
$climate->error($e->getMessage()); $climate->error($e->getMessage());
exit(1);
} }
exit(1);
}
if (posix_geteuid() !== 0) {
$climate->error("Please run this script as root!");
exit(1);
} }
$handler = new StreamHandler("php://stdout", Logger::DEBUG); $handler = new StreamHandler("php://stdout", Logger::DEBUG);
@@ -75,10 +88,10 @@ $handler->setFormatter(new ColoredLineFormatter(new LoggerColorScheme, null, nul
$logger = new Logger("ACME"); $logger = new Logger("ACME");
$logger->pushHandler($handler); $logger->pushHandler($handler);
$injector->alias("Psr\\Log\\LoggerInterface", Logger::class); $injector->alias(LoggerInterface::class, Logger::class);
$injector->share($logger); $injector->share($logger);
$command = $injector->make($commands[$argv[1]]); $command = $injector->make(commandToClass($argv[1]));
Amp\run(function () use ($command, $climate, $logger) { Amp\run(function () use ($command, $climate, $logger) {
try { try {

View File

@@ -6,12 +6,13 @@
"ext-posix": "*", "ext-posix": "*",
"ext-openssl": "*", "ext-openssl": "*",
"bramus/monolog-colored-line-formatter": "^2", "bramus/monolog-colored-line-formatter": "^2",
"kelunik/acme": "dev-master", "kelunik/acme": "^0.2",
"kelunik/certificate": "dev-master", "kelunik/certificate": "^0.2",
"league/climate": "^3", "league/climate": "^3",
"monolog/monolog": "^1.17", "monolog/monolog": "^1.17",
"psr/log": "^1", "psr/log": "^1",
"rdlowrey/auryn": "^1" "rdlowrey/auryn": "^1",
"amphp/process": "^0.1.1"
}, },
"license": "MIT", "license": "MIT",
"authors": [ "authors": [
@@ -25,6 +26,9 @@
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Kelunik\\AcmeClient\\": "src" "Kelunik\\AcmeClient\\": "src"
} },
"files": [
"src/functions.php"
]
} }
} }

View File

@@ -4,13 +4,17 @@ namespace Kelunik\AcmeClient\Commands;
use Amp\Dns as dns; use Amp\Dns as dns;
use Amp\Dns\Record; use Amp\Dns\Record;
use function Amp\File\put;
use Amp\Promise; use Amp\Promise;
use Generator; use Generator;
use Kelunik\Acme\AcmeClient; use Kelunik\Acme\AcmeClient;
use Kelunik\Acme\AcmeException; use Kelunik\Acme\AcmeException;
use Kelunik\Acme\AcmeService; use Kelunik\Acme\AcmeService;
use Kelunik\Acme\KeyPair;
use Kelunik\Acme\OpenSSLKeyGenerator; 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 League\CLImate\Argument\Manager;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use stdClass; use stdClass;
@@ -18,6 +22,7 @@ use Throwable;
use function Amp\all; use function Amp\all;
use function Amp\any; use function Amp\any;
use function Amp\resolve; use function Amp\resolve;
use function Kelunik\AcmeClient\getServer;
class Issue implements Command { class Issue implements Command {
private $logger; private $logger;
@@ -31,35 +36,22 @@ class Issue implements Command {
} }
private function doExecute(Manager $args): Generator { private function doExecute(Manager $args): Generator {
if (posix_geteuid() !== 0) { $domains = array_map("trim", explode(",", $args->get("domains")));
throw new AcmeException("Please run this script as root!"); yield resolve($this->checkDnsRecords($domains));
}
$user = $args->get("user") ?? "www-data"; $user = $args->get("user") ?? "www-data";
$server = $args->get("server"); $keyStore = new KeyStore(dirname(dirname(__DIR__)) . "/data");
$protocol = substr($server, 0, strpos("://", $server));
if (!$protocol || $protocol === $server) { $keyPair = yield $keyStore->get("account/key.pem");
$server = "https://" . $server; $acme = new AcmeService(new AcmeClient(getServer(), $keyPair), $keyPair);
} 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) { foreach ($domains as $domain) {
list($location, $challenges) = yield $acme->requestChallenges($domain); list($location, $challenges) = yield $acme->requestChallenges($domain);
$goodChallenges = $this->findSuitableCombination($challenges); $goodChallenges = $this->findSuitableCombination($challenges);
if (empty($goodChallenges)) { if (empty($goodChallenges)) {
throw new AcmeException("Couldn't find any combination of challenges which this server can solve!"); throw new AcmeException("Couldn't find any combination of challenges which this client can solve!");
} }
$challenge = $challenges->challenges[reset($goodChallenges)]; $challenge = $challenges->challenges[reset($goodChallenges)];
@@ -72,30 +64,13 @@ class Issue implements Command {
$this->logger->debug("Generating payload..."); $this->logger->debug("Generating payload...");
$payload = $acme->generateHttp01Payload($token); $payload = $acme->generateHttp01Payload($token);
$docRoot = rtrim($args->get("path") ?? __DIR__ . "/../../data/public", "/\\"); $this->logger->info("Providing payload at http://{$domain}/.well-known/acme-challenge/{$token}");
$path = $docRoot . "/.well-known/acme-challenge"; $docRoot = rtrim(str_replace("\\", "/", $args->get("path")), "/");
$challengeStore = new ChallengeStore($docRoot);
try { try {
if (!file_exists($docRoot)) { $challengeStore->put($token, $payload, $user);
throw new AcmeException("Document root doesn't exist: " . $docRoot);
}
if (!file_exists($path) && !@mkdir($path, 0770, true)) {
throw new AcmeException("Couldn't create public dir to serve the challenges: " . $path);
}
if (!$userInfo = posix_getpwnam($user)) {
throw new AcmeException("Unknown user: " . $user);
}
chown($docRoot . "/.well-known", $userInfo["uid"]);
chown($docRoot . "/.well-known/acme-challenge", $userInfo["uid"]);
$this->logger->info("Providing payload for {$domain} at {$path}/{$token}");
file_put_contents("{$path}/{$token}", $payload);
chown("{$path}/{$token}", $userInfo["uid"]);
chmod("{$path}/{$token}", 0660);
yield $acme->selfVerify($domain, $token, $payload); yield $acme->selfVerify($domain, $token, $payload);
$this->logger->info("Successfully self-verified challenge."); $this->logger->info("Successfully self-verified challenge.");
@@ -106,53 +81,38 @@ class Issue implements Command {
yield $acme->pollForChallenge($location); yield $acme->pollForChallenge($location);
$this->logger->info("Challenge successful. {$domain} is now authorized."); $this->logger->info("Challenge successful. {$domain} is now authorized.");
@unlink("{$path}/{$token}"); yield $challengeStore->delete($token);
} catch (Throwable $e) { } catch (Throwable $e) {
// no finally because generators... // no finally because generators...
@unlink("{$path}/{$token}"); yield $challengeStore->delete($token);
throw $e; throw $e;
} }
} }
$path = __DIR__ . "/../../data/live/" . reset($domains); $path = "certs/" . reset($domains) . "/key.pem";
$bits = $args->get("bits") ?? 2048;
if (!file_exists($path) && !mkdir($path, 0700, true)) { try {
throw new AcmeException("Couldn't create directory: {$path}"); $keyPair = yield $keyStore->get($path);
} } catch (KeyStoreException $e) {
$keyPair = (new OpenSSLKeyGenerator)->generate($bits);
if (file_exists($path . "/private.pem") && file_exists($path . "/public.pem")) { $keyPair = yield $keyStore->put($path, $keyPair);
$private = file_get_contents($path . "/private.pem");
$public = file_get_contents($path . "/public.pem");
$this->logger->info("Using existing domain key found at {$path}");
$domainKeys = new KeyPair($private, $public);
} else {
$domainKeys = (new OpenSSLKeyGenerator)->generate(2048);
file_put_contents($path . "/private.pem", $domainKeys->getPrivate());
file_put_contents($path . "/public.pem", $domainKeys->getPublic());
$this->logger->info("Saved new domain key at {$path}");
chmod($path . "/private.pem", 0600);
chmod($path . "/public.pem", 0600);
} }
$this->logger->info("Requesting certificate ..."); $this->logger->info("Requesting certificate ...");
$location = yield $acme->requestCertificate($domainKeys, $domains); $location = yield $acme->requestCertificate($keyPair, $domains);
$certificates = yield $acme->pollForCertificate($location); $certificates = yield $acme->pollForCertificate($location);
$this->logger->info("Saving certificate ..."); $path = dirname(dirname(__DIR__)) . "/data/certs";
$certificateStore = new CertificateStore($path);
yield $certificateStore->put($certificates);
file_put_contents($path . "/cert.pem", reset($certificates)); yield put($path . "/" . reset($domains) . "/config.json", json_encode([
file_put_contents($path . "/fullchain.pem", implode("\n", $certificates)); "domains" => $domains, "path" => $args->get("path"), "user" => $user, "bits" => $bits
], JSON_PRETTY_PRINT) . "\n");
array_shift($certificates); $this->logger->info("Successfully issued certificate, see {$path}/" . reset($domains));
file_put_contents($path . "/chain.pem", implode("\n", $certificates));
$this->logger->info("Successfully issued certificate.");
} }
private function checkDnsRecords($domains): Generator { private function checkDnsRecords($domains): Generator {
@@ -174,34 +134,6 @@ class Issue implements Command {
$this->logger->info("Checked DNS records, all fine."); $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 { private function findSuitableCombination(stdClass $response): array {
$challenges = $response->challenges ?? []; $challenges = $response->challenges ?? [];
$combinations = $response->combinations ?? []; $combinations = $response->combinations ?? [];
@@ -227,26 +159,26 @@ class Issue implements Command {
"domains" => [ "domains" => [
"prefix" => "d", "prefix" => "d",
"longPrefix" => "domains", "longPrefix" => "domains",
"description" => "Domains to request a certificate for.", "description" => "Comma separated list of domains to request a certificate for.",
"required" => true, "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" => [ "path" => [
"prefix" => "p", "prefix" => "p",
"longPrefix" => "path", "longPrefix" => "path",
"description" => "Path to the document root for ACME challenges.", "description" => "Absolute path to the document root of these domains.",
"required" => false, "required" => true,
],
"user" => [
"prefix" => "u",
"longPrefix" => "user",
"description" => "User running the web server.",
"defaultValue" => "www-data",
],
"bits" => [
"longPrefix" => "bits",
"description" => "Length of the private key in bit.",
"defaultValue" => 2048,
"castTo" => "int",
], ],
]; ];
} }

93
src/Commands/Renew.php Normal file
View File

@@ -0,0 +1,93 @@
<?php
namespace Kelunik\AcmeClient\Commands;
use Amp\Process;
use Amp\Promise;
use Generator;
use Kelunik\Certificate\Certificate;
use League\CLImate\Argument\Manager;
use Psr\Log\LoggerInterface;
use function Amp\all;
use function Amp\File\get;
use function Amp\File\scandir;
use function Amp\pipe;
use function Amp\resolve;
class Renew implements Command {
private $logger;
public function __construct(LoggerInterface $logger) {
$this->logger = $logger;
}
public function execute(Manager $args): Promise {
return resolve($this->doExecute($args));
}
private function doExecute(Manager $args): Generator {
$path = dirname(dirname(__DIR__)) . "/data/certs";
if (!realpath($path)) {
throw new \RuntimeException("Certificate path doesn't exist: '{$path}'");
}
$domains = yield scandir($path);
$promises = [];
foreach ($domains as $domain) {
$pem = yield get($path . "/" . $domain . "/cert.pem");
$cert = new Certificate($pem);
if ($cert->getValidTo() > time() + 30 * 24 * 60 * 60) {
$this->logger->info("Certificate for " . implode(",", $cert->getNames()) . " is still valid for more than 30 days.");
continue;
}
$json = yield get($path . "/" . $domain . "/config.json");
$config = json_decode($json);
$command = [
PHP_BINARY,
dirname(dirname(__DIR__)) . "/bin/acme",
"issue",
"-d",
implode(",", $config->domains),
"-p",
$config->path,
"-u",
$config->user,
];
$command = array_map("escapeshellarg", $command);
$command = implode(" ", $command);
$promises[] = pipe((new Process($command))->exec()->watch(function ($update) {
list($type, $data) = $update;
if ($type === "err") {
$this->logger->error($data);
} else {
$this->logger->info($data);
}
}), function ($result) use ($command) {
$result->command = $command;
return $result;
});
}
$results = yield all($promises);
foreach ($results as $result) {
if ($result->exit !== 0) {
throw new \RuntimeException("Invalid exit code: " . $result->exit . " (" . $result->command . ")");
}
}
}
public static function getDefinition(): array {
return [];
}
}

View File

@@ -2,12 +2,16 @@
namespace Kelunik\AcmeClient\Commands; namespace Kelunik\AcmeClient\Commands;
use Amp\File\FilesystemException;
use Amp\Promise; use Amp\Promise;
use Generator; use Generator;
use Kelunik\Acme\AcmeClient; use Kelunik\Acme\AcmeClient;
use Kelunik\Acme\AcmeException; use Kelunik\Acme\AcmeException;
use Kelunik\Acme\AcmeService; use Kelunik\Acme\AcmeService;
use Kelunik\Acme\KeyPair; use Kelunik\Acme\KeyPair;
use function Kelunik\AcmeClient\getServer;
use Kelunik\AcmeClient\Stores\CertificateStore;
use Kelunik\AcmeClient\Stores\KeyStore;
use Kelunik\Certificate\Certificate; use Kelunik\Certificate\Certificate;
use League\CLImate\Argument\Manager; use League\CLImate\Argument\Manager;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
@@ -31,75 +35,36 @@ class Revoke implements Command {
throw new AcmeException("Please run this script as root!"); throw new AcmeException("Please run this script as root!");
} }
$server = $args->get("server"); $keyStore = new KeyStore(dirname(dirname(__DIR__)) . "/data");
$protocol = substr($server, 0, strpos("://", $server));
if (!$protocol || $protocol === $server) { $keyPair = yield $keyStore->get("account/key.pem");
$server = "https://" . $server; $acme = new AcmeService(new AcmeClient(getServer(), $keyPair), $keyPair);
} elseif ($protocol !== "https") {
throw new \InvalidArgumentException("Invalid server protocol, only HTTPS supported");
}
$keyPair = $this->checkRegistration($args);
$acme = new AcmeService(new AcmeClient($server, $keyPair), $keyPair);
$this->logger->info("Revoking certificate ..."); $this->logger->info("Revoking certificate ...");
$pem = yield get($args->get("cert")); try {
$cert = new Certificate($pem); $pem = yield get(dirname(dirname(__DIR__)) . "/data/certs/" . $args->get("name") . "/cert.pem");
$cert = new Certificate($pem);
} catch (FilesystemException $e) {
throw new \RuntimeException("There's no such certificate!");
}
if ($cert->getValidTo() < time()) { if ($cert->getValidTo() < time()) {
$this->logger->warning("Certificate did already expire, no need to revoke it."); $this->logger->warning("Certificate did already expire, no need to revoke it.");
return;
} }
$this->logger->info("Certificate was valid for: " . implode(", ", $cert->getNames())); $this->logger->info("Certificate was valid for: " . implode(", ", $cert->getNames()));
yield $acme->revokeCertificate($pem); yield $acme->revokeCertificate($pem);
$this->logger->info("Certificate has been revoked."); $this->logger->info("Certificate has been revoked.");
}
private function checkRegistration(Manager $args) { yield (new CertificateStore(dirname(dirname(__DIR__)) . "/data/certs"))->delete($args->get("name"));
$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(): array {
return [ return [
"cert" => [ "name" => [
"prefix" => "c", "longPrefix" => "name",
"longPrefix" => "cert", "description" => "Common name of the certificate to be revoked.",
"description" => "Certificate to be revoked.",
"required" => true,
],
"server" => [
"prefix" => "s",
"longPrefix" => "server",
"description" => "ACME server to use for authorization.",
"required" => true, "required" => true,
], ],
]; ];

View File

@@ -9,15 +9,19 @@ use Generator;
use Kelunik\Acme\AcmeClient; use Kelunik\Acme\AcmeClient;
use Kelunik\Acme\AcmeException; use Kelunik\Acme\AcmeException;
use Kelunik\Acme\AcmeService; use Kelunik\Acme\AcmeService;
use Kelunik\Acme\KeyPair;
use Kelunik\Acme\OpenSSLKeyGenerator; use Kelunik\Acme\OpenSSLKeyGenerator;
use Kelunik\Acme\Registration; use Kelunik\Acme\Registration;
use Kelunik\AcmeClient\Stores\KeyStore;
use Kelunik\AcmeClient\Stores\KeyStoreException;
use League\CLImate\Argument\Manager; use League\CLImate\Argument\Manager;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use RuntimeException;
use function Amp\File\exists; use function Amp\File\exists;
use function Amp\File\put;
use function Amp\resolve; use function Amp\resolve;
use function Kelunik\AcmeClient\serverToIdentity;
class Register implements Command { class Setup implements Command {
private $logger; private $logger;
public function __construct(LoggerInterface $logger) { public function __construct(LoggerInterface $logger) {
@@ -29,10 +33,6 @@ class Register implements Command {
} }
public function doExecute(Manager $args): Generator { public function doExecute(Manager $args): Generator {
if (posix_geteuid() !== 0) {
throw new AcmeException("Please run this script as root!");
}
$email = $args->get("email"); $email = $args->get("email");
yield resolve($this->checkEmail($email)); yield resolve($this->checkEmail($email));
@@ -42,46 +42,47 @@ class Register implements Command {
if (!$protocol || $protocol === $server) { if (!$protocol || $protocol === $server) {
$server = "https://" . $server; $server = "https://" . $server;
} elseif ($protocol !== "https") { } elseif ($protocol !== "https") {
throw new \InvalidArgumentException("Invalid server protocol, only HTTPS supported"); throw new \InvalidArgumentException("Invalid protocol, only https is allowed!");
} }
$identity = str_replace(["/", "%"], "-", substr($server, 8)); $path = "account/key.pem";
$bits = 4096;
$path = __DIR__ . "/../../data/accounts"; $keyStore = new KeyStore(dirname(dirname(__DIR__)) . "/data");
$pathPrivate = "{$path}/{$identity}.private.key";
$pathPublic = "{$path}/{$identity}.public.key";
if ((yield exists($pathPrivate)) && (yield exists($pathPublic))) { try {
$this->logger->info("Loading existing keys ..."); $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.");
$private = file_get_contents($pathPrivate); $this->logger->info("Saving new private key ...");
$public = file_get_contents($pathPublic); $keyPair = yield $keyStore->put($path, $keyPair);
$this->logger->info("New private key successfully saved.");
}
$keyPair = new KeyPair($private, $public); $user = $args->get("user") ?? "www-data";
} else { $userInfo = posix_getpwnam($user);
$this->logger->info("Generating key keys ...");
$keyPair = (new OpenSSLKeyGenerator)->generate(4096); if (!$userInfo) {
throw new RuntimeException("User doesn't exist: '{$user}'");
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); $acme = new AcmeService(new AcmeClient($server, $keyPair), $keyPair);
$this->logger->info("Registering with ACME server " . substr($server, 8) . " ..."); $this->logger->info("Registering with ACME server " . substr($server, 8) . " ...");
/** @var Registration $registration */ /** @var Registration $registration */
$registration = yield $acme->register($email); $registration = yield $acme->register($email);
$this->logger->notice("Registration successful with the following contact information: " . implode(", ", $registration->getContact()));
$this->logger->notice("Registration successful with contact " . json_encode($registration->getContact())); yield put(dirname(dirname(__DIR__)) . "/data/account/config.json", json_encode([
"version" => 1,
"server" => $server,
"email" => $email,
], JSON_PRETTY_PRINT) . "\n");
} }
private function checkEmail(string $email): Generator { private function checkEmail(string $email): Generator {
@@ -103,12 +104,12 @@ class Register implements Command {
"server" => [ "server" => [
"prefix" => "s", "prefix" => "s",
"longPrefix" => "server", "longPrefix" => "server",
"description" => "ACME server to register for.", "description" => "ACME server to use for registration and issuance of certificates.",
"required" => true, "required" => true,
], ],
"email" => [ "email" => [
"longPrefix" => "email", "longPrefix" => "email",
"description" => "Email to be notified about important account issues.", "description" => "Email for important issues, will be sent to the ACME server.",
"required" => true, "required" => true,
], ],
]; ];

27
src/Configuration.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
namespace Kelunik\AcmeClient;
use RuntimeException;
class Configuration {
private $config;
public function __construct(string $file) {
$json = file_get_contents($file);
if (!$json) {
throw new RuntimeException("Couldn't read config file: '{$file}'");
}
$this->config = json_decode($json);
if (!$this->config) {
throw new RuntimeException("Couldn't read JSON: '{$json}'");
}
}
public function get(string $key) {
return $this->config->{$key} ?? null;
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace Kelunik\AcmeClient\Stores;
use Amp\File\FilesystemException;
use Amp\Promise;
use Generator;
use InvalidArgumentException;
use Kelunik\Certificate\Certificate;
use function Amp\File\put;
use function Amp\File\chmod;
use function Amp\File\chown;
use function Amp\File\scandir;
use function Amp\File\unlink;
use function Amp\resolve;
use function Amp\File\rmdir;
class CertificateStore {
private $root;
public function __construct(string $root) {
$this->root = rtrim(str_replace("\\", "/", $root), "/");
}
public function put(array $certificates): Promise {
return resolve($this->doPut($certificates));
}
private function doPut(array $certificates): Generator {
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, 0770, true)) {
throw new FilesystemException("Couldn't create certificate directory: '{$path}'");
}
yield put($path . "/cert.pem", $certificates[0]);
yield chown($path . "/cert.pem", 0, 0);
yield chmod($path . "/cert.pem", 0640);
yield put($path . "/fullchain.pem", implode("\n", $certificates));
yield chown($path . "/fullchain.pem", 0, 0);
yield chmod($path . "/fullchain.pem", 0640);
yield put($path . "/chain.pem", implode("\n", $chain));
yield chown($path . "/chain.pem", 0, 0);
yield chmod($path . "/chain.pem", 0640);
} catch (FilesystemException $e) {
throw new CertificateStoreException("Couldn't save certificates for '{$commonName}'", 0, $e);
}
}
public function delete(string $name): Promise {
return resolve($this->doDelete($name));
}
private function doDelete(string $name): Generator {
foreach ((yield scandir($this->root . "/" . $name)) as $file) {
yield unlink($this->root . "/" . $name . "/" . $file);
}
yield 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,60 @@
<?php
namespace Kelunik\AcmeClient\Stores;
use Amp\Promise;
use Generator;
use function Amp\File\put;
use function Amp\File\unlink;
use function Amp\resolve;
class ChallengeStore {
private $docroot;
public function __construct(string $docroot) {
$this->docroot = rtrim(str_replace("\\", "/", $docroot), "/");
}
public function put(string $token, string $payload, string $user): Promise {
return resolve($this->doPut($token, $payload, $user));
}
private function doPut(string $token, string $payload, string $user): Generator {
$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, 0770, true)) {
throw new ChallengeStoreException("Couldn't create public directory to serve the challenges: '{$path}'");
}
if (!$userInfo = posix_getpwnam($user)) {
throw new ChallengeStoreException("Unknown user: '{$user}'");
}
// TODO: Make async, see https://github.com/amphp/file/issues/6
chown($this->docroot . "/.well-known", $userInfo["uid"]);
chown($this->docroot . "/.well-known/acme-challenge", $userInfo["uid"]);
yield put("{$path}/{$token}", $payload);
chown("{$path}/{$token}", $userInfo["uid"]);
chmod("{$path}/{$token}", 0660);
}
public function delete(string $token): Promise {
return resolve($this->doDelete($token));
}
private function doDelete(string $token): Generator {
$path = $this->docroot . "/.well-known/acme-challenge/{$token}";
$realpath = realpath($path);
if ($realpath) {
yield unlink($realpath);
}
}
}

View File

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

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

@@ -0,0 +1,66 @@
<?php
namespace Kelunik\AcmeClient\Stores;
use Amp\File\FilesystemException;
use Amp\Promise;
use Generator;
use Kelunik\Acme\KeyPair;
use function Amp\File\chmod;
use function Amp\File\chown;
use function Amp\File\get;
use function Amp\File\put;
use function Amp\resolve;
class KeyStore {
private $root;
public function __construct(string $root = "") {
$this->root = rtrim(str_replace("\\", "/", $root), "/");
}
public function get(string $path): Promise {
return resolve($this->doGet($path));
}
private function doGet(string $path): Generator {
$file = $this->root . "/" . $path;
$realpath = realpath($file);
if (!$realpath) {
throw new KeyStoreException("File not found: '{$file}'");
}
$privateKey = yield 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"];
return new KeyPair($privateKey, $publicKey);
}
public function put(string $path, KeyPair $keyPair): Promise {
return resolve($this->doPut($path, $keyPair));
}
private function doPut(string $path, KeyPair $keyPair): Generator {
$file = $this->root . "/" . $path;
try {
// TODO: Replace with async version once available
mkdir(dirname($file), 0770, true);
yield put($file, $keyPair->getPrivate());
yield chmod($file, 0600);
yield chown($file, 0, 0);
} catch (FilesystemException $e) {
throw new KeyStoreException("Could not save key.", 0, $e);
}
return $keyPair;
}
}

View File

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

16
src/functions.php Normal file
View File

@@ -0,0 +1,16 @@
<?php
namespace Kelunik\AcmeClient;
function commandToClass(string $command): string {
return __NAMESPACE__ . "\\Commands\\" . ucfirst($command);
}
function getServer(Configuration $config = null) {
if ($config === null) {
$path = dirname(__DIR__) . "/data";
$config = new Configuration($path . "/account/config.json");
}
return $config->get("server");
}