12 Commits

Author SHA1 Message Date
Niklas Keller
6d31cec6ad Update dependencies 2016-06-02 21:39:30 +02:00
Niklas Keller
8cbb3d02a8 Update dependencies 2016-05-27 16:49:35 +02:00
Niklas Keller
d7b71dab24 Merge pull request #27 from kelunik/status
Implement 'status' command
2016-03-29 10:56:14 +02:00
Niklas Keller
944adf0c06 Implement 'status' command 2016-03-28 19:48:39 +02:00
Niklas Keller
a1d65c1483 Don't show help with 'acme-client subcommand h / help', just with '-h' / '--help' flags 2016-03-28 12:35:21 +02:00
Niklas Keller
fb0509ae7e Show help when --help is included, not only if parsing fails and then --help is included 2016-03-28 12:27:02 +02:00
Niklas Keller
866b172c5f Implement version command and better help 2016-03-28 12:26:17 +02:00
Niklas Keller
8d085347b9 Fixup error message for issues with IPv4 resolving 2016-03-26 10:08:24 +01:00
Niklas Keller
fc3b7e948f Document all functions in functions.php 2016-03-25 20:16:43 +01:00
Niklas Keller
2b2daee8bb Refactor AcmeService creation into Factory 2016-03-25 20:04:58 +01:00
Niklas Keller
e4b9203537 Unignore composer.lock 2016-03-25 18:04:12 +01:00
Niklas Keller
c94d9b4795 Add requirements to README 2016-03-25 15:48:34 +01:00
12 changed files with 3281 additions and 66 deletions

1
.gitignore vendored
View File

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

View File

