AzuraCast/src/Service/Acme.php

207 lines
6.2 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Repository\SettingsRepository;
use App\Entity\Repository\StationRepository;
use App\Environment;
use App\Message\AbstractMessage;
use App\Message\GenerateAcmeCertificate;
use App\Nginx\Nginx;
use App\Radio\Adapters;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use Psr\Log\LogLevel;
use skoerfgen\ACMECert\ACMECert;
use Symfony\Component\Filesystem\Filesystem;
final class Acme
{
public const LETSENCRYPT_PROD = 'https://acme-v02.api.letsencrypt.org/directory';
public const LETSENCRYPT_DEV = 'https://acme-staging-v02.api.letsencrypt.org/directory';
public const THRESHOLD_DAYS = 14;
public function __construct(
private readonly SettingsRepository $settingsRepo,
private readonly StationRepository $stationRepo,
private readonly Environment $environment,
private readonly Logger $logger,
private readonly Nginx $nginx,
private readonly Adapters $adapters,
) {
}
public function __invoke(AbstractMessage $message): void
{
if ($message instanceof GenerateAcmeCertificate) {
$outputPath = $message->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',
];
}
}