Files
ladill-hosting/app/Services/Ssl/SslRenewalService.php
T
isaaccladandCursor e251a4cf60
Deploy Ladill Hosting / deploy (push) Failing after 17s
Initial Ladill Hosting app with Gitea deploy pipeline.
Shared web hosting extracted from the platform monolith, with CI deploy
to /var/www/ladill-hosting matching Bird/Domains/Email.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 16:24:20 +00:00

330 lines
10 KiB
PHP

<?php
namespace App\Services\Ssl;
use App\Models\CustomerHostingOrder;
use App\Models\Domain;
use App\Models\HostedSite;
use App\Models\HostingNode;
use App\Models\ServerSite;
use App\Models\ServerTask;
use App\Notifications\SslExpiringNotification;
use App\Services\Hosting\Providers\SharedNodeProvider;
use Carbon\CarbonInterface;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
class SslRenewalService
{
public function __construct(
private readonly SharedNodeProvider $nodeProvider,
private readonly SslProvisioner $sslProvisioner,
) {
}
/**
* @return array{nodes: int, central: bool, server_orders: int, sites_synced: int, domains_synced: int, fallbacks: int}
*/
public function renewAll(bool $force = false, bool $dryRun = false): array
{
$stats = [
'nodes' => 0,
'central' => false,
'server_orders' => 0,
'sites_synced' => 0,
'domains_synced' => 0,
'fallbacks' => 0,
];
if ($dryRun) {
$stats['nodes'] = $this->sharedNodesWithSsl()->count();
$stats['central'] = $this->sslProvisioner->isConfigured() && $this->centralDomainsNeedingRenewal()->isNotEmpty();
$stats['server_orders'] = $this->managedServerOrdersNeedingRenewal()->count();
return $stats;
}
foreach ($this->sharedNodesWithSsl() as $node) {
try {
$this->nodeProvider->renewCertificatesOnNode($node, $force);
$stats['nodes']++;
} catch (\Throwable $exception) {
Log::error('SslRenewalService: node renew failed', [
'node_id' => $node->id,
'error' => $exception->getMessage(),
]);
}
$stats['sites_synced'] += $this->syncHostedSiteExpiriesForNode($node);
}
if ($this->sslProvisioner->isConfigured()) {
$centralDomains = $this->centralDomainsNeedingRenewal();
if ($centralDomains->isNotEmpty()) {
$this->sslProvisioner->renewCertificates($force);
$this->sslProvisioner->reloadNginx();
$stats['central'] = true;
$stats['domains_synced'] += $this->syncCentralDomainExpiries($centralDomains);
}
}
$stats['server_orders'] = $this->queueManagedServerRenewals();
$stats['fallbacks'] = $this->reissueExpiringHostedSites();
$this->notifyExpiringDomains();
return $stats;
}
/**
* @return Collection<int, HostingNode>
*/
private function sharedNodesWithSsl(): Collection
{
$nodeIds = HostedSite::query()
->where('ssl_enabled', true)
->where('status', 'active')
->whereHas('account', function ($query): void {
$query->where('status', 'active')
->whereNotNull('hosting_node_id');
})
->with('account:id,hosting_node_id')
->get()
->pluck('account.hosting_node_id')
->filter()
->unique()
->values();
if ($nodeIds->isEmpty()) {
return collect();
}
return HostingNode::query()
->whereIn('id', $nodeIds)
->where('type', HostingNode::TYPE_SHARED)
->where('status', 'active')
->get();
}
public function syncHostedSiteExpiriesForNode(HostingNode $node): int
{
$sites = HostedSite::query()
->where('ssl_enabled', true)
->where('status', 'active')
->whereHas('account', function ($query) use ($node): void {
$query->where('status', 'active')
->where('hosting_node_id', $node->id);
})
->get();
$synced = 0;
foreach ($sites as $site) {
$expiresAt = $this->readHostedSiteExpiry($node, $site);
if ($expiresAt === null) {
continue;
}
$site->update(['ssl_expires_at' => $expiresAt]);
$synced++;
if ($site->domain_id) {
Domain::query()
->whereKey($site->domain_id)
->update([
'ssl_status' => 'active',
'ssl_expires_at' => $expiresAt,
]);
}
}
return $synced;
}
private function readHostedSiteExpiry(HostingNode $node, HostedSite $site): ?CarbonInterface
{
try {
return $this->nodeProvider->readCertificateExpiryOnNode($node, $site->domain);
} catch (\Throwable $exception) {
Log::warning('SslRenewalService: could not read certificate expiry', [
'site_id' => $site->id,
'domain' => $site->domain,
'error' => $exception->getMessage(),
]);
return null;
}
}
/**
* Domains with active SSL on the central web server (not served from a shared node cert).
*
* @return Collection<int, Domain>
*/
private function centralDomainsNeedingRenewal(): Collection
{
return Domain::query()
->where('ssl_status', 'active')
->whereDoesntHave('hostedSites', function ($query): void {
$query->where('ssl_enabled', true)
->where('status', 'active')
->whereHas('account', fn ($account) => $account->where('status', 'active'));
})
->get();
}
/**
* @param Collection<int, Domain> $domains
*/
private function syncCentralDomainExpiries(Collection $domains): int
{
$synced = 0;
foreach ($domains as $domain) {
$expiresAt = $this->sslProvisioner->readCertificateExpiry($domain->host);
if ($expiresAt === null) {
continue;
}
$domain->update(['ssl_expires_at' => $expiresAt]);
$synced++;
}
return $synced;
}
private function queueManagedServerRenewals(): int
{
$queued = 0;
foreach ($this->managedServerOrdersNeedingRenewal() as $order) {
$existing = ServerTask::query()
->where('customer_hosting_order_id', $order->id)
->where('type', ServerTask::TYPE_SSL_RENEW)
->whereIn('status', [
ServerTask::STATUS_QUEUED,
ServerTask::STATUS_DISPATCHED,
ServerTask::STATUS_RUNNING,
])
->exists();
if ($existing) {
continue;
}
ServerTask::create([
'customer_hosting_order_id' => $order->id,
'server_agent_id' => $order->serverAgent?->id,
'type' => ServerTask::TYPE_SSL_RENEW,
'status' => ServerTask::STATUS_QUEUED,
'payload' => [],
'progress' => 0,
'max_attempts' => 3,
'retry_backoff_seconds' => 60,
'stale_after_seconds' => 300,
'queued_at' => now(),
'available_at' => now(),
]);
$queued++;
}
return $queued;
}
/**
* @return Collection<int, CustomerHostingOrder>
*/
private function managedServerOrdersNeedingRenewal(): Collection
{
$orderIds = ServerSite::query()
->whereIn('ssl_status', ['issued', 'active'])
->pluck('customer_hosting_order_id')
->unique()
->filter();
if ($orderIds->isEmpty()) {
return collect();
}
return CustomerHostingOrder::query()
->whereIn('id', $orderIds)
->whereHas('serverAgent')
->with('serverAgent')
->get();
}
private function reissueExpiringHostedSites(): int
{
$thresholdDays = (int) config('ssl.fallback_reissue_before_days', 10);
$threshold = now()->addDays($thresholdDays);
$sites = HostedSite::query()
->where('ssl_enabled', true)
->where('status', 'active')
->where(function ($query) use ($threshold): void {
$query->whereNull('ssl_expires_at')
->orWhere('ssl_expires_at', '<=', $threshold);
})
->whereHas('account', fn ($query) => $query->where('status', 'active'))
->with(['account.node', 'account.user'])
->get();
$reissued = 0;
foreach ($sites as $site) {
if (! $site->account?->node) {
continue;
}
try {
$this->nodeProvider->requestLetsEncryptCertificate($site);
$expiresAt = $this->readHostedSiteExpiry($site->account->node, $site);
$site->update(array_filter([
'ssl_status' => 'issued',
'ssl_error' => null,
'ssl_expires_at' => $expiresAt,
'ssl_provisioned_at' => now(),
]));
$reissued++;
} catch (\Throwable $exception) {
Log::warning('SslRenewalService: fallback re-issue failed', [
'site_id' => $site->id,
'domain' => $site->domain,
'error' => $exception->getMessage(),
]);
}
}
return $reissued;
}
private function notifyExpiringDomains(): void
{
Domain::query()
->where('ssl_status', 'active')
->whereNotNull('ssl_expires_at')
->where('ssl_expires_at', '>', now())
->with('website.user')
->each(function (Domain $domain): void {
$user = $domain->website?->user;
if ($user === null) {
return;
}
$daysUntilExpiry = (int) now()->startOfDay()->diffInDays(
$domain->ssl_expires_at->copy()->startOfDay(),
false
);
$milestones = array_map('intval', (array) config('notifications.ssl_expiry_milestones', [14, 7, 3, 1]));
if (! in_array($daysUntilExpiry, $milestones, true)) {
return;
}
$user->notify(new SslExpiringNotification($domain, $daysUntilExpiry));
});
}
}