outputPath; if (null !== $outputPath) { $logHandler = new StreamHandler($outputPath, LogLevel::DEBUG, true); $this->logger->pushHandler($logHandler); } try { $this->getCertificate(); } catch (\Exception $e) { $this->logger->error( sprintf('ACME Error: %s', $e->getMessage()), [ 'exception' => $e, ] ); } if (null !== $outputPath) { $this->logger->popHandler(); } } } public function getCertificate(bool $force = false): void { // Check folder permissions. $acmeDir = self::getAcmeDirectory(); $fs = new Filesystem(); // Build ACME Cert class. $directoryUrl = $this->environment->isProduction() ? self::LETSENCRYPT_PROD : self::LETSENCRYPT_DEV; $this->logger->debug( sprintf('ACME: Using directory URL: %s', $directoryUrl) ); $acme = new ACMECert($directoryUrl); // Build LetsEncrypt settings. $settings = $this->settingsRepo->readSettings(); $acmeEmail = $settings->getAcmeEmail(); $acmeDomain = $settings->getAcmeDomains(); if (empty($acmeEmail)) { $acmeEmail = getenv('LETSENCRYPT_EMAIL'); } if (empty($acmeDomain)) { $acmeDomain = getenv('LETSENCRYPT_HOST'); } if (empty($acmeDomain)) { $acmeDomain = $settings->getBaseUrlAsUri()?->getHost(); } if (empty($acmeEmail) || empty($acmeDomain)) { throw new \RuntimeException('Missing e-mail address or domain(s).'); } $settings->setAcmeEmail($acmeEmail); $settings->setAcmeDomains($acmeDomain); $this->settingsRepo->writeSettings($settings); // Account certificate registration. if (file_exists($acmeDir . '/account_key.pem')) { $acme->loadAccountKey('file://' . $acmeDir . '/account_key.pem'); } else { $accountKey = $acme->generateECKey('P-384'); $fs->dumpFile($acmeDir . '/account_key.pem', $accountKey); $acme->loadAccountKey($accountKey); $acme->register(true, $acmeEmail); } // Renewal check. if ( !$force && file_exists($acmeDir . '/acme.crt') && $acme->getRemainingDays('file://' . $acmeDir . '/acme.crt') > self::THRESHOLD_DAYS ) { throw new \RuntimeException('Certificate does not need renewal.'); } $fs->mkdir($acmeDir . '/challenges'); $domainConfig = []; foreach (explode(',', $acmeDomain) as $domain) { $domain = trim($domain); $domainConfig[$domain] = ['challenge' => 'http-01']; } $handler = function ($opts) use ($acmeDir, $fs) { $fs->dumpFile( $acmeDir . '/challenges/' . basename($opts['key']), $opts['value'] ); return function ($opts) use ($acmeDir, $fs) { $fs->remove($acmeDir . '/challenges/' . $opts['key']); }; }; if (!file_exists($acmeDir . '/acme.key')) { $acmeKey = $acme->generateECKey('P-384'); $fs->dumpFile($acmeDir . '/acme.key', $acmeKey); } $fullchain = $acme->getCertificateChain( 'file://' . $acmeDir . '/acme.key', $domainConfig, $handler ); $fs->dumpFile($acmeDir . '/acme.crt', $fullchain); // Symlink to the shared SSL cert. $fs->remove([ $acmeDir . '/ssl.crt', $acmeDir . '/ssl.key', ]); $fs->symlink($acmeDir . '/acme.crt', $acmeDir . '/ssl.crt'); $fs->symlink($acmeDir . '/acme.key', $acmeDir . '/ssl.key'); $this->reloadServices(); $this->logger->notice('ACME certificate process successful.'); } private function reloadServices(): void { try { $this->nginx->reload(); foreach ($this->stationRepo->iterateEnabledStations() as $station) { if (!$station->getHasStarted()) { continue; } $frontend = $this->adapters->getFrontendAdapter($station); if ($frontend->supportsReload() && $frontend->isRunning($station)) { $frontend->reload($station); } } } catch (\Exception $e) { $this->logger->error( sprintf('ACME: Could not reload all adapters: %s', $e->getMessage()), [ 'exception' => $e, ] ); } } public static function getAcmeDirectory(): string { return Environment::getInstance()->getParentDirectory() . '/acme'; } public static function getCertificatePaths(): array { $acmeDir = self::getAcmeDirectory(); return [ $acmeDir . '/ssl.crt', $acmeDir . '/ssl.key', ]; } }