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