4 Commits

Author SHA1 Message Date
Niklas Keller
9b34d667d0 Merge pull request #14 from ekohl/fixes
Permission fixes and rewrite to kelunik/acme 0.3.0-dev
2016-02-24 15:45:51 +01:00
Ewoud Kohl van Wijngaarden
8ed17841b8 Rewrite to kelunik/acme version 0.3.0-dev 2016-02-24 15:33:38 +01:00
Ewoud Kohl van Wijngaarden
1fcd437aaf Remove the requirement to run as root 2016-02-24 15:33:34 +01:00
Ewoud Kohl van Wijngaarden
0db38e9d95 Correct permissions on files 2016-02-23 16:20:11 +01:00
22 changed files with 443 additions and 1119 deletions

1
.gitignore vendored
View File

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

15
.php_cs
View File

@@ -1,15 +0,0 @@
<?php
return Symfony\CS\Config\Config::create()
->level(Symfony\CS\FixerInterface::NONE_LEVEL)
->fixers([
"psr2",
"-braces",
"-psr0",
])
->finder(
Symfony\CS\Finder\DefaultFinder::create()
->in(__DIR__ . "/bin")
->in(__DIR__ . "/src")
->in(__DIR__ . "/test")
);

View File

@@ -1,28 +0,0 @@
language: php
php:
- 5.5
- 5.6
- 7.0
- nightly
cache:
directories:
- vendor
install:
- phpenv config-rm xdebug.ini
- composer self-update
- composer config --global discard-changes true
- if [[ ${TRAVIS_PHP_VERSION:0:3} == "5.5" ]]; then composer require --dev --no-update phpunit/phpunit ^4; fi
- composer require satooshi/php-coveralls dev-master --dev --no-update
- composer update --ignore-platform-reqs
- composer show --installed
script:
- find -name "*.php" -not -path "./vendor/*" -print0 | xargs -n 1 -0 php -l
- $(php -r 'if (PHP_MAJOR_VERSION >= 7) echo "phpdbg -qrr"; else echo "php";') vendor/bin/phpunit --coverage-text --coverage-clover build/logs/clover.xml
- php vendor/bin/php-cs-fixer --diff --dry-run -v fix
after_script:
- php vendor/bin/coveralls -v

140
README.md
View File

@@ -6,95 +6,14 @@
`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 development. Use at your own risk.
> **Warning**: This software is under heavy development. Use at your own risk.
## Installation
**Requirements**
* PHP 5.5+
* Composer
**Instructions using the Phar**
```bash
# Go to https://github.com/kelunik/acme-client/releases/latest
# Download the latest release archive
# Run it.
chmod +x acme-client.phar
./acme-client.phar
# Or install it globally
mv ./acme-client.phar /usr/local/bin/acme-client
```
If you want to update, just replace the old phar with a new one.
All commands require an additional `--storage` argument when using the phar. That's the path where your keys and certificates will be stored.
On Unix you could use something like `--storage /etc/acme`.
If you're using the phar, you can add a file called `acme-client.yml` next to it with the two keys `storage` and `server`.
These values will be used as default if you don't specify them, but you can still use another server by explicitly adding it as argument.
```yml
# Sample YAML configuration:
storage: /etc/acme
server: letsencrypt
```
**Instructions using Composer**
```bash
# Clone repository
git clone https://github.com/kelunik/acme-client && cd acme-client
# Checkout latest release
git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
# Install dependencies
composer install --no-dev
```
## Migration from 0.1.x to 0.2.x
```bash
# Start in ./data
cd data
# Move your account key to new location:
mkdir accounts
mv account/key.pem accounts/acme-v01.api.letsencrypt.org.directory.pem
# or accounts/acme-staging.api.letsencrypt.org.directory.pem if it's a staging key
# account should now be empty or contain just a config.json, you can delete the folder then
rm -rf account
# Migrate certificates to new location:
cd certs
mkdir acme-v01.api.letsencrypt.org.directory
# Move all your certificate directories
# Repeat for all directories!
mv example.com acme-v01.api.letsencrypt.org.directory
# or acme-staging.api.letsencrypt.org.directory
# Delete all config.json files which may exist
find -name "config.json" | xargs rm
# Update to current version
git checkout master && git pull
# Check out latest release
git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
# Update dependencies
composer update --no-dev
# Reconfigure your webserver to use the new paths
# and check (and fix) your automation commands.
git clone https://github.com/kelunik/acme-client
cd acme-client
composer install
```
## Usage
@@ -109,55 +28,28 @@ 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.
```
bin/acme setup -s letsencrypt --email me@example.com
sudo bin/acme register \
--server acme-v01.api.letsencrypt.org/directory \
--email me@example.com
```
`-s` / `--server` can either be a URI or a shortcut. Available shortcuts:
* `letsencrypt` / `letsencrypt:production`
* `letsencrypt:staging`
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.
```
bin/acme issue -s letsencrypt -d example.com:www.example.com -p /var/www/example.com
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.
To revoke a certificate, you need a valid account key currently, just like for issuance.
```
bin/acme revoke --name example.com -s letsencrypt
```
For renewal, there's the `bin/acme check` subcommand.
It exists with a non-zero exit code, if the certificate is going to expire soon.
Default check time is 30 days, but you can use `--ttl` to customize it.
You may use this as daily cron:
```
bin/acme check --name example.com --ttl 30 -s letsencrypt || bin/acme issue ...
```
You can also use a more advanced script to automatically reload the server as well.
```bash
#!/usr/bin/env bash
cd /git/kelunik/acme-client
bin/acme check --name example.com --ttl 30 -s letsencrypt
if [ $? -eq 1 ]; then
bin/acme issue -d example.com:www.example.com -p /var/www -s letsencrypt
if [ $? -eq 0 ]; then
nginx -t -q
if [ $? -eq 0 ]; then
nginx -s reload
fi
fi
fi
sudo bin/acme revoke \
--server acme-v01.api.letsencrypt.org/directory \
--cert data/live/example.com/cert.pem
```

