Files
acmeclient/src/Commands/Auto.php
Niklas Keller 253d3f476b Renew if not all names are covered
Renew a certificate if not all names are covered by the current certificate yet.
Adds a new `--names` option to `check` that makes `check` fail if not all names are covered.
Resolves #34.
2016-10-22 11:41:34 +02:00

326 lines
10 KiB
PHP

<?php
namespace Kelunik\AcmeClient\Commands;
use Amp\CoroutineResult;
use Amp\File\FilesystemException;
use Amp\Process;
use Kelunik\Acme\AcmeException;
use Kelunik\AcmeClient\ConfigException;
use League\CLImate\Argument\Manager;
use League\CLImate\CLImate;
use Symfony\Component\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Yaml;
class Auto implements Command {
const EXIT_CONFIG_ERROR = 1;
const EXIT_SETUP_ERROR = 2;
const EXIT_ISSUANCE_ERROR = 3;
const EXIT_ISSUANCE_PARTIAL = 4;
const EXIT_ISSUANCE_OK = 5;
const STATUS_NO_CHANGE = 0;
const STATUS_RENEWED = 1;
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) {
$configPath = $args->get("config");
try {
$config = Yaml::parse(
yield \Amp\File\get($configPath)
);
} catch (FilesystemException $e) {
$this->climate->error("Config file ({$configPath}) not found.");
yield new CoroutineResult(self::EXIT_CONFIG_ERROR);
return;
} catch (ParseException $e) {
$this->climate->error("Config file ({$configPath}) had an invalid format and couldn't be parsed.");
yield new CoroutineResult(self::EXIT_CONFIG_ERROR);
return;
}
if ($args->exists("server")) {
$config["server"] = $args->get("server");
}
if ($args->exists("storage")) {
$config["storage"] = $args->get("storage");
}
if (!isset($config["server"])) {
$this->climate->error("Config file ({$configPath}) didn't have a 'server' set nor was it passed as command line argument.");
yield new CoroutineResult(self::EXIT_CONFIG_ERROR);
return;
}
if (!isset($config["storage"])) {
$this->climate->error("Config file ({$configPath}) didn't have a 'storage' set nor was it passed as command line argument.");
yield new CoroutineResult(self::EXIT_CONFIG_ERROR);
return;
}
if (!isset($config["email"])) {
$this->climate->error("Config file ({$configPath}) didn't have a 'email' set.");
yield new CoroutineResult(self::EXIT_CONFIG_ERROR);
return;
}
if (!isset($config["certificates"]) || !is_array($config["certificates"])) {
$this->climate->error("Config file ({$configPath}) didn't have a 'certificates' section that's an array.");
yield new CoroutineResult(self::EXIT_CONFIG_ERROR);
return;
}
$command = implode(" ", array_map("escapeshellarg", [
PHP_BINARY,
$GLOBALS["argv"][0],
"setup",
"--server",
$config["server"],
"--storage",
$config["storage"],
"--email",
$config["email"],
]));
$process = new Process($command);
$result = (yield $process->exec(Process::BUFFER_ALL));
if ($result->exit !== 0) {
$this->climate->error("Registration failed ({$result->exit})");
$this->climate->error($command);
$this->climate->br()->out($result->stdout);
$this->climate->br()->error($result->stderr);
yield new CoroutineResult(self::EXIT_SETUP_ERROR);
return;
}
$certificateChunks = array_chunk($config["certificates"], 10, true);
$errors = [];
$values = [];
foreach ($certificateChunks as $certificateChunk) {
$promises = [];
foreach ($certificateChunk as $certificate) {
$promises[] = \Amp\resolve($this->checkAndIssue($certificate, $server, $storage));
}
list($chunkErrors, $chunkValues) = (yield \Amp\any($promises));
$errors += $chunkErrors;
$values += $chunkValues;
}
$status = [
"no_change" => count(array_filter($values, function($value) { return $value === self::STATUS_NO_CHANGE; })),
"renewed" => count(array_filter($values, function($value) { return $value === self::STATUS_RENEWED; })),
"failure" => count($errors),
];
if ($status["renewed"] > 0) {
foreach ($values as $i => $value) {
if ($value === self::STATUS_RENEWED) {
$certificate = $config["certificates"][$i];
$this->climate->info("Certificate for " . implode(", ", array_keys($this->toDomainPathMap($certificate["paths"]))) . " successfully renewed.");
}
}
}
if ($status["failure"] > 0) {
foreach ($errors as $i => $error) {
$certificate = $config["certificates"][$i];
$this->climate->error("Issuance for the following domains failed: " . implode(", ", array_keys($this->toDomainPathMap($certificate["paths"]))));
$this->climate->error("Reason: {$error}");
}
$exitCode = $status["renewed"] > 0
? self::EXIT_ISSUANCE_PARTIAL
: self::EXIT_ISSUANCE_ERROR;
yield new CoroutineResult($exitCode);
return;
}
if ($status["renewed"] > 0) {
yield new CoroutineResult(self::EXIT_ISSUANCE_OK);
return;
}
}
/**
* @param array $certificate certificate configuration
* @param string $server server to use for issuance
* @param string $storage storage directory
* @return \Generator
* @throws AcmeException if something does wrong
*/
private function checkAndIssue(array $certificate, $server, $storage) {
$domainPathMap = $this->toDomainPathMap($certificate["paths"]);
$domains = array_keys($domainPathMap);
$commonName = reset($domains);
$args = [
PHP_BINARY,
$GLOBALS["argv"][0],
"check",
"--server",
$server,
"--storage",
$storage,
"--name",
$commonName,
"--names",
implode(",", $domains),
];
$command = implode(" ", array_map("escapeshellarg", $args));
$process = new Process($command);
$result = (yield $process->exec(Process::BUFFER_ALL));
if ($result->exit === 0) {
// No need for renewal
yield new CoroutineResult(self::STATUS_NO_CHANGE);
return;
}
if ($result->exit === 1) {
// Renew certificate
$args = [
PHP_BINARY,
$GLOBALS["argv"][0],
"issue",
"--server",
$server,
"--storage",
$storage,
"--domains",
implode(",", $domains),
"--path",
implode(PATH_SEPARATOR, array_values($domainPathMap)),
];
if (isset($certificate["user"])) {
$args[] = "--user";
$args[] = $certificate["user"];
}
if (isset($certificate["bits"])) {
$args[] = "--bits";
$args[] = $certificate["bits"];
}
$command = implode(" ", array_map("escapeshellarg", $args));
$process = new Process($command);
$result = (yield $process->exec(Process::BUFFER_ALL));
if ($result->exit !== 0) {
throw new AcmeException("Unexpected exit code ({$result->exit}) for '{$command}'." . PHP_EOL . $result->stdout . PHP_EOL . PHP_EOL . $result->stderr);
}
yield new CoroutineResult(self::STATUS_RENEWED);
return;
}
throw new AcmeException("Unexpected exit code ({$result->exit}) for '{$command}'." . PHP_EOL . $result->stdout . PHP_EOL . PHP_EOL . $result->stderr);
}
private function toDomainPathMap(array $paths) {
$result = [];
foreach ($paths as $path => $domains) {
if (is_numeric($path)) {
$message = <<<MESSAGE
Your configuration has the wrong format. Received a numeric value as path name.
This is most probably due to your "paths" value not being a map but a list instead.
If your configuration looks like this:
certificates:
- paths:
- /www/a: a.example.org
- /www/b: b.example.org
Rewrite it to the following format for a single certificate:
certificates:
- paths:
/www/a: a.example.org
/www/b: b.example.org
Rewrite it to the following format for two separate certificates:
certificates:
- paths:
/www/a: a.example.org
- paths:
/www/b: b.example.org
Documentation is available at https://github.com/kelunik/acme-client/blob/master/doc/usage.md#configuration
If this doesn't solve your issue, please reply to the following issue: https://github.com/kelunik/acme-client/issues/30
MESSAGE;
throw new ConfigException($message);
}
$domains = (array) $domains;
foreach ($domains as $domain) {
if (isset($result[$domain])) {
throw new ConfigException("Duplicate domain: {$domain}");
}
$result[$domain] = $path;
}
}
return $result;
}
public static function getDefinition() {
$server = \Kelunik\AcmeClient\getArgumentDescription("server");
$storage = \Kelunik\AcmeClient\getArgumentDescription("storage");
$server["required"] = false;
$storage["required"] = false;
$args = [
"server" => $server,
"storage" => $storage,
"config" => [
"prefix" => "c",
"longPrefix" => "config",
"description" => "Configuration file to read.",
"required" => true,
],
];
$configPath = \Kelunik\AcmeClient\getConfigPath();
if ($configPath) {
$args["config"]["required"] = false;
$args["config"]["defaultValue"] = $configPath;
}
return $args;
}
}