diff --git a/bin/acme b/bin/acme index 5c7cd30..e05a49f 100755 --- a/bin/acme +++ b/bin/acme @@ -14,7 +14,7 @@ require __DIR__ . "/../vendor/autoload.php"; $commands = [ "setup", "issue", - "renew", + "check", "revoke", ]; @@ -53,7 +53,14 @@ if (count($argv) === 1 || in_array($argv[1], ["h", "-h", "help", "--help"], true } if (!in_array($argv[1], $commands)) { - $climate->error("Unknown command '{$argv[1]}', use --help for a list of available 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); } @@ -70,6 +77,7 @@ try { } 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()); @@ -77,12 +85,6 @@ try { } } -if (posix_geteuid() !== 0) { // TODO: Windows? - $climate->error("Please run this script as root."); - - exit(1); -} - $handler = new StreamHandler("php://stdout", Logger::DEBUG); $handler->setFormatter(new ColoredLineFormatter(new LoggerColorScheme, null, null, true, true)); diff --git a/composer.json b/composer.json index 776396d..d025657 100644 --- a/composer.json +++ b/composer.json @@ -6,13 +6,14 @@ "bramus/monolog-colored-line-formatter": "^2", "ext-posix": "*", "ext-openssl": "*", - "kelunik/acme": "^0.2", - "kelunik/certificate": "^0.2", + "kelunik/acme": "^0.3", + "kelunik/certificate": "^1", "league/climate": "^3", "monolog/monolog": "^1.17", "php": ">=5.5", "psr/log": "^1", - "rdlowrey/auryn": "^1" + "rdlowrey/auryn": "^1", + "webmozart/assert": "^1" }, "license": "MIT", "authors": [ @@ -26,6 +27,12 @@ "autoload": { "psr-4": { "Kelunik\\AcmeClient\\": "src" - } + }, + "files": [ + "src/functions.php" + ] + }, + "require-dev": { + "phpunit/phpunit": "^5" } } diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..cda635b --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + ./test + + + + + + ./src/ + + + \ No newline at end of file diff --git a/src/Commands/Check.php b/src/Commands/Check.php new file mode 100644 index 0000000..6a3de7b --- /dev/null +++ b/src/Commands/Check.php @@ -0,0 +1,61 @@ +logger = $logger; + } + + public function execute(Manager $args) { + return \Amp\resolve($this->doExecute($args)); + } + + /** + * @param Manager $args + * @return \Generator + */ + private function doExecute(Manager $args) { + $path = $args->get("cert"); + + if (!realpath($path)) { + throw new \RuntimeException("Certificate doesn't exist: '{$path}'"); + } + + $pem = (yield \Amp\File\get($path)); + $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 [ + "cert" => [ + "longPrefix" => "cert", + "prefix" => "c", + "description" => "Certificate to check.", + "required" => true, + ], + "ttl" => [ + "longPrefix" => "ttl", + "description" => "Minimum valid time in days.", + "defaultValue" => 30, + "castTo" => "int", + ], + ]; + } +} \ No newline at end of file diff --git a/src/Commands/Issue.php b/src/Commands/Issue.php index 349516a..0c1dda4 100644 --- a/src/Commands/Issue.php +++ b/src/Commands/Issue.php @@ -29,15 +29,30 @@ class Issue implements Command { } private function doExecute(Manager $args) { - $domains = array_map("trim", explode(",", $args->get("domains"))); - yield \Amp\resolve($this->checkDnsRecords($domains)); + 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"; + if ($currentUsername !== $user) { + throw new AcmeException("Running this script with --user only works as root!"); + } + } else { + $user = $args->get("user") ?: "www-data"; + } + } + + $domains = array_map("trim", explode(":", $args->get("domains"))); + yield \Amp\resolve($this->checkDnsRecords($domains)); $keyStore = new KeyStore(dirname(dirname(__DIR__)) . "/data"); - $keyPair = (yield $keyStore->get("account/key.pem")); - $acme = new AcmeService(new AcmeClient(\Kelunik\AcmeClient\getServer(), $keyPair), $keyPair); + $server = \Kelunik\AcmeClient\resolveServer($args->get("server")); + $keyFile = \Kelunik\AcmeClient\serverToKeyname($server); + + $keyPair = (yield $keyStore->get("accounts/{$keyFile}.pem")); + $acme = new AcmeService(new AcmeClient($server, $keyPair)); foreach ($domains as $domain) { list($location, $challenges) = (yield $acme->requestChallenges($domain)); @@ -51,11 +66,11 @@ class Issue implements Command { $token = $challenge->token; if (!preg_match("#^[a-zA-Z0-9-_]+$#", $token)) { - throw new AcmeException("Protocol Violation: Invalid Token!"); + throw new AcmeException("Protocol violation: Invalid Token!"); } $this->logger->debug("Generating payload..."); - $payload = $acme->generateHttp01Payload($token); + $payload = $acme->generateHttp01Payload($token, $keyPair); $this->logger->info("Providing payload at http://{$domain}/.well-known/acme-challenge/{$token}"); $docRoot = rtrim(str_replace("\\", "/", $args->get("path")), "/"); @@ -63,9 +78,9 @@ class Issue implements Command { $challengeStore = new ChallengeStore($docRoot); try { - $challengeStore->put($token, $payload, $user); + $challengeStore->put($token, $payload, isset($user) ? $user : null); - yield $acme->selfVerify($domain, $token, $payload); + yield $acme->verifyHttp01Challenge($domain, $token, $payload); $this->logger->info("Successfully self-verified challenge."); yield $acme->answerChallenge($challenge->uri, $payload); @@ -87,7 +102,7 @@ class Issue implements Command { } $path = "certs/" . reset($domains) . "/key.pem"; - $bits = $args->get("bits") ?: 2048; + $bits = $args->get("bits"); try { $keyPair = (yield $keyStore->get($path)); @@ -153,23 +168,28 @@ class Issue implements Command { public static function getDefinition() { return [ + "server" => [ + "prefix" => "s", + "longPrefix" => "server", + "description" => "Server to use for issuance, see also 'bin/acme setup'.", + "required" => true, + ], "domains" => [ "prefix" => "d", "longPrefix" => "domains", - "description" => "Comma separated list of domains to request a certificate for.", + "description" => "Colon separated list of domains to request a certificate for.", "required" => true, ], "path" => [ "prefix" => "p", "longPrefix" => "path", - "description" => "Absolute path to the document root of these domains.", + "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.", - "defaultValue" => "www-data", ], "bits" => [ "longPrefix" => "bits", diff --git a/src/Commands/Renew.php b/src/Commands/Renew.php deleted file mode 100644 index c43e4c2..0000000 --- a/src/Commands/Renew.php +++ /dev/null @@ -1,86 +0,0 @@ -logger = $logger; - } - - public function execute(Manager $args) { - return \Amp\resolve($this->doExecute($args)); - } - - private function doExecute(Manager $args) { - $path = dirname(dirname(__DIR__)) . "/data/certs"; - - if (!realpath($path)) { - throw new \RuntimeException("Certificate path doesn't exist: '{$path}'"); - } - - $domains = (yield \Amp\File\scandir($path)); - $promises = []; - - foreach ($domains as $domain) { - $pem = (yield \Amp\File\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 \Amp\File\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[] = \Amp\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 \Amp\all($promises)); - - foreach ($results as $result) { - if ($result->exit !== 0) { - throw new \RuntimeException("Invalid exit code: " . $result->exit . " (" . $result->command . ")"); - } - } - } - - public static function getDefinition() { - return []; - } -} \ No newline at end of file diff --git a/src/Commands/Revoke.php b/src/Commands/Revoke.php index 7bdcc31..c74f62e 100644 --- a/src/Commands/Revoke.php +++ b/src/Commands/Revoke.php @@ -26,14 +26,13 @@ class Revoke implements Command { } private function doExecute(Manager $args) { - if (posix_geteuid() !== 0) { - throw new AcmeException("Please run this script as root!"); - } - $keyStore = new KeyStore(dirname(dirname(__DIR__)) . "/data"); - $keyPair = (yield $keyStore->get("account/key.pem")); - $acme = new AcmeService(new AcmeClient(\Kelunik\AcmeClient\getServer(), $keyPair), $keyPair); + $server = $args->get("server"); + $keyFile = \Kelunik\AcmeClient\serverToKeyname($server); + + $keyPair = (yield $keyStore->get("accounts/{$keyFile}.pem")); + $acme = new AcmeService(new AcmeClient($server, $keyPair), $keyPair); $this->logger->info("Revoking certificate ..."); @@ -57,6 +56,12 @@ class Revoke implements Command { public static function getDefinition() { return [ + "server" => [ + "prefix" => "s", + "longPrefix" => "server", + "description" => "", + "required" => true, + ], "name" => [ "longPrefix" => "name", "description" => "Common name of the certificate to be revoked.", diff --git a/src/Commands/Setup.php b/src/Commands/Setup.php index 038deb5..ff25e55 100644 --- a/src/Commands/Setup.php +++ b/src/Commands/Setup.php @@ -34,15 +34,9 @@ class Setup implements Command { yield \Amp\resolve($this->checkEmail($email)); $server = $args->get("server"); - $protocol = substr($server, 0, strpos("://", $server)); + $keyFile = \Kelunik\AcmeClient\serverToKeyname($server); - if (!$protocol || $protocol === $server) { - $server = "https://" . $server; - } elseif ($protocol !== "https") { - throw new \InvalidArgumentException("Invalid protocol, only https is allowed!"); - } - - $path = "account/key.pem"; + $path = "accounts/{$keyFile}.pem"; $bits = 4096; $keyStore = new KeyStore(dirname(dirname(__DIR__)) . "/data"); diff --git a/src/Config.php b/src/Config.php deleted file mode 100644 index 112bc92..0000000 --- a/src/Config.php +++ /dev/null @@ -1,15 +0,0 @@ -config = $config; - } - - public function get($key) { - return isset($this->config[$key]) ? $this->config[$key] : null; - } -} \ No newline at end of file diff --git a/src/Stores/CertificateStore.php b/src/Stores/CertificateStore.php index 607f717..738c618 100644 --- a/src/Stores/CertificateStore.php +++ b/src/Stores/CertificateStore.php @@ -5,6 +5,7 @@ namespace Kelunik\AcmeClient\Stores; use Amp\File\FilesystemException; use InvalidArgumentException; use Kelunik\Certificate\Certificate; +use Webmozart\Assert\Assert; class CertificateStore { private $root; @@ -43,38 +44,29 @@ class CertificateStore { $path = $this->root . "/" . $commonName; $realpath = realpath($path); - if (!$realpath && !mkdir($path, 0770, true)) { + 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\chown($path . "/cert.pem", 0, 0); - yield \Amp\File\chmod($path . "/cert.pem", 0640); + yield \Amp\File\chmod($path . "/cert.pem", 0644); yield \Amp\File\put($path . "/fullchain.pem", implode("\n", $certificates)); - yield \Amp\File\chown($path . "/fullchain.pem", 0, 0); - yield \Amp\File\chmod($path . "/fullchain.pem", 0640); + yield \Amp\File\chmod($path . "/fullchain.pem", 0644); yield \Amp\File\put($path . "/chain.pem", implode("\n", $chain)); - yield \Amp\File\chown($path . "/chain.pem", 0, 0); - yield \Amp\File\chmod($path . "/chain.pem", 0640); + 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) { - if (!is_string($name)) { - throw new InvalidArgumentException(sprintf("\$name must be of type string, %s given.", gettype($name))); - } - return \Amp\resolve($this->doDelete($name)); } private function doDelete($name) { - if (!is_string($name)) { - throw new InvalidArgumentException(sprintf("\$name must be of type string, %s given.", gettype($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); diff --git a/src/Stores/ChallengeStore.php b/src/Stores/ChallengeStore.php index 9d222d7..b9a9b1e 100644 --- a/src/Stores/ChallengeStore.php +++ b/src/Stores/ChallengeStore.php @@ -2,9 +2,8 @@ namespace Kelunik\AcmeClient\Stores; -use Amp\Promise; -use Generator; use InvalidArgumentException; +use Webmozart\Assert\Assert; class ChallengeStore { private $docroot; @@ -17,34 +16,14 @@ class ChallengeStore { $this->docroot = rtrim(str_replace("\\", "/", $docroot), "/"); } - public function put($token, $payload, $user) { - if (!is_string($token)) { - throw new InvalidArgumentException(sprintf("\$token must be of type string, %s given.", gettype($token))); - } - - if (!is_string($payload)) { - throw new InvalidArgumentException(sprintf("\$payload must be of type string, %s given.", gettype($payload))); - } - - if (!is_string($user)) { - throw new InvalidArgumentException(sprintf("\$user must be of type string, %s given.", gettype($user))); - } - + public function put($token, $payload, $user = null) { return \Amp\resolve($this->doPut($token, $payload, $user)); } - private function doPut($token, $payload, $user) { - if (!is_string($token)) { - throw new InvalidArgumentException(sprintf("\$token must be of type string, %s given.", gettype($token))); - } - - if (!is_string($payload)) { - throw new InvalidArgumentException(sprintf("\$payload must be of type string, %s given.", gettype($payload))); - } - - if (!is_string($user)) { - throw new InvalidArgumentException(sprintf("\$user must be of type string, %s given.", gettype($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); @@ -53,36 +32,36 @@ class ChallengeStore { throw new ChallengeStoreException("Document root doesn't exist: '{$this->docroot}'"); } - if (!$realpath && !@mkdir($path, 0770, true)) { + if (!$realpath && !@mkdir($path, 0755, 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}'"); + if ($user) { + 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"]); + 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); - chown("{$path}/{$token}", $userInfo["uid"]); - chmod("{$path}/{$token}", 0660); + if (isset($userInfo)) { + yield \Amp\File\chown("{$path}/{$token}", $userInfo["uid"], -1); + } + + yield \Amp\File\chmod("{$path}/{$token}", 0644); } public function delete($token) { - if (!is_string($token)) { - throw new InvalidArgumentException(sprintf("\$token must be of type string, %s given.", gettype($token))); - } - return \Amp\resolve($this->doDelete($token)); } private function doDelete($token) { - if (!is_string($token)) { - throw new InvalidArgumentException(sprintf("\$token must be of type string, %s given.", gettype($token))); - } + Assert::string($token, "Token must be a string. Got: %s"); $path = $this->docroot . "/.well-known/acme-challenge/{$token}"; $realpath = realpath($path); diff --git a/src/Stores/KeyStore.php b/src/Stores/KeyStore.php index 9bbe776..d1711f8 100644 --- a/src/Stores/KeyStore.php +++ b/src/Stores/KeyStore.php @@ -67,7 +67,7 @@ class KeyStore { try { // TODO: Replace with async version once available - mkdir(dirname($file), 0770, true); + mkdir(dirname($file), 0755, true); yield \Amp\File\put($file, $keyPair->getPrivate()); yield \Amp\File\chmod($file, 0600); diff --git a/src/functions.php b/src/functions.php new file mode 100644 index 0000000..c63217e --- /dev/null +++ b/src/functions.php @@ -0,0 +1,57 @@ + $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) { + $keyFile = str_replace("/", ".", $server); + $keyFile = preg_replace("[^a-z0-9._-]", "", $keyFile); + $keyFile = preg_replace("\\.+", ".", $keyFile); + + return $keyFile; +} \ No newline at end of file diff --git a/test/FunctionsTest.php b/test/FunctionsTest.php new file mode 100644 index 0000000..f48c771 --- /dev/null +++ b/test/FunctionsTest.php @@ -0,0 +1,18 @@ +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"])); + } +} \ No newline at end of file