├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── Lescript.php ├── README.md ├── _auto_example.php ├── _example.php └── composer.json /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | phpstan: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - uses: php-actions/composer@v6 11 | 12 | - name: PHPStan Static Analysis 13 | uses: php-actions/phpstan@v3 14 | with: 15 | level: 3 16 | path: Lescript.php -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | composer.lock 3 | vendor -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Stanislav Humplik 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Lescript.php: -------------------------------------------------------------------------------- 1 | certificatesDir = $certificatesDir; 37 | $this->webRootDir = $webRootDir; 38 | $this->logger = $logger; 39 | $this->client = $client ? $client : new Client($this->ca, $this->clientUserAgent); 40 | $this->accountKeyPath = $certificatesDir . '/_account/private.pem'; 41 | } 42 | 43 | public function initAccount() 44 | { 45 | $this->initCommunication(); 46 | 47 | if (!is_file($this->accountKeyPath)) { 48 | 49 | // generate and save new private key for account 50 | // --------------------------------------------- 51 | 52 | $this->log('Starting new account registration'); 53 | $this->generateKey(dirname($this->accountKeyPath)); 54 | $this->postNewReg(); 55 | $this->log('New account certificate registered'); 56 | } else { 57 | $this->log('Account already registered. Continuing.'); 58 | $this->getAccountId(); 59 | } 60 | 61 | if (empty($this->accountId)) { 62 | throw new RuntimeException("We don't have account ID"); 63 | } 64 | 65 | $this->log("Account: " . $this->accountId); 66 | } 67 | 68 | public function initCommunication() 69 | { 70 | $this->log('ACME Client: '.$this->clientUserAgent); 71 | $this->log('Getting list of URLs for API'); 72 | 73 | $directory = $this->client->get('/directory'); 74 | if (!isset($directory['newNonce']) || !isset($directory['newAccount']) || !isset($directory['newOrder'])) { 75 | throw new RuntimeException("Missing setup urls"); 76 | } 77 | 78 | $this->urlNewNonce = $directory['newNonce']; 79 | $this->urlNewAccount = $directory['newAccount']; 80 | $this->urlNewOrder = $directory['newOrder']; 81 | 82 | $this->log('Requesting new nonce for client communication'); 83 | $this->client->get($this->urlNewNonce); 84 | } 85 | 86 | public function signDomains(array $domains, $reuseCsr = false) 87 | { 88 | $this->log('Starting certificate generation process for domains'); 89 | 90 | $privateAccountKey = $this->readPrivateKey($this->accountKeyPath); 91 | $accountKeyDetails = openssl_pkey_get_details($privateAccountKey); 92 | 93 | // start domains authentication 94 | // ---------------------------- 95 | 96 | $this->log("Requesting challenge for " . join(', ', $domains)); 97 | $response = $this->signedRequest( 98 | $this->urlNewOrder, 99 | array("identifiers" => array_map( 100 | function ($domain) { 101 | return array("type" => "dns", "value" => $domain); 102 | }, 103 | $domains 104 | )) 105 | ); 106 | 107 | $finalizeUrl = $response['finalize']; 108 | 109 | foreach ($response['authorizations'] as $authz) { 110 | // 1. getting authentication requirements 111 | // -------------------------------------- 112 | 113 | $response = $this->signedRequest($authz, ""); 114 | $domain = $response['identifier']['value']; 115 | if (empty($response['challenges'])) { 116 | throw new RuntimeException("HTTP Challenge for $domain is not available. Whole response: " . json_encode($response)); 117 | } 118 | 119 | $self = $this; 120 | $challenge = array_reduce($response['challenges'], function ($v, $w) use (&$self) { 121 | return $v ? $v : ($w['type'] == $self->challenge ? $w : false); 122 | }); 123 | if (!$challenge) throw new RuntimeException("HTTP Challenge for $domain is not available. Whole response: " . json_encode($response)); 124 | 125 | $this->log("Got challenge token for $domain"); 126 | 127 | // 2. saving authentication token for web verification 128 | // --------------------------------------------------- 129 | $directory = $this->webRootDir . '/.well-known/acme-challenge'; 130 | $tokenPath = $directory . '/' . $challenge['token']; 131 | 132 | if (!file_exists($directory) && !@mkdir($directory, 0755, true)) { 133 | throw new RuntimeException("Couldn't create directory to expose challenge: ${tokenPath}"); 134 | } 135 | 136 | $header = array( 137 | // need to be in precise order! 138 | "e" => Base64UrlSafeEncoder::encode($accountKeyDetails["rsa"]["e"]), 139 | "kty" => "RSA", 140 | "n" => Base64UrlSafeEncoder::encode($accountKeyDetails["rsa"]["n"]) 141 | 142 | ); 143 | $payload = $challenge['token'] . '.' . Base64UrlSafeEncoder::encode(hash('sha256', json_encode($header), true)); 144 | 145 | file_put_contents($tokenPath, $payload); 146 | chmod($tokenPath, 0644); 147 | 148 | // 3. verification process itself 149 | // ------------------------------- 150 | 151 | $uri = "http://${domain}/.well-known/acme-challenge/${challenge['token']}"; 152 | 153 | $this->log("Token for $domain saved at $tokenPath and should be available at $uri"); 154 | $this->log("Sending request to challenge"); 155 | 156 | // send request to challenge 157 | $maxAllowedLoops = 6; 158 | $loopCount = 1; 159 | 160 | $result = null; 161 | while ($loopCount < $maxAllowedLoops) { 162 | $result = $this->signedRequest( 163 | $challenge['url'], 164 | array("keyAuthorization" => $payload) 165 | ); 166 | 167 | if (empty($result['status']) || $result['status'] == "invalid") { 168 | throw new RuntimeException("Verification ended with error: " . json_encode($result)); 169 | } 170 | 171 | if ($result['status'] != "pending") { 172 | break; 173 | } 174 | 175 | $sleepTime = $loopCount * $loopCount; // 1 4 9 16 25 36 176 | $loopCount++; 177 | 178 | $this->log("Verification pending, sleeping " . $sleepTime . "s"); 179 | sleep($sleepTime); 180 | } 181 | 182 | if ($result['status'] === "pending") { 183 | throw new RuntimeException("Verification timed out"); 184 | } 185 | 186 | $this->log("Verification ended with status: ${result['status']}"); 187 | 188 | @unlink($tokenPath); 189 | } 190 | 191 | // requesting certificate 192 | // ---------------------- 193 | $domainPath = $this->getDomainPath(reset($domains)); 194 | 195 | // generate private key for domain if not exist 196 | if (!is_dir($domainPath) || !is_file($domainPath . '/private.pem')) { 197 | $this->generateKey($domainPath); 198 | } 199 | 200 | // load domain key 201 | $privateDomainKey = $this->readPrivateKey($domainPath . '/private.pem'); 202 | 203 | $this->client->getLastLinks(); 204 | 205 | $csr = $reuseCsr && is_file($domainPath . "/last.csr") ? 206 | $this->getCsrContent($domainPath . "/last.csr") : 207 | $this->generateCSR($privateDomainKey, $domains); 208 | 209 | $finalizeResponse = $this->signedRequest($finalizeUrl, array('csr' => $csr)); 210 | 211 | if ($this->client->getLastCode() > 299 || $this->client->getLastCode() < 200) { 212 | throw new RuntimeException("Invalid response code: " . $this->client->getLastCode() . ", " . json_encode($finalizeResponse)); 213 | } 214 | 215 | $maxAllowedLoops = 6; 216 | $loopCount = 1; 217 | while ($loopCount < $maxAllowedLoops) { 218 | $this->log("Firing Order Status Request Nr. " . $loopCount . " to: " . $this->client->getLastLocation()); 219 | $OrderStatusResponse = $this->signedRequest($this->client->getLastLocation(), ""); 220 | 221 | if (($this->client->getLastCode() > 299 || $this->client->getLastCode() < 200)) { 222 | throw new RuntimeException("Invalid response code: " . $this->client->getLastCode() . ", " . json_encode($OrderStatusResponse)); 223 | } 224 | 225 | if (($OrderStatusResponse['status'] == "valid" && !empty($OrderStatusResponse['certificate']))) { 226 | $this->log("Order Status: " . $OrderStatusResponse['status']); 227 | $location = $OrderStatusResponse['certificate']; 228 | break; 229 | } 230 | 231 | $sleepTime = $loopCount * $loopCount; // 1 4 9 16 25 36 232 | $loopCount++; 233 | 234 | $this->log("Order Status not 'valid' yet but '" . $OrderStatusResponse['status'] . "', sleeping " . $sleepTime . "s"); 235 | sleep($sleepTime); 236 | } 237 | 238 | if (empty($location)) { 239 | throw new RuntimeException("Certificate generation processing timed out (Status not 'valid')"); 240 | } 241 | 242 | // waiting loop 243 | $certificates = array(); 244 | while (1) { 245 | $this->client->getLastLinks(); 246 | 247 | $result = $this->signedRequest($location, ""); 248 | 249 | if ($this->client->getLastCode() == 202) { 250 | 251 | $this->log("Certificate generation pending, sleeping 1s"); 252 | sleep(1); 253 | 254 | } else if ($this->client->getLastCode() == 200) { 255 | 256 | $this->log("Got certificate! YAY!"); 257 | $serverCert = $this->parseFirstPemFromBody($result); 258 | $certificates[] = $serverCert; 259 | $certificates[] = substr($result, strlen($serverCert)); // rest of ca certs 260 | 261 | break; 262 | } else { 263 | 264 | throw new RuntimeException("Can't get certificate: HTTP code " . $this->client->getLastCode()); 265 | 266 | } 267 | } 268 | 269 | if (empty($certificates)) throw new RuntimeException('No certificates generated'); 270 | 271 | $this->log("Saving fullchain.pem"); 272 | file_put_contents($domainPath . '/fullchain.pem', implode("\n", $certificates)); 273 | 274 | $this->log("Saving cert.pem"); 275 | file_put_contents($domainPath . '/cert.pem', array_shift($certificates)); 276 | 277 | $this->log("Saving chain.pem"); 278 | file_put_contents($domainPath . "/chain.pem", implode("\n", $certificates)); 279 | 280 | $this->log("Done !!§§!"); 281 | } 282 | 283 | protected function readPrivateKey($path) 284 | { 285 | if (($key = openssl_pkey_get_private('file://' . $path)) === FALSE) { 286 | throw new RuntimeException(openssl_error_string()); 287 | } 288 | 289 | return $key; 290 | } 291 | 292 | protected function parseFirstPemFromBody($body) 293 | { 294 | preg_match('~(-----BEGIN.*?END CERTIFICATE-----)~s', $body, $matches); 295 | 296 | return $matches[1]; 297 | } 298 | 299 | protected function getDomainPath($domain) 300 | { 301 | return $this->certificatesDir . '/' . $domain . '/'; 302 | } 303 | 304 | protected function getAccountId() 305 | { 306 | return $this->postNewReg(); 307 | } 308 | 309 | protected function postNewReg() 310 | { 311 | $data = array( 312 | 'termsOfServiceAgreed' => true 313 | ); 314 | 315 | $this->log('Sending registration to letsencrypt server'); 316 | 317 | if ($this->contact) { 318 | $data['contact'] = $this->contact; 319 | } 320 | 321 | $response = $this->signedRequest( 322 | $this->urlNewAccount, 323 | $data 324 | ); 325 | $lastLocation = $this->client->getLastLocation(); 326 | if (!empty($lastLocation)) { 327 | $this->accountId = $lastLocation; 328 | } 329 | return $response; 330 | } 331 | 332 | protected function generateCSR($privateKey, array $domains) 333 | { 334 | $domain = reset($domains); 335 | $san = implode(",", array_map(function ($dns) { 336 | return "DNS:" . $dns; 337 | }, $domains)); 338 | $tmpConf = tmpfile(); 339 | $tmpConfMeta = stream_get_meta_data($tmpConf); 340 | $tmpConfPath = $tmpConfMeta["uri"]; 341 | 342 | // workaround to get SAN working 343 | fwrite($tmpConf, 344 | 'HOME = . 345 | RANDFILE = $ENV::HOME/.rnd 346 | [ req ] 347 | default_bits = 2048 348 | default_keyfile = privkey.pem 349 | distinguished_name = req_distinguished_name 350 | req_extensions = v3_req 351 | [ req_distinguished_name ] 352 | countryName = Country Name (2 letter code) 353 | [ v3_req ] 354 | basicConstraints = CA:FALSE 355 | subjectAltName = ' . $san . ' 356 | keyUsage = nonRepudiation, digitalSignature, keyEncipherment'); 357 | 358 | $csr = openssl_csr_new( 359 | array( 360 | "CN" => $domain, 361 | "ST" => $this->state, 362 | "C" => $this->countryCode, 363 | "O" => "Unknown", 364 | ), 365 | $privateKey, 366 | array( 367 | "config" => $tmpConfPath, 368 | "digest_alg" => "sha256" 369 | ) 370 | ); 371 | 372 | if (!$csr) throw new RuntimeException("CSR couldn't be generated! " . openssl_error_string()); 373 | 374 | openssl_csr_export($csr, $csr); 375 | fclose($tmpConf); 376 | 377 | $csrPath = $this->getDomainPath($domain) . "/last.csr"; 378 | file_put_contents($csrPath, $csr); 379 | 380 | return $this->getCsrContent($csrPath); 381 | } 382 | 383 | protected function getCsrContent($csrPath) 384 | { 385 | $csr = file_get_contents($csrPath); 386 | 387 | preg_match('~REQUEST-----(.*)-----END~s', $csr, $matches); 388 | 389 | return trim(Base64UrlSafeEncoder::encode(base64_decode($matches[1]))); 390 | } 391 | 392 | protected function generateKey($outputDirectory) 393 | { 394 | $res = openssl_pkey_new(array( 395 | "private_key_type" => OPENSSL_KEYTYPE_RSA, 396 | "private_key_bits" => 4096, 397 | )); 398 | 399 | if (!openssl_pkey_export($res, $privateKey)) { 400 | throw new RuntimeException("Key export failed!"); 401 | } 402 | 403 | $details = openssl_pkey_get_details($res); 404 | 405 | if (!is_dir($outputDirectory)) @mkdir($outputDirectory, 0700, true); 406 | if (!is_dir($outputDirectory)) throw new RuntimeException("Cant't create directory $outputDirectory"); 407 | 408 | file_put_contents($outputDirectory . '/private.pem', $privateKey); 409 | file_put_contents($outputDirectory . '/public.pem', $details['key']); 410 | } 411 | 412 | protected function signedRequest($uri, $payload, $nonce = null) 413 | { 414 | $privateKey = $this->readPrivateKey($this->accountKeyPath); 415 | $details = openssl_pkey_get_details($privateKey); 416 | 417 | $protected = array( 418 | "alg" => "RS256", 419 | "nonce" => $nonce ? $nonce : $this->client->getLastNonce(), 420 | "url" => $uri 421 | ); 422 | 423 | if ($this->accountId) { 424 | $protected["kid"] = $this->accountId; 425 | } else { 426 | $protected["jwk"] = array( 427 | "kty" => "RSA", 428 | "n" => Base64UrlSafeEncoder::encode($details["rsa"]["n"]), 429 | "e" => Base64UrlSafeEncoder::encode($details["rsa"]["e"]), 430 | ); 431 | } 432 | 433 | $payload64 = Base64UrlSafeEncoder::encode(empty($payload) ? "" : str_replace('\\/', '/', json_encode($payload))); 434 | $protected64 = Base64UrlSafeEncoder::encode(json_encode($protected)); 435 | 436 | openssl_sign($protected64 . '.' . $payload64, $signed, $privateKey, "SHA256"); 437 | 438 | $signed64 = Base64UrlSafeEncoder::encode($signed); 439 | 440 | $data = array( 441 | 'protected' => $protected64, 442 | 'payload' => $payload64, 443 | 'signature' => $signed64 444 | ); 445 | 446 | $this->log("Sending signed request to $uri"); 447 | 448 | return $this->client->post($uri, json_encode($data)); 449 | } 450 | 451 | protected function log($message) 452 | { 453 | if ($this->logger) { 454 | $this->logger->info($message); 455 | } else { 456 | echo $message . "\n"; 457 | } 458 | } 459 | } 460 | 461 | interface ClientInterface 462 | { 463 | /** 464 | * Constructor 465 | * 466 | * @param string $base the ACME API base all relative requests are sent to 467 | * @param string $userAgent ACME Client User-Agent 468 | */ 469 | public function __construct($base, $userAgent); 470 | 471 | /** 472 | * Send a POST request 473 | * 474 | * @param string $url URL to post to 475 | * @param array $data fields to sent via post 476 | * @return array|string the parsed JSON response, raw response on error 477 | */ 478 | public function post($url, $data); 479 | 480 | /** 481 | * @param string $url URL to request via get 482 | * @return array|string the parsed JSON response, raw response on error 483 | */ 484 | public function get($url); 485 | 486 | /** 487 | * Returns the Replay-Nonce header of the last request 488 | * 489 | * if no request has been made, yet. A GET on $base/directory is done and the 490 | * resulting nonce returned 491 | * 492 | * @return mixed 493 | */ 494 | public function getLastNonce(); 495 | 496 | /** 497 | * Return the Location header of the last request 498 | * 499 | * returns null if last request had no location header 500 | * 501 | * @return string|null 502 | */ 503 | public function getLastLocation(); 504 | 505 | /** 506 | * Return the HTTP status code of the last request 507 | * 508 | * @return int 509 | */ 510 | public function getLastCode(); 511 | 512 | /** 513 | * Get all Link headers of the last request 514 | * 515 | * @return string[] 516 | */ 517 | public function getLastLinks(); 518 | } 519 | 520 | class Client implements ClientInterface 521 | { 522 | protected $lastCode; 523 | protected $lastHeader; 524 | 525 | protected $base; 526 | protected $userAgent; 527 | 528 | public function __construct($base, $userAgent) 529 | { 530 | $this->base = $base; 531 | $this->userAgent = $userAgent; 532 | } 533 | 534 | protected function curl($method, $url, $data = null) 535 | { 536 | $headers = array('Accept: application/json', 'Content-Type: application/jose+json'); 537 | $handle = curl_init(); 538 | curl_setopt($handle, CURLOPT_URL, preg_match('~^http~', $url) ? $url : $this->base . $url); 539 | curl_setopt($handle, CURLOPT_HTTPHEADER, $headers); 540 | curl_setopt($handle, CURLOPT_RETURNTRANSFER, true); 541 | curl_setopt($handle, CURLOPT_HEADER, true); 542 | curl_setopt($handle, CURLOPT_USERAGENT, $this->userAgent); 543 | 544 | // DO NOT DO THAT! 545 | // curl_setopt($handle, CURLOPT_SSL_VERIFYHOST, false); 546 | // curl_setopt($handle, CURLOPT_SSL_VERIFYPEER, false); 547 | 548 | switch ($method) { 549 | case 'GET': 550 | break; 551 | case 'POST': 552 | curl_setopt($handle, CURLOPT_POST, true); 553 | curl_setopt($handle, CURLOPT_POSTFIELDS, $data); 554 | break; 555 | } 556 | $response = curl_exec($handle); 557 | 558 | if (curl_errno($handle)) { 559 | throw new RuntimeException('Curl: ' . curl_error($handle)); 560 | } 561 | 562 | $header_size = curl_getinfo($handle, CURLINFO_HEADER_SIZE); 563 | 564 | $header = substr($response, 0, $header_size); 565 | $body = substr($response, $header_size); 566 | 567 | $this->lastHeader = $header; 568 | $this->lastCode = curl_getinfo($handle, CURLINFO_HTTP_CODE); 569 | 570 | if ($this->lastCode >= 400 && $this->lastCode < 600) { 571 | throw new RuntimeException($this->lastCode . "\n" . $body); 572 | } 573 | 574 | $data = json_decode($body, true); 575 | return $data === null ? $body : $data; 576 | } 577 | 578 | public function post($url, $data) 579 | { 580 | return $this->curl('POST', $url, $data); 581 | } 582 | 583 | public function get($url) 584 | { 585 | return $this->curl('GET', $url); 586 | } 587 | 588 | public function getLastNonce() 589 | { 590 | if (preg_match('~Replay-Nonce: (.+)~i', $this->lastHeader, $matches)) { 591 | return trim($matches[1]); 592 | } 593 | 594 | throw new RuntimeException("We don't have nonce"); 595 | } 596 | 597 | public function getLastLocation() 598 | { 599 | if (preg_match('~Location: (.+)~i', $this->lastHeader, $matches)) { 600 | return trim($matches[1]); 601 | } 602 | return null; 603 | } 604 | 605 | public function getLastCode() 606 | { 607 | return $this->lastCode; 608 | } 609 | 610 | public function getLastLinks() 611 | { 612 | preg_match_all('~Link: <(.+)>;rel="up"~', $this->lastHeader, $matches); 613 | return $matches[1]; 614 | } 615 | } 616 | 617 | class Base64UrlSafeEncoder 618 | { 619 | public static function encode($input) 620 | { 621 | return str_replace('=', '', strtr(base64_encode($input), '+/', '-_')); 622 | } 623 | 624 | public static function decode($input) 625 | { 626 | $remainder = strlen($input) % 4; 627 | if ($remainder) { 628 | $padlen = 4 - $remainder; 629 | $input .= str_repeat('=', $padlen); 630 | } 631 | return base64_decode(strtr($input, '-_', '+/')); 632 | } 633 | } 634 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Simple Let's encrypt client concept in PHP 2 | 3 | > **Note**: lescript is standalone part of [LEManager](https://github.com/analogic/lemanager) 4 | 5 | Lescript is a simplified concept of ACME client implementation specifically for the Let's Encrypt service. It's goal is to have one 6 | PHP file with no dependencies. 7 | 8 | **Use at your own risk.** 9 | 10 | ## Usage 11 | 12 | See commented content of **Lescript.php** and **_example.php**. Please rewrite the files to suit your needs - the purpose of this library is not to use it as is, nor to use it in production! 13 | 14 | Support **challenge only through webroot**. 15 | 16 | ## Requirements 17 | 18 | - PHP 5.3 and up 19 | - OpenSSL extension 20 | - Curl extension 21 | 22 | ## Why i created lescript? 23 | 24 | Because of implementation of Let's Encrypt to [Poste.io](https://poste.io)! 25 | -------------------------------------------------------------------------------- /_auto_example.php: -------------------------------------------------------------------------------- 1 | 10 | // Licence: AGPLv3. 11 | // 12 | // In addition, Stanislav Humplik is explicitly granted permission 13 | // to relicence this code under the open source licence of their choice. 14 | 15 | if(!defined("PHP_VERSION_ID") || PHP_VERSION_ID < 50300 || !extension_loaded('openssl') || !extension_loaded('curl')) { 16 | die("You need at least PHP 5.3.0 with OpenSSL and curl extension\n"); 17 | } 18 | 19 | // Configuration: 20 | $domains = array('test.example.com', 'example.com'); 21 | $webroot = "/var/www/html"; 22 | $certlocation = "/usr/local/lescript"; 23 | 24 | require 'Lescript.php'; 25 | 26 | // Always use UTC 27 | date_default_timezone_set("UTC"); 28 | 29 | // you can use any logger according to Psr\Log\LoggerInterface 30 | class Logger { function __call($name, $arguments) { echo date('Y-m-d H:i:s')." [$name] ${arguments[0]}\n"; }} 31 | $logger = new Logger(); 32 | 33 | // Make sure our cert location exists 34 | if (!is_dir($certlocation)) { 35 | // Make sure nothing is already there. 36 | if (file_exists($certlocation)) { 37 | unlink($certlocation); 38 | } 39 | mkdir ($certlocation); 40 | } 41 | 42 | // Do we need to create or upgrade our cert? Assume no to start with. 43 | $needsgen = false; 44 | 45 | // Do we HAVE a certificate for all our domains? 46 | foreach ($domains as $d) { 47 | $certfile = "$certlocation/$d/cert.pem"; 48 | 49 | if (!file_exists($certfile)) { 50 | // We don't have a cert, so we need to request one. 51 | $needsgen = true; 52 | } else { 53 | // We DO have a certificate. 54 | $certdata = openssl_x509_parse(file_get_contents($certfile)); 55 | 56 | // If it expires in less than a month, we want to renew it. 57 | $renewafter = $certdata['validTo_time_t']-(86400*30); 58 | if (time() > $renewafter) { 59 | // Less than a month left, we need to renew. 60 | $needsgen = true; 61 | } 62 | } 63 | } 64 | 65 | // Do we need to generate a certificate? 66 | if ($needsgen) { 67 | try { 68 | $le = new Analogic\ACME\Lescript($certlocation, $webroot, $logger); 69 | # or without logger: 70 | # $le = new Analogic\ACME\Lescript($certlocation, $webroot); 71 | $le->initAccount(); 72 | $le->signDomains($domains); 73 | 74 | } catch (\Exception $e) { 75 | $logger->error($e->getMessage()); 76 | $logger->error($e->getTraceAsString()); 77 | // Exit with an error code, something went wrong. 78 | exit(1); 79 | } 80 | } 81 | 82 | // Create a complete .pem file for use with haproxy or apache 2.4, 83 | // and save it as domain.name.pem for easy reference. It doesn't 84 | // matter that this is updated each time, as it'll be exactly 85 | // the same. 86 | foreach ($domains as $d) { 87 | $pem = file_get_contents("$certlocation/$d/fullchain.pem")."\n".file_get_contents("$certlocation/$d/private.pem"); 88 | file_put_contents("$certlocation/$d.pem", $pem); 89 | } 90 | 91 | 92 | -------------------------------------------------------------------------------- /_example.php: -------------------------------------------------------------------------------- 1 | contact = array('mailto:test@test.com'); // optional 19 | 20 | $le->initAccount(); 21 | $le->signDomains(array('test.com', 'www.test.com')); 22 | 23 | } catch (\Exception $e) { 24 | 25 | $logger->error($e->getMessage()); 26 | $logger->error($e->getTraceAsString()); 27 | } 28 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "analogic/lescript", 3 | "description": "Lescript is simplified concept of ACME client implementation especially for Let's encrypt service. It's goal is to have one easy to use PHP file without dependencies.", 4 | "type": "library", 5 | "license": "MIT", 6 | "keywords": [ 7 | "encryption","letsencrypt", "ACME" 8 | ], 9 | "authors": [ 10 | { 11 | "name": "Stanislav Humplik", 12 | "email": "sh@analogic.cz", 13 | "homepage": "https://analogic.cz", 14 | "role": "Developer" 15 | } 16 | ], 17 | "require": { 18 | "php": ">=5.3", 19 | "ext-json": "*", 20 | "ext-curl": "*", 21 | "ext-openssl": "*" 22 | }, 23 | "require-dev": { 24 | "psr/log": "^1", 25 | "phpstan/phpstan": "^1.10" 26 | }, 27 | "autoload": { 28 | "files": ["Lescript.php"] 29 | } 30 | } 31 | --------------------------------------------------------------------------------