@@ -2,6 +2,11 @@
`kelunik/acme-client` is an ACME client written in PHP. ACME is the protocol that powers the [Let's Encrypt](https://letsencrypt.org) certificate authority.
## Requirements
* PHP 5.5+ with OpenSSL
* Works on Unix and Windows
## Documentation
* [Installation](./doc/installation.md)

100
bin/acme
View File

@@ -2,17 +2,22 @@
<?php
use Auryn\Injector;
use Kelunik\AcmeClient\AcmeFactory;
use League\CLImate\CLImate;
$logo = <<<LOGO
____ __________ ___ ___
/ __ `/ ___/ __ `__ \/ _ \
/ /_/ / /__/ / / / / / __/
\__,_/\___/_/ /_/ /_/\___/
LOGO;
if (!file_exists(__DIR__ . "/../vendor/autoload.php")) {
echo $logo;
echo <<<HELP
____ __________ ___ ___
/ __ `/ ___/ __ `__ \/ _ \
/ /_/ / /__/ / / / / / __/
\__,_/\___/_/ /_/ /_/\___/
You need to install the composer dependencies.
You need to install the composer dependencies.
composer install --no-dev
@@ -22,14 +27,10 @@ HELP;
}
if (!function_exists("openssl_pkey_get_private")) {
echo $logo;
echo <<<HELP
____ __________ ___ ___
/ __ `/ ___/ __ `__ \/ _ \
/ /_/ / /__/ / / / / / __/
\__,_/\___/_/ /_/ /_/\___/
You need to enable OpenSSL in your php.ini
You need to enable OpenSSL in your php.ini
HELP;
@@ -39,55 +40,57 @@ HELP;
require __DIR__ . "/../vendor/autoload.php";
$commands = [
"setup",
"issue",
"check",
"revoke",
"setup" => "Setup and register account.",
"issue" => "Issue a new certificate.",
"check" => "Check if a certificate is still valid long enough.",
"revoke" => "Revoke a certificate.",
"status" => "Show status about local certificates.",
"version" => "Print version information.",
"help" => "Print this help information.",
];
$binary = \Kelunik\AcmeClient\getBinary();
$help = implode("\n ", array_map(function ($command) use ($binary) {
return "{$binary} {$command}";
}, $commands));
$help = implode(PHP_EOL, array_map(function ($command) use ($commands) {
$help = " <green>{$command}</green>\n";
$help .= " └─ {$commands[$command]}\n";
return $help;
}, array_keys($commands)));
$help = <<<EOT
____ __________ ___ ___
/ __ `/ ___/ __ `__ \/ _ \
/ /_/ / /__/ / / / / / __/
\__,_/\___/_/ /_/ /_/\___/
<yellow>Usage:</yellow>
bin/acme [command] [--args]
Usage: bin/acme command --args
Available Commands:
{$help}
Get more help by appending --help to specific commands.
<yellow>Options:</yellow>
<green>-h, --help</green>
└─ Print this help information.
<yellow>Available commands:</yellow>
{$help}
Get more help by appending <yellow>--help</yellow> to specific commands.
EOT;
$climate = new CLImate;
$injector = new Injector;
if (!in_array(PHP_SAPI, ["cli", "phpdbg"], true)) {
$climate->error("Please run this script via CLI!");
$climate->error("Please run this script on the command line!");
exit(1);
}
if (count($argv) === 1 || in_array($argv[1], ["h", "-h", "help", "--help"], true)) {
$climate->out($help);
if (count($argv) === 1 || in_array($argv[1], ["-h", "help", "--help"], true)) {
$climate->out($logo . $help);
exit(0);
}
if (!in_array($argv[1], $commands)) {
$climate->br()->error(" Unknown command '{$argv[1]}'. Use --help for a list of available commands.");
if (!in_array($argv[1], array_keys($commands))) {
$climate->error("Unknown command '{$argv[1]}'. Use --help for a list of available commands.");
$suggestion = \Kelunik\AcmeClient\suggestCommand($argv[1], $commands);
$suggestion = \Kelunik\AcmeClient\suggestCommand($argv[1], array_keys($commands));
if ($suggestion) {
$climate->br()->out(" Did you mean '$suggestion'?");
$climate->br()->out(" Did you mean '$suggestion'?");
}
$climate->br();
@@ -104,25 +107,28 @@ try {
unset($args[1]);
$climate->arguments->add($definition);
$climate->arguments->parse(array_values($args));
} catch (Exception $e) {
if (count($argv) === 3 && in_array($argv[2], ["h", "-h", "--help", "help"], true)) {
if (count($argv) === 3 && in_array($argv[2], ["-h", "--help"], true)) {
$climate->usage(["{$binary} {$argv[1]}"]);
$climate->br();
exit(0);
} else {
$climate->usage(["{$binary} {$argv[1]}"]);
$climate->br();
$climate->error($e->getMessage());
$climate->br();
exit(1);
$climate->arguments->parse(array_values($args));
}
} catch (Exception $e) {
$climate->usage(["{$binary} {$argv[1]}"]);
$climate->br();
$climate->error($e->getMessage());
$climate->br();
exit(1);
}
$injector = new Injector;
$injector->share($climate);
$injector->share(new AcmeFactory);
$command = $injector->make($class);

View File

@@ -1,6 +1,6 @@
{
"name": "kelunik/acme-client",
"description": "Standalone PHP ACME client.",
"description": "Let's Encrypt / ACME client written in PHP for the CLI.",
"keywords": [
"ACME",
"letsencrypt",
@@ -24,7 +24,7 @@
"require-dev": {
"phpunit/phpunit": "^5",
"fabpot/php-cs-fixer": "^1.9",
"macfja/phar-builder": "dev-master#a2db582eab26ef7b15144c013408749a79fae361"
"macfja/phar-builder": "dev-events-dev-files"
},
"license": "MIT",
"authors": [
@@ -43,13 +43,30 @@
"src/functions.php"
]
},
"repositories": [
{
"type": "vcs",
"url": "https://github.com/kelunik/pharbuilder"
}
],
"extra": {
"phar-builder": {
"compression": "GZip",
"name": "acme-client.phar",
"output-dir": "build",
"include": ["src", "vendor/kelunik/acme/res", "vendor/amphp/socket/var"],
"entry-point": "bin/acme"
"include": ["info", "src", "vendor/kelunik/acme/res", "vendor/amphp/socket/var"],
"entry-point": "bin/acme",
"events": {
"command.package.start": [
"mkdir -p info",
"git describe --tags > info/build.version",
"php -r 'echo time();' > info/build.time"
],
"command.package.end": [
"rm -rf info",
"chmod +x build/acme-client.phar"
]
}
}
}
}

2941
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

16
src/AcmeFactory.php Normal file
View File

@@ -0,0 +1,16 @@
<?php
namespace Kelunik\AcmeClient;
use Kelunik\Acme\AcmeClient;
use Kelunik\Acme\AcmeService;
use Kelunik\Acme\KeyPair;
use Webmozart\Assert\Assert;
class AcmeFactory {
public function build($directory, KeyPair $keyPair) {
Assert::string($directory);
return new AcmeService(new AcmeClient($directory, $keyPair));
}
}

View File

@@ -5,11 +5,11 @@ namespace Kelunik\AcmeClient\Commands;
use Amp\CoroutineResult;
use Amp\Dns\Record;
use Exception;
use Kelunik\Acme\AcmeClient;
use Kelunik\Acme\AcmeException;
use Kelunik\Acme\AcmeService;
use Kelunik\Acme\KeyPair;
use Kelunik\Acme\OpenSSLKeyGenerator;
use Kelunik\AcmeClient\AcmeFactory;
use Kelunik\AcmeClient\Stores\CertificateStore;
use Kelunik\AcmeClient\Stores\ChallengeStore;
use Kelunik\AcmeClient\Stores\KeyStore;
@@ -21,9 +21,11 @@ use Throwable;
class Issue implements Command {
private $climate;
private $acmeFactory;
public function __construct(CLImate $climate) {
public function __construct(CLImate $climate, AcmeFactory $acmeFactory) {
$this->climate = $climate;
$this->acmeFactory = $acmeFactory;
}
public function execute(Manager $args) {
@@ -77,7 +79,7 @@ class Issue implements Command {
$this->climate->br();
$acme = new AcmeService(new AcmeClient($server, $keyPair));
$acme = $this->acmeFactory->build($server, $keyPair);
$promises = [];
foreach ($domains as $i => $domain) {
@@ -176,7 +178,7 @@ class Issue implements Command {
list($errors) = (yield \Amp\any($promises));
if (!empty($errors)) {
throw new AcmeException("Couldn't resolve the following domains to an IPv4 record: " . implode(array_keys($errors)));
throw new AcmeException("Couldn't resolve the following domains to an IPv4 record: " . implode(", ", array_keys($errors)));
}
}

View File

@@ -4,8 +4,7 @@ namespace Kelunik\AcmeClient\Commands;
use Amp\CoroutineResult;
use Amp\File\FilesystemException;
use Kelunik\Acme\AcmeClient;
use Kelunik\Acme\AcmeService;
use Kelunik\AcmeClient\AcmeFactory;
use Kelunik\AcmeClient\Stores\CertificateStore;
use Kelunik\AcmeClient\Stores\KeyStore;
use Kelunik\Certificate\Certificate;
@@ -14,9 +13,11 @@ use League\CLImate\CLImate;
class Revoke implements Command {
private $climate;
private $acmeFactory;
public function __construct(CLImate $climate) {
public function __construct(CLImate $climate, AcmeFactory $acmeFactory) {
$this->climate = $climate;
$this->acmeFactory = $acmeFactory;
}
public function execute(Manager $args) {
@@ -30,7 +31,7 @@ class Revoke implements Command {
$keyFile = \Kelunik\AcmeClient\serverToKeyname($server);
$keyPair = (yield $keyStore->get("accounts/{$keyFile}.pem"));
$acme = new AcmeService(new AcmeClient($server, $keyPair), $keyPair);
$acme = $this->acmeFactory->build($server, $keyPair);
$this->climate->br();
$this->climate->whisper(" Revoking certificate ...");
@@ -57,7 +58,7 @@ class Revoke implements Command {
$this->climate->br();
$this->climate->info(" Certificate has been revoked.");
yield (new CertificateStore(\Kelunik\AcmeClient\normalizePath($args->get("storage")). "/certs/" . $keyFile))->delete($args->get("name"));
yield (new CertificateStore(\Kelunik\AcmeClient\normalizePath($args->get("storage")) . "/certs/" . $keyFile))->delete($args->get("name"));
yield new CoroutineResult(0);
}

View File

@@ -6,11 +6,10 @@ use Amp\CoroutineResult;
use Amp\Dns\Record;
use Amp\Dns\ResolutionException;
use InvalidArgumentException;
use Kelunik\Acme\AcmeClient;
use Kelunik\Acme\AcmeException;
use Kelunik\Acme\AcmeService;
use Kelunik\Acme\OpenSSLKeyGenerator;
use Kelunik\Acme\Registration;
use Kelunik\AcmeClient\AcmeFactory;
use Kelunik\AcmeClient\Stores\KeyStore;
use Kelunik\AcmeClient\Stores\KeyStoreException;
use League\CLImate\Argument\Manager;
@@ -18,9 +17,11 @@ use League\CLImate\CLImate;
class Setup implements Command {
private $climate;
private $acmeFactory;
public function __construct(CLImate $climate) {
public function __construct(CLImate $climate, AcmeFactory $acmeFactory) {
$this->climate = $climate;
$this->acmeFactory = $acmeFactory;
}
public function execute(Manager $args) {
@@ -53,7 +54,7 @@ class Setup implements Command {
$this->climate->whisper(" Generated new private key with {$bits} bits.");
}
$acme = new AcmeService(new AcmeClient($server, $keyPair), $keyPair);
$acme = $this->acmeFactory->build($server, $keyPair);
$this->climate->whisper(" Registering with " . substr($server, 8) . " ...");

80
src/Commands/Status.php Normal file
View File

@@ -0,0 +1,80 @@
<?php
namespace Kelunik\AcmeClient\Commands;
use Kelunik\AcmeClient\Stores\CertificateStore;
use Kelunik\AcmeClient\Stores\KeyStore;
use Kelunik\AcmeClient\Stores\KeyStoreException;
use Kelunik\Certificate\Certificate;
use League\CLImate\Argument\Manager;
use League\CLImate\CLImate;
class Status {
private $climate;
public function __construct(CLImate $climate) {
$this->climate = $climate;
}
public function execute(Manager $args) {
return \Amp\resolve($this->doExecute($args));
}
/**
* @param Manager $args
* @return \Generator
*/
private function doExecute(Manager $args) {
$server = \Kelunik\AcmeClient\resolveServer($args->get("server"));
$keyName = \Kelunik\AcmeClient\serverToKeyname($server);
$storage = \Kelunik\AcmeClient\normalizePath($args->get("storage"));
try {
$keyStore = new KeyStore($storage);
yield $keyStore->get("accounts/{$keyName}.pem");
$setup = true;
} catch (KeyStoreException $e) {
$setup = false;
}
$this->climate->br();
$this->climate->out(" [" . ($setup ? "<green> ✓ </green>" : "<red> ✗ </red>") . "] " . ($setup ? "Registered on " : "Not yet registered on ") . $server);
$this->climate->br();
if (yield \Amp\File\exists($storage . "/certs/{$keyName}")) {
$certificateStore = new CertificateStore($storage . "/certs/{$keyName}");
$domains = (yield \Amp\File\scandir($storage . "/certs/{$keyName}"));
foreach ($domains as $domain) {
$pem = (yield $certificateStore->get($domain));
$cert = new Certificate($pem);
$symbol = time() > $cert->getValidTo() ? "<red> ✗ </red>" : "<green> ✓ </green>";
if (time() < $cert->getValidTo() && time() + $args->get("ttl") * 24 * 60 * 60 > $cert->getValidTo()) {
$symbol = "<yellow> ⭮ </yellow>";
}
$this->climate->out(" [" . $symbol . "] " . implode(", ", $cert->getNames()));
}
$this->climate->br();
}
}
public static function getDefinition() {
return [
"server" => \Kelunik\AcmeClient\getArgumentDescription("server"),
"storage" => \Kelunik\AcmeClient\getArgumentDescription("storage"),
"ttl" => [
"longPrefix" => "ttl",
"description" => "Minimum valid time in days, shows ⭮ if renewal is required.",
"defaultValue" => 30,
"castTo" => "int",
],
];
}
}

76
src/Commands/Version.php Normal file
View File

@@ -0,0 +1,76 @@
<?php
namespace Kelunik\AcmeClient\Commands;
use League\CLImate\Argument\Manager;
use League\CLImate\CLImate;
use RuntimeException;
class Version implements Command {
private $climate;
public function __construct(CLImate $climate) {
$this->climate = $climate;
}
public function execute(Manager $args) {
$version = $this->getVersion();
$buildTime = $this->readFileOr("info/build.time", time());
$buildDate = date('M jS Y H:i:s T', (int) trim($buildTime));
$package = json_decode($this->readFileOr("composer.json", new RuntimeException("No composer.json found.")));
$this->climate->out("┌ <green>kelunik/acme-client</green> @ <yellow>{$version}</yellow> (built: {$buildDate})");
$this->climate->out(($args->defined("deps") ? "" : "") . " " . $this->getDescription($package));
if ($args->defined("deps")) {
$lockFile = json_decode($this->readFileOr("composer.lock", new RuntimeException("No composer.lock found.")));
$packages = $lockFile->packages;
for ($i = 0; $i < count($packages); $i++) {
$link = $i === count($packages) - 1 ? "└──" : "├──";
$this->climate->out("{$link} <green>{$packages[$i]->name}</green> @ <yellow>{$packages[$i]->version}</yellow>");
$link = $i === count($packages) - 1 ? " " : "";
$this->climate->out("{$link} " . $this->getDescription($packages[$i]));
}
}
}
private function getDescription($package) {
return \Kelunik\AcmeClient\ellipsis(isset($package->description) ? $package->description : "");
}
private function getVersion() {
if (file_exists(__DIR__ . "/../../.git")) {
$version = `git describe --tags`;
} else {
$version = $this->readFileOr("info/build.version", "-unknown");
}
return substr(trim($version), 1);
}
private function readFileOr($file, $default = "") {
if (file_exists(__DIR__ . "/../../" . $file)) {
return file_get_contents(__DIR__ . "/../../" . $file);
} else {
if ($default instanceof \Exception || $default instanceof \Throwable) {
throw $default;
}
return $default;
}
}
public static function getDefinition() {
return [
"deps" => [
"longPrefix" => "deps",
"description" => "Show also the bundled dependency versions.",
"noValue" => true,
],
];
}
}

View File

@@ -2,12 +2,21 @@
namespace Kelunik\AcmeClient;
use InvalidArgumentException;
use Kelunik\Acme\AcmeException;
use Phar;
use Symfony\Component\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Yaml;
use Webmozart\Assert\Assert;
/**
* Suggests a command based on similarity in a list of available commands.
*
* @param string $badCommand invalid command
* @param array $commands list of available commands
* @param int $suggestThreshold similarity threshold
* @return string suggestion or empty string if no command is similar enough
*/
function suggestCommand($badCommand, array $commands, $suggestThreshold = 70) {
Assert::string($badCommand, "Bad command must be a string. Got: %s");
Assert::integer($suggestThreshold, "Suggest threshold must be an integer. Got: %s");
@@ -30,6 +39,13 @@ function suggestCommand($badCommand, array $commands, $suggestThreshold = 70) {
return $bestMatchPercentage >= $suggestThreshold ? $bestMatch : "";
}
/**
* Resolves a server to a valid URI. If a valid shortcut is passed, it's resolved to the defined URI. If a URI without
* protocol is passed, it will default to HTTPS.
*
* @param string $uri URI to resolve
* @return string resolved URI
*/
function resolveServer($uri) {
Assert::string($uri, "URI must be a string. Got: %s");
@@ -43,6 +59,10 @@ function resolveServer($uri) {
return $shortcuts[$uri];
}
if (strpos($uri, "/") === false) {
throw new InvalidArgumentException("Invalid server URI: " . $uri);
}
$protocol = substr($uri, 0, strpos($uri, "://"));
if (!$protocol || $protocol === $uri) {
@@ -52,6 +72,12 @@ function resolveServer($uri) {
}
}
/**
* Transforms a directory URI to a valid filename for usage as key file name.
*
* @param string $server URI to the directory
* @return string identifier usable as file name
*/
function serverToKeyname($server) {
$server = substr($server, strpos($server, "://") + 3);
@@ -62,6 +88,11 @@ function serverToKeyname($server) {
return $keyFile;
}
/**
* Checks whether the application is currently running as Phar.
*
* @return bool {@code true} if running as Phar, {@code false} otherwise
*/
function isPhar() {
if (!class_exists("Phar")) {
return false;
@@ -70,10 +101,23 @@ function isPhar() {
return Phar::running(true) !== "";
}
/**
* Normalizes a path. Replaces all backslashes with slashes and removes trailing slashes.
*
* @param string $path path to normalize
* @return string normalized path
*/
function normalizePath($path) {
return rtrim(str_replace("\\", "/", $path), "/");
}
/**
* Returns a consistent argument description for CLIMate. Valid arguments are "server" and "storage".
*
* @param string $argument argument name
* @return array CLIMate argument description
* @throws AcmeException if the provided acme-client.yml file is invalid
*/
function getArgumentDescription($argument) {
$isPhar = \Kelunik\AcmeClient\isPhar();
@@ -140,10 +184,15 @@ function getArgumentDescription($argument) {
return $argument;
default:
throw new \InvalidArgumentException("Unknown argument: " . $argument);
throw new InvalidArgumentException("Unknown argument: " . $argument);
}
}
/**
* Returns the binary that currently runs. Can be included in help texts about other commands.
*
* @return string binary callable, shortened based on PATH and CWD
*/
function getBinary() {
$binary = "bin/acme";
@@ -169,4 +218,26 @@ function getBinary() {
}
return $binary;
}
/**
* Cuts a text to a certain length and appends an ellipsis if necessary.
*
* @param string $text text to shorten
* @param int $max maximum length
* @param string $append appendix when too long
* @return string shortened string
*/
function ellipsis($text, $max = 70, $append = "") {
if (strlen($text) <= $max) {
return $text;
}
$out = substr($text, 0, $max);
if (strpos($text, " ") === false) {
return $out . $append;
}
return preg_replace("/\\w+$/", "", $out) . $append;
}