From 7f0869150fb8cc6aefb546f1ff546c70fad31e76 Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Wed, 9 Dec 2015 20:10:21 +0100 Subject: [PATCH] Major update, add renew command, rename register to setup --- README.md | 12 +- bin/acme | 51 ++++--- composer.json | 12 +- src/Commands/Issue.php | 166 +++++++---------------- src/Commands/Renew.php | 93 +++++++++++++ src/Commands/Revoke.php | 69 +++------- src/Commands/{Register.php => Setup.php} | 67 ++++----- src/Configuration.php | 27 ++++ src/Stores/CertificateStore.php | 82 +++++++++++ src/Stores/CertificateStoreException.php | 9 ++ src/Stores/ChallengeStore.php | 60 ++++++++ src/Stores/ChallengeStoreException.php | 9 ++ src/Stores/KeyStore.php | 66 +++++++++ src/Stores/KeyStoreException.php | 9 ++ src/functions.php | 16 +++ 15 files changed, 517 insertions(+), 231 deletions(-) create mode 100644 src/Commands/Renew.php rename src/Commands/{Register.php => Setup.php} (53%) create mode 100644 src/Configuration.php create mode 100644 src/Stores/CertificateStore.php create mode 100644 src/Stores/CertificateStoreException.php create mode 100644 src/Stores/ChallengeStore.php create mode 100644 src/Stores/ChallengeStoreException.php create mode 100644 src/Stores/KeyStore.php create mode 100644 src/Stores/KeyStoreException.php create mode 100644 src/functions.php diff --git a/README.md b/README.md index db66c7d..d0c8a55 100644 --- a/README.md +++ b/README.md @@ -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. +The client has been updated on Dec 9th in a non-backwards compatible manner. Please review the changes or use a new clone. + ## 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. ``` -sudo bin/acme register \ +sudo bin/acme setup \ --server acme-v01.api.letsencrypt.org/directory \ --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 \ - --server acme-v01.api.letsencrypt.org/directory \ --domains example.com,www.example.com \ --path /var/www/example.com ``` -For renewal, just run this command again. +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. ``` -sudo bin/acme revoke \ - --server acme-v01.api.letsencrypt.org/directory \ - --cert data/live/example.com/cert.pem +sudo bin/acme revoke --name example.com ``` \ No newline at end of file diff --git a/bin/acme b/bin/acme index 2ed325a..1f907ab 100755 --- a/bin/acme +++ b/bin/acme @@ -7,6 +7,8 @@ use Kelunik\AcmeClient\LoggerColorScheme; use League\CLImate\CLImate; use Monolog\Handler\StreamHandler; use Monolog\Logger; +use Psr\Log\LoggerInterface; +use function Kelunik\AcmeClient\commandToClass; require __DIR__ . "/../vendor/autoload.php"; @@ -20,26 +22,27 @@ $help = << "Kelunik\\AcmeClient\\Commands\\Issue", - "register" => "Kelunik\\AcmeClient\\Commands\\Register", - "revoke" => "Kelunik\\AcmeClient\\Commands\\Revoke", -]; - $climate = new CLImate; $injector = new Injector; +$commands = [ + "setup", + "revoke", + "issue", + "renew", +]; -if (!isset($argv)) { - $climate->error("\$argv is not defined"); +if (PHP_SAPI !== "phpdbg" && PHP_SAPI !== "cli") { + $climate->error("Please run this script as command line script!"); exit(1); } @@ -48,25 +51,35 @@ if (count($argv) === 1 || $argv[1] === "-h" || $argv[1] === "--help" || $argv[1] exit(0); } -if (!array_key_exists($argv[1], $commands)) { +if (!in_array($argv[1], $commands)) { $climate->error("Unknown command: '{$argv[1]}'"); exit(1); } +// TODO: Implement subcommand help + +$class = commandToClass($argv[1]); +$definition = $class::getDefinition(); + try { - $climate->arguments->add($commands[$argv[1]]::getDefinition()); + $climate->arguments->add($definition); $climate->arguments->parse(); } catch (Exception $e) { - if ($climate->arguments->defined("help")) { - print $help; + $climate->usage(["bin/acme {$argv[1]}"]); + $climate->br(); - exit(0); - } else { + if (count($argv) !== 3 || !in_array($argv[2], ["-h", "--help", "help"])) { $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); @@ -75,10 +88,10 @@ $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(commandToClass($argv[1])); Amp\run(function () use ($command, $climate, $logger) { try { diff --git a/composer.json b/composer.json index 3809941..f56ccca 100644 --- a/composer.json +++ b/composer.json @@ -6,12 +6,13 @@ "ext-posix": "*", "ext-openssl": "*", "bramus/monolog-colored-line-formatter": "^2", - "kelunik/acme": "dev-master", - "kelunik/certificate": "dev-master", + "kelunik/acme": "^0.2", + "kelunik/certificate": "^0.2", "league/climate": "^3", "monolog/monolog": "^1.17", "psr/log": "^1", - "rdlowrey/auryn": "^1" + "rdlowrey/auryn": "^1", + "amphp/process": "^0.1.1" }, "license": "MIT", "authors": [ @@ -25,6 +26,9 @@ "autoload": { "psr-4": { "Kelunik\\AcmeClient\\": "src" - } + }, + "files": [ + "src/functions.php" + ] } } diff --git a/src/Commands/Issue.php b/src/Commands/Issue.php index 966db13..932a9e2 100644 --- a/src/Commands/Issue.php +++ b/src/Commands/Issue.php @@ -4,13 +4,17 @@ namespace Kelunik\AcmeClient\Commands; use Amp\Dns as dns; use Amp\Dns\Record; +use function Amp\File\put; 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\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; @@ -18,6 +22,7 @@ use Throwable; use function Amp\all; use function Amp\any; use function Amp\resolve; +use function Kelunik\AcmeClient\getServer; class Issue implements Command { private $logger; @@ -31,35 +36,22 @@ class Issue implements Command { } private function doExecute(Manager $args): Generator { - if (posix_geteuid() !== 0) { - throw new AcmeException("Please run this script as root!"); - } + $domains = array_map("trim", explode(",", $args->get("domains"))); + yield resolve($this->checkDnsRecords($domains)); $user = $args->get("user") ?? "www-data"; - $server = $args->get("server"); - $protocol = substr($server, 0, strpos("://", $server)); + $keyStore = new KeyStore(dirname(dirname(__DIR__)) . "/data"); - 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); + $keyPair = yield $keyStore->get("account/key.pem"); + $acme = new AcmeService(new AcmeClient(getServer(), $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!"); + throw new AcmeException("Couldn't find any combination of challenges which this client can solve!"); } $challenge = $challenges->challenges[reset($goodChallenges)]; @@ -72,30 +64,13 @@ class Issue implements Command { $this->logger->debug("Generating payload..."); $payload = $acme->generateHttp01Payload($token); - $docRoot = rtrim($args->get("path") ?? __DIR__ . "/../../data/public", "/\\"); - $path = $docRoot . "/.well-known/acme-challenge"; + $this->logger->info("Providing payload at http://{$domain}/.well-known/acme-challenge/{$token}"); + $docRoot = rtrim(str_replace("\\", "/", $args->get("path")), "/"); + + $challengeStore = new ChallengeStore($docRoot); try { - if (!file_exists($docRoot)) { - 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); + $challengeStore->put($token, $payload, $user); yield $acme->selfVerify($domain, $token, $payload); $this->logger->info("Successfully self-verified challenge."); @@ -106,53 +81,38 @@ class Issue implements Command { yield $acme->pollForChallenge($location); $this->logger->info("Challenge successful. {$domain} is now authorized."); - @unlink("{$path}/{$token}"); + yield $challengeStore->delete($token); } catch (Throwable $e) { // no finally because generators... - @unlink("{$path}/{$token}"); + yield $challengeStore->delete($token); 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)) { - throw new AcmeException("Couldn't create directory: {$path}"); - } - - if (file_exists($path . "/private.pem") && file_exists($path . "/public.pem")) { - $private = file_get_contents($path . "/private.pem"); - $public = file_get_contents($path . "/public.pem"); - - $this->logger->info("Using existing domain key found at {$path}"); - - $domainKeys = new KeyPair($private, $public); - } else { - $domainKeys = (new OpenSSLKeyGenerator)->generate(2048); - - file_put_contents($path . "/private.pem", $domainKeys->getPrivate()); - file_put_contents($path . "/public.pem", $domainKeys->getPublic()); - - $this->logger->info("Saved new domain key at {$path}"); - - chmod($path . "/private.pem", 0600); - chmod($path . "/public.pem", 0600); + 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); + $location = yield $acme->requestCertificate($keyPair, $domains); $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)); - file_put_contents($path . "/fullchain.pem", implode("\n", $certificates)); + yield put($path . "/" . reset($domains) . "/config.json", json_encode([ + "domains" => $domains, "path" => $args->get("path"), "user" => $user, "bits" => $bits + ], JSON_PRETTY_PRINT) . "\n"); - 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 { @@ -174,34 +134,6 @@ 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 ?? []; @@ -227,26 +159,26 @@ class Issue implements Command { "domains" => [ "prefix" => "d", "longPrefix" => "domains", - "description" => "Domains to request a certificate for.", + "description" => "Comma separated list of domains to request a certificate for.", "required" => true, ], - "server" => [ - "prefix" => "s", - "longPrefix" => "server", - "description" => "ACME server to use for authorization.", - "required" => true, - ], - "user" => [ - "prefix" => "s", - "longPrefix" => "user", - "description" => "User for the public directory.", - "required" => false, - ], "path" => [ "prefix" => "p", "longPrefix" => "path", - "description" => "Path to the document root for ACME challenges.", - "required" => false, + "description" => "Absolute path to the document root of these domains.", + "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", ], ]; } diff --git a/src/Commands/Renew.php b/src/Commands/Renew.php new file mode 100644 index 0000000..4576fd8 --- /dev/null +++ b/src/Commands/Renew.php @@ -0,0 +1,93 @@ +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 []; + } +} \ No newline at end of file diff --git a/src/Commands/Revoke.php b/src/Commands/Revoke.php index 6927a66..5747718 100644 --- a/src/Commands/Revoke.php +++ b/src/Commands/Revoke.php @@ -2,12 +2,16 @@ 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 function Kelunik\AcmeClient\getServer; +use Kelunik\AcmeClient\Stores\CertificateStore; +use Kelunik\AcmeClient\Stores\KeyStore; use Kelunik\Certificate\Certificate; use League\CLImate\Argument\Manager; use Psr\Log\LoggerInterface; @@ -31,75 +35,36 @@ class Revoke implements Command { throw new AcmeException("Please run this script as root!"); } - $server = $args->get("server"); - $protocol = substr($server, 0, strpos("://", $server)); + $keyStore = new KeyStore(dirname(dirname(__DIR__)) . "/data"); - if (!$protocol || $protocol === $server) { - $server = "https://" . $server; - } elseif ($protocol !== "https") { - throw new \InvalidArgumentException("Invalid server protocol, only HTTPS supported"); - } - - $keyPair = $this->checkRegistration($args); - $acme = new AcmeService(new AcmeClient($server, $keyPair), $keyPair); + $keyPair = yield $keyStore->get("account/key.pem"); + $acme = new AcmeService(new AcmeClient(getServer(), $keyPair), $keyPair); $this->logger->info("Revoking certificate ..."); - $pem = yield get($args->get("cert")); - $cert = new Certificate($pem); + try { + $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()) { $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."); - } - 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"); + yield (new CertificateStore(dirname(dirname(__DIR__)) . "/data/certs"))->delete($args->get("name")); } public static function getDefinition(): array { 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.", + "name" => [ + "longPrefix" => "name", + "description" => "Common name of the certificate to be revoked.", "required" => true, ], ]; diff --git a/src/Commands/Register.php b/src/Commands/Setup.php similarity index 53% rename from src/Commands/Register.php rename to src/Commands/Setup.php index ce145ea..d880dff 100644 --- a/src/Commands/Register.php +++ b/src/Commands/Setup.php @@ -9,15 +9,19 @@ 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 Kelunik\AcmeClient\Stores\KeyStore; +use Kelunik\AcmeClient\Stores\KeyStoreException; use League\CLImate\Argument\Manager; use Psr\Log\LoggerInterface; +use RuntimeException; use function Amp\File\exists; +use function Amp\File\put; use function Amp\resolve; +use function Kelunik\AcmeClient\serverToIdentity; -class Register implements Command { +class Setup implements Command { private $logger; public function __construct(LoggerInterface $logger) { @@ -29,10 +33,6 @@ class Register implements Command { } 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)); @@ -42,46 +42,47 @@ class Register implements Command { if (!$protocol || $protocol === $server) { $server = "https://" . $server; } 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"; - $pathPrivate = "{$path}/{$identity}.private.key"; - $pathPublic = "{$path}/{$identity}.public.key"; + $keyStore = new KeyStore(dirname(dirname(__DIR__)) . "/data"); - if ((yield exists($pathPrivate)) && (yield exists($pathPublic))) { - $this->logger->info("Loading existing keys ..."); + 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."); - $private = file_get_contents($pathPrivate); - $public = file_get_contents($pathPublic); + $this->logger->info("Saving new private key ..."); + $keyPair = yield $keyStore->put($path, $keyPair); + $this->logger->info("New private key successfully saved."); + } - $keyPair = new KeyPair($private, $public); - } else { - $this->logger->info("Generating key keys ..."); + $user = $args->get("user") ?? "www-data"; + $userInfo = posix_getpwnam($user); - $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); + if (!$userInfo) { + throw new RuntimeException("User doesn't exist: '{$user}'"); } $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())); - $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 { @@ -103,12 +104,12 @@ class Register implements Command { "server" => [ "prefix" => "s", "longPrefix" => "server", - "description" => "ACME server to register for.", + "description" => "ACME server to use for registration and issuance of certificates.", "required" => true, ], "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, ], ]; diff --git a/src/Configuration.php b/src/Configuration.php new file mode 100644 index 0000000..84f6154 --- /dev/null +++ b/src/Configuration.php @@ -0,0 +1,27 @@ +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; + } +} \ No newline at end of file diff --git a/src/Stores/CertificateStore.php b/src/Stores/CertificateStore.php new file mode 100644 index 0000000..82f77f0 --- /dev/null +++ b/src/Stores/CertificateStore.php @@ -0,0 +1,82 @@ +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); + } +} \ No newline at end of file diff --git a/src/Stores/CertificateStoreException.php b/src/Stores/CertificateStoreException.php new file mode 100644 index 0000000..4ead16f --- /dev/null +++ b/src/Stores/CertificateStoreException.php @@ -0,0 +1,9 @@ +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); + } + } +} \ No newline at end of file diff --git a/src/Stores/ChallengeStoreException.php b/src/Stores/ChallengeStoreException.php new file mode 100644 index 0000000..9b754e9 --- /dev/null +++ b/src/Stores/ChallengeStoreException.php @@ -0,0 +1,9 @@ +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; + } +} \ No newline at end of file diff --git a/src/Stores/KeyStoreException.php b/src/Stores/KeyStoreException.php new file mode 100644 index 0000000..999cf29 --- /dev/null +++ b/src/Stores/KeyStoreException.php @@ -0,0 +1,9 @@ +get("server"); +} \ No newline at end of file