Extract Ladill Servers as standalone app at servers.ladill.com.

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>
This commit is contained in:
isaacclad
2026-06-06 19:18:30 +00:00
co-authored by Cursor
commit b6c8ac343f
382 changed files with 67315 additions and 0 deletions
@@ -0,0 +1,29 @@
<?php
namespace App\Services\Ssl;
use Carbon\CarbonInterface;
use Illuminate\Support\Carbon;
class SslCertificateExpiryParser
{
public function parseOpenSslEndDate(string $output): ?CarbonInterface
{
$line = trim($output);
if ($line === '') {
return null;
}
if (str_contains($line, '=')) {
$line = trim((string) strstr($line, '='));
$line = ltrim($line, '=');
}
try {
return Carbon::parse($line);
} catch (\Throwable) {
return null;
}
}
}
+331
View File
@@ -0,0 +1,331 @@
<?php
namespace App\Services\Ssl;
use App\Models\Domain;
use App\Models\HostedSite;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\Log;
class SslProvisioner
{
public function __construct(
private readonly SslCertificateExpiryParser $expiryParser = new SslCertificateExpiryParser(),
) {
}
public function isConfigured(): bool
{
$host = config('mailinfra.web_ssh_host');
$keyPath = config('mailinfra.web_ssh_key_path');
$email = config('mailinfra.ssl_email');
return ! empty($host) && ! empty($keyPath) && ! empty($email);
}
public function provisionCertificate(Domain $domain): bool
{
$domainName = rtrim($domain->host, '.');
$webroot = config('mailinfra.ssl_webroot');
$email = config('mailinfra.ssl_email');
$domains = escapeshellarg($domainName);
$wwwDomain = 'www.' . $domainName;
if ($this->domainResolvesToServer($wwwDomain)) {
$domains .= ' -d ' . escapeshellarg($wwwDomain);
} else {
Log::info('SslProvisioner: www subdomain does not resolve, skipping', ['domain' => $wwwDomain]);
}
$commands = [
sprintf(
'certbot certonly --webroot -w %s -d %s --non-interactive --agree-tos -m %s',
escapeshellarg($webroot),
$domains,
escapeshellarg($email)
),
];
[$success, $output] = $this->sshExec($commands);
if (! $success) {
Log::error('SslProvisioner: certbot failed', [
'domain' => $domainName,
'output' => $output,
]);
return false;
}
Log::info('SslProvisioner: certificate issued', ['domain' => $domainName]);
return true;
}
/**
* @return array{success: bool, output: string, exit_code: int}
*/
public function renewCertificates(bool $force = false): array
{
$command = 'certbot renew --quiet --no-random-sleep-on-renew';
if ($force) {
$command .= ' --force-renewal';
}
[$success, $output, $exitCode] = $this->sshExecWithExitCode([$command]);
if (! $success) {
Log::warning('SslProvisioner: certbot renew completed with errors', [
'output' => $output,
'exit_code' => $exitCode,
]);
} else {
Log::info('SslProvisioner: certbot renew completed');
}
return [
'success' => $success,
'output' => $output,
'exit_code' => $exitCode,
];
}
public function readCertificateExpiry(string $domainName): ?CarbonInterface
{
$domainName = rtrim($domainName, '.');
$certPath = "/etc/letsencrypt/live/{$domainName}/fullchain.pem";
$command = sprintf(
'openssl x509 -in %s -noout -enddate 2>/dev/null',
escapeshellarg($certPath)
);
[$success, $output] = $this->sshExec([$command]);
if (! $success) {
return null;
}
return $this->expiryParser->parseOpenSslEndDate($output);
}
private function domainResolvesToServer(string $hostname): bool
{
if (! function_exists('dns_get_record')) {
return false;
}
$records = @dns_get_record($hostname, DNS_A);
if (! is_array($records) || $records === []) {
$cname = @dns_get_record($hostname, DNS_CNAME);
return is_array($cname) && $cname !== [];
}
return true;
}
public function generateNginxConfig(Domain $domain): bool
{
$domainName = rtrim($domain->host, '.');
$confDir = config('mailinfra.ssl_nginx_conf_dir', '/etc/nginx/sites-enabled');
$confPath = "{$confDir}/{$domainName}.conf";
// Check if this domain has a hosted site - use its document root instead of default
$hostedSite = HostedSite::where('domain', $domainName)->first();
if ($hostedSite) {
$includeWww = $this->domainResolvesToServer('www.' . $domainName);
$config = $this->buildHostedSiteNginxServerBlock($hostedSite, $includeWww);
} else {
$webroot = config('mailinfra.ssl_webroot');
$includeWww = $this->domainResolvesToServer('www.' . $domainName);
$config = $this->buildNginxServerBlock($domainName, $webroot, $includeWww);
}
$escapedConfig = str_replace("'", "'\\''", $config);
$commands = [
"echo '{$escapedConfig}' > {$confPath}",
];
[$success, $output] = $this->sshExec($commands);
if (! $success) {
Log::error('SslProvisioner: Nginx config generation failed', [
'domain' => $domainName,
'output' => $output,
]);
return false;
}
Log::info('SslProvisioner: Nginx config written', ['domain' => $domainName, 'path' => $confPath]);
return true;
}
public function reloadNginx(): bool
{
[$success, $output] = $this->sshExec(['nginx -t && systemctl reload nginx']);
if (! $success) {
Log::error('SslProvisioner: Nginx reload failed', ['output' => $output]);
return false;
}
return true;
}
private function buildNginxServerBlock(string $domainName, string $webroot, bool $includeWww = true): string
{
$certPath = "/etc/letsencrypt/live/{$domainName}/fullchain.pem";
$keyPath = "/etc/letsencrypt/live/{$domainName}/privkey.pem";
$serverNames = $includeWww ? "{$domainName} www.{$domainName}" : $domainName;
return <<<NGINX
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name {$serverNames};
ssl_certificate {$certPath};
ssl_certificate_key {$keyPath};
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
root {$webroot};
index index.php;
charset utf-8;
location / {
try_files \$uri \$uri/ /index.php?\$query_string;
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
error_page 404 /index.php;
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
fastcgi_param SCRIPT_FILENAME \$realpath_root\$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\.(?!well-known).* {
deny all;
}
}
NGINX;
}
private function buildHostedSiteNginxServerBlock(HostedSite $site, bool $includeWww = true): string
{
$domainName = $site->domain;
$docRoot = $site->document_root;
$username = $site->account?->username ?? 'www-data';
$phpVersion = $site->php_version ?? '8.3';
$certPath = "/etc/letsencrypt/live/{$domainName}/fullchain.pem";
$keyPath = "/etc/letsencrypt/live/{$domainName}/privkey.pem";
$serverNames = $includeWww ? "{$domainName} www.{$domainName}" : $domainName;
return <<<NGINX
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name {$serverNames};
ssl_certificate {$certPath};
ssl_certificate_key {$keyPath};
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
root {$docRoot};
index index.php index.html index.htm;
charset utf-8;
access_log /home/{$username}/logs/{$domainName}-access.log;
error_log /home/{$username}/logs/{$domainName}-error.log;
location / {
try_files \$uri \$uri/ /index.php?\$args;
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
location ~ \.php\$ {
fastcgi_pass unix:/run/php/php{$phpVersion}-fpm.sock;
fastcgi_param SCRIPT_FILENAME \$document_root\$fastcgi_script_name;
include fastcgi_params;
fastcgi_read_timeout 300;
}
location ~ /\.(?!well-known).* {
deny all;
}
location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff|woff2|ttf|svg|eot)\$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
# Security: prevent PHP execution in upload directories
location ~* /(?:uploads|files|media)/.*\.php\$ {
deny all;
}
client_max_body_size 64M;
}
server {
listen 80;
listen [::]:80;
server_name {$serverNames};
return 301 https://\$host\$request_uri;
}
NGINX;
}
/**
* @return array{0: bool, 1: string}
*/
private function sshExec(array $commands): array
{
[$success, $output] = $this->sshExecWithExitCode($commands);
return [$success, $output];
}
/**
* @return array{0: bool, 1: string, 2: int}
*/
private function sshExecWithExitCode(array $commands): array
{
$sshHost = config('mailinfra.web_ssh_host');
$sshUser = config('mailinfra.web_ssh_user', 'root');
$sshKeyPath = config('mailinfra.web_ssh_key_path');
if (empty($sshHost) || empty($sshKeyPath)) {
return [false, 'SSH not configured', 1];
}
$sshBase = sprintf(
'ssh -i %s -o StrictHostKeyChecking=no %s@%s',
escapeshellarg($sshKeyPath),
escapeshellarg($sshUser),
escapeshellarg($sshHost)
);
$fullCommand = $sshBase . ' ' . escapeshellarg(implode(' && ', $commands));
$output = [];
$exitCode = 0;
exec($fullCommand . ' 2>&1', $output, $exitCode);
return [$exitCode === 0, implode("\n", $output), $exitCode];
}
}
+329
View File
@@ -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));
});
}
}