Files
ladill-hosting/app/Services/Ssl/SslProvisioner.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

332 lines
9.5 KiB
PHP

<?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];
}
}