<?php
 namespace App\Services\Ssl; use Afosto\Acme\Client as AcmeClient; use App\Models\ClientSslCertificate; use App\Models\Setting; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; use League\Flysystem\Filesystem; class AcmeService { public function issue(ClientSslCertificate $cert, array $config, ?\DateTimeInterface $billingExpiresAt = null): ClientSslCertificate { $domains = array_values(array_filter(array_merge( [$cert->common_name], (array) ($cert->alt_names ?? []) ))); if (empty($domains)) { throw new \RuntimeException('ACME issue: no domains supplied'); } $client = $this->makeClient($config); try { $order = $client->createOrder($domains); $authorizations = $client->authorize($order); $publishedTokens = []; foreach ($authorizations as $authorization) { $file = $authorization->getFile(); if (! $file) { continue; } Cache::put("acme_challenge:{$file->getFilename()}", $file->getContents(), now()->addMinutes(15)); $publishedTokens[] = $file->getFilename(); } foreach ($authorizations as $authorization) { if ($challenge = $authorization->getHttpChallenge()) { $client->validate($challenge); } } $certData = $client->getCertificate($order); $cert->update([ 'csr' => $certData->getCsr(), 'certificate' => $certData->getCertificate(false), 'certificate_chain' => $certData->getIntermediate(), 'private_key' => $certData->getPrivateKey(), 'issuer' => $config['issuer_label'] ?? 'Let\'s Encrypt', 'installed' => true, 'installed_at' => now(), 'expires_at' => $billingExpiresAt ?: $certData->getExpiryDate(), 'last_error' => null, 'last_attempt_at' => now(), ]); foreach ($publishedTokens as $t) { Cache::forget("acme_challenge:{$t}"); } app(\App\Core\Hook\HookBridge::class)->fireAction('SSLCertificateIssued', [ 'ssl_certificate_id' => $cert->id, 'client_product_id' => $cert->client_product_id, 'service_id' => $cert->client_product_id, 'client_id' => $cert->client_id, 'domain' => $cert->common_name, ]); return $cert->fresh(); } catch (\Throwable $e) { $cert->update([ 'last_error' => mb_substr($e->getMessage(), 0, 1000), 'last_attempt_at' => now(), ]); Log::error('ACME issue failed', [ 'cert_id' => $cert->id, 'domains' => $domains, 'error' => $e->getMessage(), ]); throw $e; } } public function renew(ClientSslCertificate $cert, array $config): ClientSslCertificate { return $this->issue($cert, $config, $cert->expires_at); } public function caMaterialExpiresAt(ClientSslCertificate $cert): ?\DateTimeInterface { $pem = $cert->certificate; if (! is_string($pem) || $pem === '' || ! function_exists('openssl_x509_parse')) { return null; } $parsed = @openssl_x509_parse($pem); if (! is_array($parsed) || empty($parsed['validTo_time_t'])) { return null; } return (new \DateTimeImmutable())->setTimestamp((int) $parsed['validTo_time_t']); } public function isCaMaterialRenewalDue(ClientSslCertificate $cert, int $thresholdDays = 30): bool { $caExpiry = $this->caMaterialExpiresAt($cert); if ($caExpiry === null) { return true; } return $caExpiry <= now()->addDays($thresholdDays); } public static function resolveChallengeToken(string $token): ?string { $payload = Cache::get("acme_challenge:{$token}"); return is_string($payload) && $payload !== '' ? $payload : null; } private function makeClient(array $config): AcmeClient { $email = $config['contact_email'] ?? Setting::getValue('admin_notification_email', null) ?? Setting::getValue('company_email', null); if (empty($email)) { throw new \RuntimeException('ACME contact_email is not configured.'); } $directoryUrl = $config['directory_url'] ?? 'https://acme-staging-v02.api.letsencrypt.org/directory'; $disk = Storage::disk('local'); $driver = $disk->getDriver(); if (! $driver instanceof Filesystem) { throw new \RuntimeException('Local disk is not a Flysystem instance.'); } return new AcmeClient([ 'fs' => $driver, 'mode' => str_contains($directoryUrl, 'staging') ? AcmeClient::MODE_STAGING : AcmeClient::MODE_LIVE, 'basePath' => 'ssl/acme', 'username' => $email, ]); } } 