115
bin/acme
View File

@@ -2,38 +2,14 @@
<?php
use Auryn\Injector;
use Bramus\Monolog\Formatter\ColoredLineFormatter;
use Kelunik\AcmeClient\LoggerColorScheme;
use League\CLImate\CLImate;
if (!file_exists(__DIR__ . "/../vendor/autoload.php")) {
echo <<<HELP
____ __________ ___ ___
/ __ `/ ___/ __ `__ \/ _ \
/ /_/ / /__/ / / / / / __/
\__,_/\___/_/ /_/ /_/\___/
You need to install the composer dependencies.
composer install --no-dev
HELP;
exit(-1);
}
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
require __DIR__ . "/../vendor/autoload.php";
$commands = [
"setup",
"issue",
"check",
"revoke",
];
$help = implode("\n ", array_map(function ($command) {
return "bin/acme {$command}";
}, $commands));
$help = <<<EOT
____ __________ ___ ___
@@ -44,73 +20,70 @@ $help = <<<EOT
Usage: bin/acme command --args
Available Commands:
{$help}
bin/acme register
bin/acme issue
bin/acme revoke
Get more help by appending --help to specific commands.
EOT;
$commands = [
"issue" => "Kelunik\\AcmeClient\\Commands\\Issue",
"register" => "Kelunik\\AcmeClient\\Commands\\Register",
"revoke" => "Kelunik\\AcmeClient\\Commands\\Revoke",
];
$climate = new CLImate;
$injector = new Injector;
if (!in_array(PHP_SAPI, ["cli", "phpdbg"], true)) {
$climate->error("Please run this script via CLI!");
if (!isset($argv)) {
$climate->error("\$argv is not defined");
exit(1);
}
if (count($argv) === 1 || in_array($argv[1], ["h", "-h", "help", "--help"], true)) {
$climate->out($help);
if (count($argv) === 1 || $argv[1] === "-h" || $argv[1] === "--help" || $argv[1] === "help") {
print $help;
exit(0);
}
if (!in_array($argv[1], $commands)) {
$climate->br()->error(" Unknown command '{$argv[1]}'. Use --help for a list of available commands.");
$suggestion = \Kelunik\AcmeClient\suggestCommand($argv[1], $commands);
if ($suggestion) {
$climate->br()->out(" Did you mean '$suggestion'?");
}
$climate->br();
if (!array_key_exists($argv[1], $commands)) {
$climate->error("Unknown command: '{$argv[1]}'");
exit(1);
}
/** @var \Kelunik\AcmeClient\Commands\Command $class */
$class = "Kelunik\\AcmeClient\\Commands\\" . ucfirst($argv[1]);
$definition = $class::getDefinition();
try {
$args = $argv;
unset($args[1]);
$climate->arguments->add($definition);
$climate->arguments->parse(array_values($args));
$climate->arguments->add($commands[$argv[1]]::getDefinition());
$climate->arguments->parse();
} catch (Exception $e) {
if (count($argv) === 3 && in_array($argv[2], ["h", "-h", "--help", "help"], true)) {
$climate->usage(["bin/acme {$argv[1]}"]);
$climate->br();
if ($climate->arguments->defined("help")) {
print $help;
exit(0);
} else {
$climate->usage(["bin/acme {$argv[1]}"]);
$climate->br();
$climate->error($e->getMessage());
$climate->br();
exit(1);
}
}
$injector->share($climate);
$handler = new StreamHandler("php://stdout", Logger::DEBUG);
$handler->setFormatter(new ColoredLineFormatter(new LoggerColorScheme, null, null, true, true));
$command = $injector->make($class);
$logger = new Logger("ACME");
$logger->pushHandler($handler);
Amp\run(function () use ($command, $climate) {
$handler = function ($e) use ($climate) {
$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) {
@@ -118,24 +91,10 @@ Amp\run(function () use ($command, $climate) {
});
foreach ($lines as $line) {
$climate->error($line)->br();
$logger->error($line);
}
exit(1);
};
try {
$exitCode = (yield $command->execute($climate->arguments));
if ($exitCode === null) {
$logger->warning("Invalid exit code: null, falling back to 0. Please consider reporting this as bug.");
}
exit($exitCode);
} catch (Throwable $e) {
$handler($e);
} catch (Exception $e) {
$handler($e);
}
Amp\stop();

View File

@@ -1,30 +1,17 @@
{
"name": "kelunik/acme-client",
"description": "Standalone PHP ACME client.",
"keywords": [
"ACME",
"letsencrypt",
"certificate",
"https",
"encryption",
"ssl",
"tls"
],
"require": {
"php": "^5.5|^7",
"php": ">=7.0.0",
"ext-posix": "*",
"ext-openssl": "*",
"amphp/process": "^0.1.1",
"bramus/monolog-colored-line-formatter": "^2",
"kelunik/acme": "^0.3",
"kelunik/certificate": "^1",
"league/climate": "^3",
"rdlowrey/auryn": "^1",
"webmozart/assert": "^1",
"symfony/yaml": "^3.0"
},
"require-dev": {
"phpunit/phpunit": "^5",
"fabpot/php-cs-fixer": "^1.9",
"macfja/phar-builder": "dev-master#a2db582eab26ef7b15144c013408749a79fae361"
"monolog/monolog": "^1.17",
"psr/log": "^1",
"rdlowrey/auryn": "^1"
},
"license": "MIT",
"authors": [
@@ -38,18 +25,6 @@
"autoload": {
"psr-4": {
"Kelunik\\AcmeClient\\": "src"
},
"files": [
"src/functions.php"
]
},
"extra": {
"phar-builder": {
"compression": "GZip",
"name": "acme-client.phar",
"output-dir": "build",
"include": ["src", "vendor/kelunik/acme/res"],
"entry-point": "bin/acme"
}
}
}

View File

@@ -1,29 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit backupGlobals="false"
backupStaticAttributes="false"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
processIsolation="false"
stopOnFailure="false"
syntaxCheck="false"
bootstrap="./vendor/autoload.php">
<php>
<ini name="memory_limit" value="1G"/>
<ini name="error_reporting" value="-1"/>
</php>
<testsuites>
<testsuite name="Tests">
<directory>./test</directory>
</testsuite>
</testsuites>
<filter>
<whitelist>
<directory>./src/</directory>
</whitelist>
</filter>
</phpunit>

View File

@@ -1,75 +0,0 @@
<?php
namespace Kelunik\AcmeClient\Commands;
use Amp\CoroutineResult;
use Kelunik\AcmeClient\Stores\CertificateStore;
use Kelunik\AcmeClient\Stores\CertificateStoreException;
use Kelunik\Certificate\Certificate;
use League\CLImate\Argument\Manager;
use League\CLImate\CLImate;
class Check implements Command {
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"));
$server = \Kelunik\AcmeClient\serverToKeyname($server);
$path = \Kelunik\AcmeClient\normalizePath($args->get("storage")) . "/certs/" . $server;
$certificateStore = new CertificateStore($path);
try {
$pem = (yield $certificateStore->get($args->get("name")));
} catch (CertificateStoreException $e) {
$this->climate->br()->error(" Certificate not found.")->br();
yield new CoroutineResult(1);
return;
}
$cert = new Certificate($pem);
$this->climate->br();
$this->climate->whisper(" Certificate is valid until " . date("d.m.Y", $cert->getValidTo()))->br();
if ($cert->getValidTo() > time() + $args->get("ttl") * 24 * 60 * 60) {
yield new CoroutineResult(0);
return;
}
$this->climate->comment(" Certificate is going to expire within the specified " . $args->get("ttl") . " days.")->br();
yield new CoroutineResult(1);
}
public static function getDefinition() {
return [
"server" => \Kelunik\AcmeClient\getArgumentDescription("server"),
"storage" => \Kelunik\AcmeClient\getArgumentDescription("storage"),
"name" => [
"longPrefix" => "name",
"description" => "Common name of the certificate to check.",
"required" => true,
],
"ttl" => [
"longPrefix" => "ttl",
"description" => "Minimum valid time in days.",
"defaultValue" => 30,
"castTo" => "int",
],
];
}
}

View File

@@ -2,10 +2,11 @@
namespace Kelunik\AcmeClient\Commands;
use Amp\Promise;
use League\CLImate\Argument\Manager;
interface Command {
public function execute(Manager $args);
public function execute(Manager $args): Promise;
public static function getDefinition();
public static function getDefinition(): array;
}

View File

@@ -2,187 +2,214 @@
namespace Kelunik\AcmeClient\Commands;
use Amp\CoroutineResult;
use Amp\Dns as dns;
use Amp\Dns\Record;
use Exception;
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 League\CLImate\CLImate;
use Psr\Log\LoggerInterface;
use stdClass;
use Throwable;
use function Amp\all;
use function Amp\any;
use function Amp\resolve;
class Issue implements Command {
private $climate;
private $logger;
public function __construct(CLImate $climate) {
$this->climate = $climate;
public function __construct(LoggerInterface $logger) {
$this->logger = $logger;
}
public function execute(Manager $args) {
return \Amp\resolve($this->doExecute($args));
public function execute(Manager $args): Promise {
return resolve($this->doExecute($args));
}
private function doExecute(Manager $args) {
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;
if ($currentUsername !== $user) {
throw new AcmeException("Running this script with --user only works as root!");
}
} else {
$user = $args->get("user") ?: "www-data";
private function doExecute(Manager $args): Generator {
if (posix_geteuid() !== 0) {
$processUser = posix_getpwuid(posix_geteuid());
$currentUsername = $processUser['name'];
$user = $args->get("user") ?? $currentUsername;
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(":", str_replace([",", ";"], ":", $args->get("domains"))));
yield \Amp\resolve($this->checkDnsRecords($domains));
$server = $args->get("server");
$protocol = substr($server, 0, strpos("://", $server));
$docRoots = explode(PATH_SEPARATOR, str_replace("\\", "/", $args->get("path")));
$docRoots = array_map(function ($root) {
return rtrim($root, "/");
}, $docRoots);
if (count($domains) < count($docRoots)) {
throw new AcmeException("Specified more document roots than domains.");
if (!$protocol || $protocol === $server) {
$server = "https://" . $server;
} elseif ($protocol !== "https") {
throw new \InvalidArgumentException("Invalid server protocol, only HTTPS supported");
}
if (count($domains) > count($docRoots)) {
$docRoots = array_merge(
$docRoots,
array_fill(count($docRoots), count($domains) - count($docRoots), end($docRoots))
);
}
$domains = $args->get("domains");
$domains = array_map("trim", explode(",", $domains));
yield from $this->checkDnsRecords($domains);
$keyStore = new KeyStore(\Kelunik\AcmeClient\normalizePath($args->get("storage")));
$server = \Kelunik\AcmeClient\resolveServer($args->get("server"));
$keyFile = \Kelunik\AcmeClient\serverToKeyname($server);
try {
$keyPair = (yield $keyStore->get("accounts/{$keyFile}.pem"));
} catch (KeyStoreException $e) {
throw new AcmeException("Account key not found, did you run 'bin/acme setup'?", 0, $e);
}
$this->climate->br();
$keyPair = $this->checkRegistration($args);
$acme = new AcmeService(new AcmeClient($server, $keyPair));
$promises = [];
foreach ($domains as $i => $domain) {
$promises[] = \Amp\resolve($this->solveChallenge($acme, $keyPair, $domain, $docRoots[$i]));
}
foreach ($domains as $domain) {
list($location, $challenges) = yield $acme->requestChallenges($domain);
$goodChallenges = $this->findSuitableCombination($challenges);
list($errors) = (yield \Amp\any($promises));
if (!empty($errors)) {
foreach ($errors as $error) {
$this->climate->error($error->getMessage());
if (empty($goodChallenges)) {
throw new AcmeException("Couldn't find any combination of challenges which this server can solve!");
}
throw new AcmeException("Issuance failed, not all challenges could be solved.");
$challenge = $challenges->challenges[reset($goodChallenges)];
$token = $challenge->token;
if (!preg_match("#^[a-zA-Z0-9-_]+$#", $token)) {
throw new AcmeException("Protocol Violation: Invalid Token!");
}
$this->logger->debug("Generating payload...");
$payload = $acme->generateHttp01Payload($keyPair, $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"]);
$this->logger->info("Providing payload for {$domain} at {$path}/{$token}");
file_put_contents("{$path}/{$token}", $payload);
chown("{$path}/{$token}", $userInfo["uid"]);
chmod("{$path}/{$token}", 0664);
yield $acme->verifyHttp01Challenge($domain, $token, $payload);
$this->logger->info("Successfully self-verified challenge.");
yield $acme->answerChallenge($challenge->uri, $payload);
$this->logger->info("Answered challenge... waiting");
yield $acme->pollForChallenge($location);
$this->logger->info("Challenge successful. {$domain} is now authorized.");
@unlink("{$path}/{$token}");
} catch (Throwable $e) {
// no finally because generators...
@unlink("{$path}/{$token}");
throw $e;
}
}
$path = "certs/" . $keyFile . "/" . reset($domains) . "/key.pem";
$bits = $args->get("bits");
$path = __DIR__ . "/../../data/live/" . reset($domains);
try {
$keyPair = (yield $keyStore->get($path));
} catch (KeyStoreException $e) {
$keyPair = (new OpenSSLKeyGenerator)->generate($bits);
$keyPair = (yield $keyStore->put($path, $keyPair));
if (!file_exists($path) && !mkdir($path, 0700, true)) {
throw new AcmeException("Couldn't create directory: {$path}");
}
$this->climate->br();
$this->climate->whisper(" Requesting certificate ...");
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");
$location = (yield $acme->requestCertificate($keyPair, $domains));
$certificates = (yield $acme->pollForCertificate($location));
$this->logger->info("Using existing domain key found at {$path}");
$path = \Kelunik\AcmeClient\normalizePath($args->get("storage")) . "/certs/" . $keyFile;
$certificateStore = new CertificateStore($path);
yield $certificateStore->put($certificates);
$domainKeys = new KeyPair($private, $public);
} else {
$domainKeys = (new OpenSSLKeyGenerator)->generate(2048);
$this->climate->info(" Successfully issued certificate.");
$this->climate->info(" See {$path}/" . reset($domains));
$this->climate->br();
file_put_contents($path . "/private.pem", $domainKeys->getPrivate());
file_put_contents($path . "/public.pem", $domainKeys->getPublic());
yield new CoroutineResult(0);
$this->logger->info("Saved new domain key at {$path}");
chmod($path . "/private.pem", 0600);
chmod($path . "/public.pem", 0600);
}
$this->logger->info("Requesting certificate ...");
$location = yield $acme->requestCertificate($domainKeys, $domains);
$certificates = yield $acme->pollForCertificate($location);
$this->logger->info("Saving certificate ...");
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));
$this->logger->info("Successfully issued certificate.");
}
private function solveChallenge(AcmeService $acme, KeyPair $keyPair, $domain, $path) {
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 client 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($keyPair, $token);
$this->climate->whisper(" Providing payload at http://{$domain}/.well-known/acme-challenge/{$token}");
$challengeStore = new ChallengeStore($path);
try {
$challengeStore->put($token, $payload, isset($user) ? $user : null);
yield $acme->verifyHttp01Challenge($domain, $token, $payload);
yield $acme->answerChallenge($challenge->uri, $payload);
yield $acme->pollForChallenge($location);
$this->climate->comment(" {$domain} is now authorized.");
yield $challengeStore->delete($token);
} catch (Exception $e) {
// no finally because generators...
yield $challengeStore->delete($token);
throw $e;
} catch (Throwable $e) {
// no finally because generators...
yield $challengeStore->delete($token);
throw $e;
}
}
private function checkDnsRecords($domains) {
private function checkDnsRecords($domains): Generator {
$promises = [];
foreach ($domains as $domain) {
$promises[$domain] = \Amp\Dns\resolve($domain, [
$promises[$domain] = dns\resolve($domain, [
"types" => [Record::A],
"hosts" => false,
]);
}
list($errors) = (yield \Amp\any($promises));
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)));
}
$this->logger->info("Checked DNS records, all fine.");
}
private function findSuitableCombination(stdClass $response) {
$challenges = isset($response->challenges) ? $response->challenges : [];
$combinations = isset($response->combinations) ? $response->combinations : [];
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 ?? [];
$goodChallenges = [];
foreach ($challenges as $i => $challenge) {
@@ -200,32 +227,31 @@ class Issue implements Command {
return $goodChallenges;
}
public static function getDefinition() {
public static function getDefinition(): array {
return [
"server" => \Kelunik\AcmeClient\getArgumentDescription("server"),
"storage" => \Kelunik\AcmeClient\getArgumentDescription("storage"),
"domains" => [
"prefix" => "d",
"longPrefix" => "domains",
"description" => "Colon / Semicolon / Comma separated list of domains to request a certificate for.",
"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" => "Colon (Unix) / Semicolon (Windows) 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.",
],
"bits" => [
"longPrefix" => "bits",
"description" => "Length of the private key in bit.",
"defaultValue" => 2048,
"castTo" => "int",
"description" => "Path to the document root for ACME challenges.",
"required" => false,
],
];
}

111
src/Commands/Register.php Normal file
View File

@@ -0,0 +1,111 @@
<?php
namespace Kelunik\AcmeClient\Commands;
use Amp\Dns\Record;
use Amp\Dns\ResolutionException;
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\Acme\Registration;
use League\CLImate\Argument\Manager;
use Psr\Log\LoggerInterface;
use function Amp\File\exists;
use function Amp\resolve;
class Register implements Command {
private $logger;
public function __construct(LoggerInterface $logger) {
$this->logger = $logger;
}
public function execute(Manager $args): Promise {
return resolve($this->doExecute($args));
}
public function doExecute(Manager $args): Generator {
$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, 0600);
}
$acme = new AcmeService(new AcmeClient($server, $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,
],
];
}
}

View File

@@ -2,73 +2,100 @@
namespace Kelunik\AcmeClient\Commands;
use Amp\CoroutineResult;
use Amp\File\FilesystemException;
use Amp\Promise;
use Generator;
use Kelunik\Acme\AcmeClient;
use Kelunik\Acme\AcmeException;
use Kelunik\Acme\AcmeService;
use Kelunik\AcmeClient\Stores\CertificateStore;
use Kelunik\AcmeClient\Stores\KeyStore;
use Kelunik\Acme\KeyPair;
use Kelunik\Certificate\Certificate;
use League\CLImate\Argument\Manager;
use League\CLImate\CLImate;
use Psr\Log\LoggerInterface;
use function Amp\File\exists;
use function Amp\File\get;
use function Amp\resolve;
class Revoke implements Command {
private $climate;
private $logger;
public function __construct(CLImate $climate) {
$this->climate = $climate;
public function __construct(LoggerInterface $logger) {
$this->logger = $logger;
}
public function execute(Manager $args) {
return \Amp\resolve($this->doExecute($args));
public function execute(Manager $args): Promise {
return resolve($this->doExecute($args));
}
private function doExecute(Manager $args) {
$keyStore = new KeyStore(\Kelunik\AcmeClient\normalizePath($args->get("storage")));
private function doExecute(Manager $args): Generator {
$server = $args->get("server");
$protocol = substr($server, 0, strpos("://", $server));
$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), $keyPair);
$this->climate->br();
$this->climate->whisper(" Revoking certificate ...");
$path = \Kelunik\AcmeClient\normalizePath($args->get("storage")) . "/certs/" . $keyFile . "/" . $args->get("name") . "/cert.pem";
try {
$pem = (yield \Amp\File\get($path));
$cert = new Certificate($pem);
} catch (FilesystemException $e) {
throw new \RuntimeException("There's no such certificate (" . $path . ")");
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));
$this->logger->info("Revoking certificate ...");
$pem = yield get($args->get("cert"));
$cert = new Certificate($pem);
if ($cert->getValidTo() < time()) {
$this->climate->comment(" Certificate did already expire, no need to revoke it.");
$this->logger->warning("Certificate did already expire, no need to revoke it.");
return;
}
$names = $cert->getNames();
$this->climate->whisper(" Certificate was valid for " . count($names) . " domains.");
$this->climate->whisper(" - " . implode(PHP_EOL . " - ", $names) . PHP_EOL);
$this->logger->info("Certificate was valid for: " . implode(", ", $cert->getNames()));
yield $acme->revokeCertificate($pem);
$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 CoroutineResult(0);
$this->logger->info("Certificate has been revoked.");
}
public static function getDefinition() {
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");
}
public static function getDefinition(): array {
return [
"server" => \Kelunik\AcmeClient\getArgumentDescription("server"),
"storage" => \Kelunik\AcmeClient\getArgumentDescription("storage"),
"name" => [
"longPrefix" => "name",
"description" => "Common name of the certificate to be revoked.",
"cert" => [
"prefix" => "c",
"longPrefix" => "cert",
"description" => "Certificate to be revoked.",
"required" => true,
],
"server" => [
"prefix" => "s",
"longPrefix" => "server",
"description" => "ACME server to use for authorization.",
"required" => true,
],
];

View File

@@ -1,99 +0,0 @@
<?php
namespace Kelunik\AcmeClient\Commands;
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\Stores\KeyStore;
use Kelunik\AcmeClient\Stores\KeyStoreException;
use League\CLImate\Argument\Manager;
use League\CLImate\CLImate;
class Setup implements Command {
private $climate;
public function __construct(CLImate $climate) {
$this->climate = $climate;
}
public function execute(Manager $args) {
return \Amp\resolve($this->doExecute($args));
}
public function doExecute(Manager $args) {
$email = $args->get("email");
yield \Amp\resolve($this->checkEmail($email));
$server = \Kelunik\AcmeClient\resolveServer($args->get("server"));
$keyFile = \Kelunik\AcmeClient\serverToKeyname($server);
$path = "accounts/{$keyFile}.pem";
$bits = 4096;
$keyStore = new KeyStore(\Kelunik\AcmeClient\normalizePath($args->get("storage")));
$this->climate->br();
try {
$keyPair = (yield $keyStore->get($path));
$this->climate->whisper(" Using existing private key ...");
} catch (KeyStoreException $e) {
$this->climate->whisper(" No private key found, generating new one ...");
$keyPair = (new OpenSSLKeyGenerator)->generate($bits);
$keyPair = (yield $keyStore->put($path, $keyPair));
$this->climate->whisper(" Generated new private key with {$bits} bits.");
}
$acme = new AcmeService(new AcmeClient($server, $keyPair), $keyPair);
$this->climate->whisper(" Registering with " . substr($server, 8) . " ...");
/** @var Registration $registration */
$registration = (yield $acme->register($email));
$this->climate->info(" Registration successful. Contacts: " . implode(", ", $registration->getContact()));
$this->climate->br();
yield new CoroutineResult(0);
}
private function checkEmail($email) {
if (!is_string($email)) {
throw new InvalidArgumentException(sprintf("\$email must be of type string, %s given.", gettype($email)));
}
$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() {
return [
"server" => \Kelunik\AcmeClient\getArgumentDescription("server"),
"storage" => \Kelunik\AcmeClient\getArgumentDescription("storage"),
"email" => [
"longPrefix" => "email",
"description" => "E-mail for important issues, will be sent to the ACME server.",
"required" => true,
],
];
}
}

29
src/LoggerColorScheme.php Normal file
View File

@@ -0,0 +1,29 @@
<?php
namespace Kelunik\AcmeClient;
use Bramus\Ansi\ControlSequences\EscapeSequences\Enums\SGR;
use Bramus\Monolog\Formatter\ColorSchemes\ColorSchemeInterface;
use Bramus\Monolog\Formatter\ColorSchemes\ColorSchemeTrait;
use Monolog\Logger;
class LoggerColorScheme implements ColorSchemeInterface {
use ColorSchemeTrait {
ColorSchemeTrait::__construct as private __constructTrait;
}
public function __construct() {
$this->__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(),
]);
}
}

View File

@@ -1,93 +0,0 @@
<?php
namespace Kelunik\AcmeClient\Stores;
use Amp\CoroutineResult;
use Amp\File\FilesystemException;
use InvalidArgumentException;
use Kelunik\Certificate\Certificate;
use Webmozart\Assert\Assert;
class CertificateStore {
private $root;
public function __construct($root) {
if (!is_string($root)) {
throw new InvalidArgumentException(sprintf("\$root must be of type string, %s given.", gettype($root)));
}
$this->root = rtrim(str_replace("\\", "/", $root), "/");
}
public function get($name) {
return \Amp\resolve($this->doGet($name));
}
private function doGet($name) {
Assert::string($name, "Name must be a string. Got: %s");
try {
$contents = (yield \Amp\File\get($this->root . "/" . $name . "/cert.pem"));
yield new CoroutineResult($contents);
} catch (FilesystemException $e) {
throw new CertificateStoreException("Failed to load certificate.", 0, $e);
}
}
public function put(array $certificates) {
return \Amp\resolve($this->doPut($certificates));
}
private function doPut(array $certificates) {
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, 0775, true)) {
throw new FilesystemException("Couldn't create certificate directory: '{$path}'");
}
yield \Amp\File\put($path . "/cert.pem", $certificates[0]);
yield \Amp\File\chmod($path . "/cert.pem", 0644);
yield \Amp\File\put($path . "/fullchain.pem", implode("\n", $certificates));
yield \Amp\File\chmod($path . "/fullchain.pem", 0644);
yield \Amp\File\put($path . "/chain.pem", implode("\n", $chain));
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) {
return \Amp\resolve($this->doDelete($name));
}
private function doDelete($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);
}
yield \Amp\File\rmdir($this->root . "/" . $name);
}
}

View File

@@ -1,9 +0,0 @@
<?php
namespace Kelunik\AcmeClient\Stores;
use RuntimeException;
class CertificateStoreException extends RuntimeException {
}

View File

@@ -1,73 +0,0 @@
<?php
namespace Kelunik\AcmeClient\Stores;
use InvalidArgumentException;
use Webmozart\Assert\Assert;
class ChallengeStore {
private $docroot;
public function __construct($docroot) {
if (!is_string($docroot)) {
throw new InvalidArgumentException(sprintf("\$docroot must be of type string, %s given.", gettype($docroot)));
}
$this->docroot = rtrim(str_replace("\\", "/", $docroot), "/");
}
public function put($token, $payload, $user = null) {
return \Amp\resolve($this->doPut($token, $payload, $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);
if (!realpath($this->docroot)) {
throw new ChallengeStoreException("Document root doesn't exist: '{$this->docroot}'");
}
if (!$realpath && !@mkdir($path, 0755, true)) {
throw new ChallengeStoreException("Couldn't create public directory to serve the challenges: '{$path}'");
}
if ($user) {
if (!$userInfo = posix_getpwnam($user)) {
throw new ChallengeStoreException("Unknown user: '{$user}'");
}
}
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);
if (isset($userInfo)) {
yield \Amp\File\chown("{$path}/{$token}", $userInfo["uid"], -1);
}
yield \Amp\File\chmod("{$path}/{$token}", 0644);
}
public function delete($token) {
return \Amp\resolve($this->doDelete($token));
}
private function doDelete($token) {
Assert::string($token, "Token must be a string. Got: %s");
$path = $this->docroot . "/.well-known/acme-challenge/{$token}";
$realpath = realpath($path);
if ($realpath) {
yield \Amp\File\unlink($realpath);
}
}
}

View File

@@ -1,9 +0,0 @@
<?php
namespace Kelunik\AcmeClient\Stores;
use RuntimeException;
class ChallengeStoreException extends RuntimeException {
}

View File

@@ -1,82 +0,0 @@
<?php
namespace Kelunik\AcmeClient\Stores;
use Amp\CoroutineResult;
use Amp\File\FilesystemException;
use InvalidArgumentException;
use Kelunik\Acme\KeyPair;
class KeyStore {
private $root;
public function __construct($root = "") {
if (!is_string($root)) {
throw new InvalidArgumentException(sprintf("\$root must be of type string, %s given.", gettype($root)));
}
$this->root = rtrim(str_replace("\\", "/", $root), "/");
}
public function get($path) {
if (!is_string($path)) {
throw new InvalidArgumentException(sprintf("\$root must be of type string, %s given.", gettype($path)));
}
return \Amp\resolve($this->doGet($path));
}
private function doGet($path) {
if (!is_string($path)) {
throw new InvalidArgumentException(sprintf("\$root must be of type string, %s given.", gettype($path)));
}
$file = $this->root . "/" . $path;
$realpath = realpath($file);
if (!$realpath) {
throw new KeyStoreException("File not found: '{$file}'");
}
$privateKey = (yield \Amp\File\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"];
yield new CoroutineResult(new KeyPair($privateKey, $publicKey));
}
public function put($path, KeyPair $keyPair) {
if (!is_string($path)) {
throw new InvalidArgumentException(sprintf("\$root must be of type string, %s given.", gettype($path)));
}
return \Amp\resolve($this->doPut($path, $keyPair));
}
private function doPut($path, KeyPair $keyPair) {
if (!is_string($path)) {
throw new InvalidArgumentException(sprintf("\$root must be of type string, %s given.", gettype($path)));
}
$file = $this->root . "/" . $path;
try {
// TODO: Replace with async version once available
if (!file_exists(dirname($file))) {
mkdir(dirname($file), 0755, true);
}
yield \Amp\File\put($file, $keyPair->getPrivate());
yield \Amp\File\chmod($file, 0600);
} catch (FilesystemException $e) {
throw new KeyStoreException("Could not save key.", 0, $e);
}
yield new CoroutineResult($keyPair);
}
}

View File

@@ -1,9 +0,0 @@
<?php
namespace Kelunik\AcmeClient\Stores;
use RuntimeException;
class KeyStoreException extends RuntimeException {
}

View File

@@ -1,145 +0,0 @@
<?php
namespace Kelunik\AcmeClient;
use Kelunik\Acme\AcmeException;
use Phar;
use Symfony\Component\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Yaml;
use Webmozart\Assert\Assert;
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");
$badCommand = strtolower($badCommand);
$bestMatch = "";
$bestMatchPercentage = 0;
$byRefPercentage = 0;
foreach ($commands as $command) {
\similar_text($badCommand, strtolower($command), $byRefPercentage);
if ($byRefPercentage > $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) {
$server = substr($server, strpos($server, "://") + 3);
$keyFile = str_replace("/", ".", $server);
$keyFile = preg_replace("@[^a-z0-9._-]@", "", $keyFile);
$keyFile = preg_replace("@\\.+@", ".", $keyFile);
return $keyFile;
}
function isPhar() {
if (!class_exists("Phar")) {
return false;
}
return Phar::running(true) !== "";
}
function normalizePath($path) {
return rtrim(str_replace("\\", "/", $path), "/");
}
function getArgumentDescription($argument) {
$isPhar = \Kelunik\AcmeClient\isPhar();
$config = [];
if ($isPhar) {
$configPath = substr(dirname(Phar::running(true)), strlen("phar://")) . "/acme-client.yml";
if (file_exists($configPath)) {
$configContent = file_get_contents($configPath);
try {
$value = Yaml::parse($configContent);
if (isset($value["server"]) && is_string($value["server"])) {
$config["server"] = $value["server"];
unset($value["server"]);
}
if (isset($value["storage"]) && is_string($value["storage"])) {
$config["storage"] = $value["storage"];
unset($value["storage"]);
}
if (!empty($value)) {
throw new AcmeException("Provided YAML file had unknown options: " . implode(", ", array_keys($value)));
}
} catch (ParseException $e) {
throw new AcmeException("Unable to parse the YAML file ({$configPath}): " . $e->getMessage());
}
}
}
switch ($argument) {
case "server":
$argument = [
"prefix" => "s",
"longPrefix" => "server",
"description" => "ACME server to use for registration and issuance of certificates.",
"required" => true,
];
if (isset($config["server"])) {
$argument["required"] = false;
$argument["defaultValue"] = $config["server"];
}
return $argument;
case "storage":
$argument = [
"longPrefix" => "storage",
"description" => "Storage directory for account keys and certificates.",
"required" => $isPhar,
];
if (!$isPhar) {
$argument["defaultValue"] = dirname(__DIR__) . "/data";
} else if (isset($config["storage"])) {
$argument["required"] = false;
$argument["defaultValue"] = $config["storage"];
}
return $argument;
default:
throw new \InvalidArgumentException("Unknown argument: " . $argument);
}
}

View File

@@ -1,29 +0,0 @@
<?php
namespace Kelunik\AcmeClient;
class FunctionsTest extends \PHPUnit_Framework_TestCase {
public function testResolveServer() {
$this->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"]));
}
public function testIsPhar() {
$this->assertFalse(isPhar());
}
public function testNormalizePath() {
$this->assertSame("/etc/foobar", normalizePath("/etc/foobar"));
$this->assertSame("/etc/foobar", normalizePath("/etc/foobar/"));
$this->assertSame("/etc/foobar", normalizePath("/etc/foobar/"));
$this->assertSame("C:/etc/foobar", normalizePath("C:\\etc\\foobar\\"));
}
}