commit 3d9de77c905c026cc7ac80e9370284dddc4d0c88 Author: Niklas Keller Date: Thu Dec 3 01:14:34 2015 +0100 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d86a61d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/data/ +/vendor/ +/composer.lock \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..37b0854 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 Niklas Keller + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f43c9a --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# acme + +![unstable](https://img.shields.io/badge/api-unstable-orange.svg?style=flat-square) +![MIT license](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square) + +`kelunik/acme-client` is 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. Revocation is not yet supported by this client. + +## Installation + +``` +git clone https://github.com/kelunik/acme-client +cd acme-client +composer install +``` + +## Usage + +Before you can issue certificates, you have to register an account first and read and understand the terms of service of the ACME CA you're using. +For Let's Encrypt there's a [subscriber agreement](https://letsencrypt.org/repository/) you have to accept. + +By using this client you agree to any agreement and any further updates by continued usage. +You're responsible to react to updates and stop the automation if you no longer agree with the terms of service. + +``` +sudo bin/acme register \ + --server acme-v01.api.letsencrypt.org/directory \ + --email me@example.com +``` + +After a successful registration you're able to issue certificates. +This client assumes you have a HTTP server setup and running. +You must have a document root setup in order to use this client. + +``` +sudo bin/acme issue \ + --server acme-v01.api.letsencrypt.org/directory \ + --domains example.com,www.example.com \ + --path /var/www/example.com +``` + +For renewal, just run this command again. \ No newline at end of file diff --git a/bin/acme b/bin/acme new file mode 100755 index 0000000..2ed325a --- /dev/null +++ b/bin/acme @@ -0,0 +1,101 @@ +#!/usr/bin/env php + "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"); + exit(1); +} + +if (count($argv) === 1 || $argv[1] === "-h" || $argv[1] === "--help" || $argv[1] === "help") { + print $help; + exit(0); +} + +if (!array_key_exists($argv[1], $commands)) { + $climate->error("Unknown command: '{$argv[1]}'"); + + exit(1); +} + +try { + $climate->arguments->add($commands[$argv[1]]::getDefinition()); + $climate->arguments->parse(); +} catch (Exception $e) { + if ($climate->arguments->defined("help")) { + print $help; + + exit(0); + } else { + $climate->error($e->getMessage()); + + exit(1); + } +} + +$handler = new StreamHandler("php://stdout", Logger::DEBUG); +$handler->setFormatter(new ColoredLineFormatter(new LoggerColorScheme, null, null, true, true)); + +$logger = new Logger("ACME"); +$logger->pushHandler($handler); + +$injector->alias("Psr\\Log\\LoggerInterface", Logger::class); +$injector->share($logger); + +$command = $injector->make($commands[$argv[1]]); + +Amp\run(function () use ($command, $climate, $logger) { + try { + yield $command->execute($climate->arguments); + } catch (Throwable $e) { + $error = (string) $e; + $lines = explode("\n", $error); + $lines = array_filter($lines, function ($line) { + return strlen($line) && $line[0] !== "#" && $line !== "Stack trace:"; + }); + + foreach ($lines as $line) { + $logger->error($line); + } + + exit(1); + } + + Amp\stop(); +}); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..04ed83d --- /dev/null +++ b/composer.json @@ -0,0 +1,30 @@ +{ + "name": "kelunik/acme-client", + "description": "Standalone PHP ACME client.", + "require": { + "php": ">=7.0.0", + "ext-posix": "*", + "ext-openssl": "*", + "amphp/aerys": "dev-master", + "bramus/monolog-colored-line-formatter": "^2", + "kelunik/acme": "dev-master", + "league/climate": "^3", + "monolog/monolog": "^1.17", + "psr/log": "^1", + "rdlowrey/auryn": "^1" + }, + "license": "MIT", + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "minimum-stability": "dev", + "prefer-stable": true, + "autoload": { + "psr-4": { + "Kelunik\\AcmeClient\\": "src" + } + } +} diff --git a/src/Commands/Command.php b/src/Commands/Command.php new file mode 100644 index 0000000..b505344 --- /dev/null +++ b/src/Commands/Command.php @@ -0,0 +1,12 @@ +logger = $logger; + } + + public function execute(Manager $args): Promise { + return resolve($this->doExecute($args)); + } + + private function doExecute(Manager $args): Generator { + if (posix_geteuid() !== 0) { + throw new AcmeException("Please run this script as root!"); + } + + $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)); + $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!"); + } + + $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 (!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"]); + + file_put_contents("{$path}/{$token}", $payload); + chown("{$path}/{$token}", $userInfo["uid"]); + chmod("{$path}/{$token}", 0660); + + yield $acme->selfVerify($domain, $token, $payload); + yield $acme->answerChallenge($challenge->uri, $payload); + yield $acme->pollForChallenge($location); + + @unlink("{$path}/{$token}"); + } catch (Throwable $e) { + // no finally because generators... + @unlink("{$path}/{$token}"); + throw $e; + } + } + + $path = __DIR__ . "/../../data/live/" . reset($domains); + + 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"); + + $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()); + + chmod($path . "/private.pem", 0600); + chmod($path . "/public.pem", 0600); + } + + $location = yield $acme->requestCertificate($domainKeys, $domains); + $certificates = yield $acme->pollForCertificate($location); + + 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)); + } + + private function checkDnsRecords($domains): Generator { + $promises = []; + + foreach ($domains as $domain) { + $promises[$domain] = dns\resolve($domain, [ + "types" => Record::A, + "hosts" => false, + ]); + } + + list($errors) = yield any($promises); + + if (!empty($errors)) { + throw new AcmeException("Couldn't resolve the following domains to an IPv4 record: " . implode(array_keys($errors))); + } + } + + 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); + + 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 ?? []; + $goodChallenges = []; + + foreach ($challenges as $i => $challenge) { + if ($challenge->type === "http-01") { + $goodChallenges[] = $i; + } + } + + foreach ($goodChallenges as $i => $challenge) { + if (!in_array([$challenge], $combinations)) { + unset($goodChallenges[$i]); + } + } + + return $goodChallenges; + } + + public static function getDefinition(): array { + 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.", + "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, + ], + ]; + } +} \ No newline at end of file diff --git a/src/Commands/Register.php b/src/Commands/Register.php new file mode 100644 index 0000000..ce145ea --- /dev/null +++ b/src/Commands/Register.php @@ -0,0 +1,116 @@ +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, + ], + ]; + } +} \ No newline at end of file diff --git a/src/Commands/Revoke.php b/src/Commands/Revoke.php new file mode 100644 index 0000000..144ffeb --- /dev/null +++ b/src/Commands/Revoke.php @@ -0,0 +1,30 @@ + [ + "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.", + "required" => true, + ], + ]; + } +} \ No newline at end of file diff --git a/src/LoggerColorScheme.php b/src/LoggerColorScheme.php new file mode 100644 index 0000000..729d11e --- /dev/null +++ b/src/LoggerColorScheme.php @@ -0,0 +1,29 @@ +__constructTrait(); + + $this->setColorizeArray([ + Logger::DEBUG => $this->ansi->color(SGR::COLOR_FG_WHITE)->get(), + Logger::INFO => $this->ansi->color(SGR::COLOR_FG_WHITE_BRIGHT)->get(), + Logger::NOTICE => $this->ansi->color(SGR::COLOR_FG_GREEN)->get(), + Logger::WARNING => $this->ansi->color(SGR::COLOR_FG_YELLOW)->get(), + Logger::ERROR => $this->ansi->color(SGR::COLOR_FG_RED)->get(), + Logger::CRITICAL => $this->ansi->color(SGR::COLOR_FG_RED)->get(), + Logger::ALERT => $this->ansi->color(SGR::COLOR_FG_RED)->get(), + Logger::EMERGENCY => $this->ansi->color(SGR::COLOR_FG_RED)->get(), + ]); + } +} \ No newline at end of file