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>
332 lines
9.5 KiB
PHP
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];
|
|
}
|
|
}
|