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 */ 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 */ 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 $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 */ 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)); }); } }