Initial Ladill Hosting app with Gitea deploy pipeline.
Deploy Ladill Hosting / deploy (push) Failing after 17s
Deploy Ladill Hosting / deploy (push) Failing after 17s
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>
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
<?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));
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user