├── .gitignore ├── README.md ├── composer.json └── lib ├── AcmeAdapter.php ├── AcmeHost.php └── StartEvent.php /.gitignore: -------------------------------------------------------------------------------- 1 | ssl 2 | vendor 3 | composer.lock 4 | config.php -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aerys-acme 2 | 3 | ACME is a protocol to automate certificate issuance and renewal. [Aerys](https://github.com/amphp/aerys) provides a feature to encrypt hosts automatically using ACME. 4 | 5 | ## installation 6 | 7 | ``` 8 | composer require kelunik/aerys-acme:dev-master 9 | ``` 10 | 11 | ## usage 12 | 13 | ```php 14 | expose("*", 443) 25 | ->name("example.com"); 26 | 27 | // Currently we need a redirect, because the spec requires 28 | // the initial HTTP challenge to use HTTP instead of HTTPS. 29 | // If you don't want to redirect all traffic, just redirect 30 | // everything starting with "/.well-known/acme-challenge/". 31 | $http = (new Host) 32 | ->expose("*", 80) 33 | ->name("example.com") 34 | ->redirect("https://example.com"); 35 | 36 | // this will issue a test certificate, see below 37 | return (new AcmeHost($https, __DIR__ . "/ssl")) 38 | ->acceptAgreement(LETS_ENCRYPT_AGREEMENT) 39 | ->encrypt(LETS_ENCRYPT_STAGING, ["mailto:me@example.com"]); 40 | 41 | // if your domain is already whitelisted for Let's Encrypt's closed beta, 42 | // use the right server to obtain a real certificate 43 | // return (new AcmeHost($https, __DIR__ . "/ssl")) 44 | // ->acceptAgreement(LETS_ENCRYPT_AGREEMENT) 45 | // ->encrypt(LETS_ENCRYPT_BETA, ["mailto:me@example.com"]); 46 | ``` 47 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kelunik/aerys-acme", 3 | "description": "Encrypting TLS hosts automatically using ACME to issue certificates.", 4 | "require": { 5 | "amphp/aerys": "dev-master", 6 | "kelunik/acme": "^0.1", 7 | "amphp/dns": "^0.8", 8 | "benconstable/lock": "^1" 9 | }, 10 | "license": "MIT", 11 | "authors": [ 12 | { 13 | "name": "Niklas Keller", 14 | "email": "me@kelunik.com" 15 | } 16 | ], 17 | "minimum-stability": "dev", 18 | "prefer-stable": true, 19 | "autoload": { 20 | "psr-4": { 21 | "Aerys\\Acme\\": "lib" 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /lib/AcmeAdapter.php: -------------------------------------------------------------------------------- 1 | configPath = $configPath; 21 | } 22 | 23 | public function getCertificatePath(string $dns): Promise { 24 | return new Success($this->configPath . "/live/{$dns}.pem"); 25 | } 26 | 27 | public function provideChallenge(string $dns, string $token, string $payload): Promise { 28 | return resolve($this->doProvideChallenge($dns, $token, $payload)); 29 | } 30 | 31 | private function doProvideChallenge(string $dns, string $token, string $payload) { 32 | yield put($this->configPath . "/challenges/{$dns}/.well-known/acme-challenge/{$token}", $payload); 33 | } 34 | 35 | public function cleanUpChallenge(string $dns, string $token): Promise { 36 | return resolve($this->doCleanUpChallenge($dns, $token)); 37 | } 38 | 39 | private function doCleanUpChallenge(string $dns, string $token): Generator { 40 | try { 41 | yield unlink($this->configPath . "/challenges/{$dns}/.well-known/acme-challenge/{$token}"); 42 | } catch (FilesystemException $e) { 43 | // ignore, creation may already have failed 44 | } 45 | } 46 | 47 | public function getKeyPair(string $dns): Promise { 48 | return resolve($this->doGetKeyPair($dns)); 49 | } 50 | 51 | private function doGetKeyPair(string $dns): Generator { 52 | return new KeyPair( 53 | yield get($this->configPath . "/keys/{$dns}/private.pem"), 54 | yield get($this->configPath . "/keys/{$dns}/public.pem") 55 | ); 56 | } 57 | } -------------------------------------------------------------------------------- /lib/AcmeHost.php: -------------------------------------------------------------------------------- 1 | checkValidity($host); 39 | $this->host = $host; 40 | $this->path = $path; 41 | $this->showActionWarning = false; 42 | $this->agreement = null; 43 | } 44 | 45 | private function checkValidity(Host $host) { 46 | $details = $host->export(); 47 | 48 | if (!$this->isListeningOn(443, $details["interfaces"])) { 49 | throw new \InvalidArgumentException("Host isn't listening on port 443, host not allowed!"); 50 | } 51 | 52 | if (!empty($details["crypto"])) { 53 | throw new \InvalidArgumentException("Host must not have crypto settings already!"); 54 | } 55 | 56 | if (empty($details["actions"])) { 57 | $this->showActionWarning = true; 58 | } 59 | } 60 | 61 | private function isListeningOn(int $port, array $interfaces) { 62 | foreach ($interfaces as list($ip, $iPort)) { 63 | if ($iPort === $port) { 64 | return true; 65 | } 66 | } 67 | 68 | return false; 69 | } 70 | 71 | public function boot(Server $server, Logger $logger) { 72 | $this->logger = $logger; 73 | 74 | if ($this->showActionWarning) { 75 | $logger->warning("No actions registered for \$host yet, be sure to add them before injecting Host to AcmeHost for best performance."); 76 | } 77 | 78 | $server->attach(new StartEvent($this->onBoot)); 79 | } 80 | 81 | public function acceptAgreement(string $agreement): self { 82 | $this->agreement = $agreement; 83 | 84 | return $this; 85 | } 86 | 87 | public function encrypt(string $acmeServer, array $contact) { 88 | return resolve($this->doEncrypt($acmeServer, $contact)); 89 | } 90 | 91 | private function doEncrypt(string $acmeServer, array $contact): Generator { 92 | $domain = strtok(str_replace("https://", "", $acmeServer), "/"); 93 | $info = $this->host->export(); 94 | $dns = $info["name"]; 95 | 96 | $this->mkdirs( 97 | $this->path . "/accounts/{$domain}", 98 | $this->path . "/keys/{$dns}", 99 | $this->path . "/live", 100 | $this->path . "/challenges/{$dns}/.well-known/acme-challenge" 101 | ); 102 | 103 | $this->host->use(root($this->path . "/challenges/{$dns}")); 104 | $this->host->use($this); 105 | 106 | $accountKeyPair = yield $this->loadKeyPair($this->path . "/accounts/{$domain}"); 107 | $domainKeyPair = yield $this->loadKeyPair($this->path . "/keys/{$dns}"); 108 | 109 | $certificateService = new AcmeService(new AcmeClient($acmeServer, $accountKeyPair), $accountKeyPair, new AcmeAdapter($this->path)); 110 | list($selfSigned, $lifetime) = yield $certificateService->getCertificateData($dns); 111 | 112 | if ($lifetime > 30 * 24 * 60 * 60 && !$selfSigned) { // valid for more than 30 days and not self signed 113 | $this->host->encrypt($this->path . "/live/{$dns}.pem"); 114 | 115 | // TODO Add timer to renew certificate! 116 | 117 | return; 118 | } 119 | 120 | try { 121 | yield put($this->path . "/live/{$dns}.lock", $dns); 122 | } catch (FilesystemException $e) { 123 | } 124 | 125 | $lock = new Lock($this->path . "/live/{$dns}.lock"); 126 | 127 | try { 128 | $lock->acquire(); 129 | 130 | if ($lifetime < 1 || $selfSigned) { // don't touch valid certificate here if still in place. 131 | $privateKey = openssl_pkey_get_private($domainKeyPair->getPrivate()); 132 | 133 | $csr = openssl_csr_new([ 134 | "commonName" => $dns, 135 | "organizationName" => "kelunik/aerys-acme", 136 | ], $privateKey, ["digest_alg" => "sha256"]); 137 | 138 | if (!$csr) { 139 | throw new AcmeException("CSR couldn't be generated!"); 140 | } 141 | 142 | $privateCertificate = openssl_csr_sign($csr, null, $privateKey, 90, ["digest_alg" => "sha256"], random_int(0, PHP_INT_MAX)); 143 | openssl_x509_export($privateCertificate, $cert); 144 | 145 | file_put_contents($this->path . "/live/{$dns}.pem", implode("\n", [ 146 | $domainKeyPair->getPrivate(), 147 | $cert, 148 | ])); 149 | } 150 | 151 | $this->host->encrypt($this->path . "/live/{$dns}.pem"); 152 | 153 | $this->onBoot = function (Server $server) use ($certificateService, $dns, $contact, $lock) { 154 | $certificateService->issueCertificate($dns, $contact, $this->agreement)->when(function (Throwable $error = null) use ($server, $lock, $dns) { 155 | $lock->release(); 156 | unlink($this->path . "/live/{$dns}.lock"); 157 | 158 | if ($error) { 159 | $this->logger->emergency($error); 160 | // $server->stop(); 161 | } 162 | }); 163 | }; 164 | } catch (LockException $e) { 165 | do { 166 | yield new Pause(500); 167 | } while (!yield exists($this->path . "/live/{$dns}.pem")); 168 | 169 | $this->host->encrypt($this->path . "/live/{$dns}.pem"); 170 | } 171 | } 172 | 173 | private function mkdirs(string ...$path) { 174 | foreach ($path as $p) { 175 | file_exists($p) or @mkdir($p, 0700, true); 176 | } 177 | } 178 | 179 | private function loadKeyPair(string $path): Promise { 180 | return resolve($this->doLoadKeyPair($path)); 181 | } 182 | 183 | private function doLoadKeyPair(string $path): Generator { 184 | $privateExists = yield exists("{$path}/private.pem"); 185 | $publicExists = yield exists("{$path}/public.pem"); 186 | $lockExists = yield exists("{$path}/key.lock"); 187 | 188 | if ($privateExists && $publicExists) { 189 | while ($lockExists) { 190 | yield new Pause(500); 191 | $lockExists = yield exists("{$path}/key.lock"); 192 | } 193 | 194 | return new KeyPair( 195 | yield get("{$path}/private.pem"), 196 | yield get("{$path}/public.pem") 197 | ); 198 | } 199 | 200 | $lock = new Lock("{$path}/key.lock"); 201 | 202 | try { 203 | $lock->acquire(); 204 | 205 | $gen = new OpenSSLKeyGenerator; 206 | $keyPair = $gen->generate(4096); 207 | 208 | yield put("{$path}/private.pem", $keyPair->getPrivate()); 209 | yield put("{$path}/public.pem", $keyPair->getPublic()); 210 | 211 | return $keyPair; 212 | } catch (Exception $e) { 213 | do { 214 | yield new Pause(500); 215 | $lockExists = yield exists("{$path}/key.lock"); 216 | } while ($lockExists); 217 | 218 | return new KeyPair( 219 | yield get("{$path}/private.pem"), 220 | yield get("{$path}/public.pem") 221 | ); 222 | } finally { 223 | $lock->release(); 224 | 225 | unlink("{$path}/key.lock"); // do not yield in finally! 226 | } 227 | } 228 | } -------------------------------------------------------------------------------- /lib/StartEvent.php: -------------------------------------------------------------------------------- 1 | callable = $callable; 15 | } 16 | 17 | public function update(Server $server): Promise { 18 | if ($server->state() === Server::STARTED) { 19 | if ($this->callable) { 20 | $callable = $this->callable; 21 | $return = $callable($server); 22 | 23 | if ($return instanceof Promise) { 24 | return $return; 25 | } else { 26 | return new Success; 27 | } 28 | } 29 | } 30 | 31 | return new Success; 32 | } 33 | } --------------------------------------------------------------------------------