VPS and dedicated server ordering, managed panels, SSO, billing, and launcher integration — forked from hosting infrastructure with server-focused routes and dashboard. Co-authored-by: Cursor <cursoragent@cursor.com>
330 lines
10 KiB
PHP
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));
|
|
});
|
|
}
|
|
}
|