Files
jabali-panel/app/Console/Commands/Jabali/SslCheckCommand.php
2026-02-02 03:11:45 +02:00

464 lines
16 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Console\Commands\Jabali;
use App\Models\Domain;
use App\Models\SslCertificate;
use App\Services\AdminNotificationService;
use App\Services\Agent\AgentClient;
use Carbon\Carbon;
use Exception;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
class SslCheckCommand extends Command
{
protected $signature = 'jabali:ssl-check
{--domain= : Check a specific domain only}
{--issue-only : Only issue certificates for domains without SSL}
{--renew-only : Only renew expiring certificates}';
protected $description = 'Check SSL certificates and automatically issue/renew them';
private AgentClient $agent;
private int $issued = 0;
private int $renewed = 0;
private int $failed = 0;
private int $skipped = 0;
private array $logEntries = [];
private string $logFile = '';
public function __construct()
{
parent::__construct();
$this->agent = new AgentClient();
}
public function handle(): int
{
$this->initializeLogging();
$this->log('Starting SSL certificate check...');
$this->info('Starting SSL certificate check...');
$this->newLine();
$domain = $this->option('domain');
$issueOnly = $this->option('issue-only');
$renewOnly = $this->option('renew-only');
if ($domain) {
$this->processSingleDomain($domain);
} else {
if (!$renewOnly) {
$this->issueMissingCertificates();
}
if (!$issueOnly) {
$this->renewExpiringCertificates();
}
// Check for certificates expiring very soon (7 days) and notify
$this->notifyExpiringSoon();
}
$this->newLine();
$this->info('SSL Check Complete');
$this->table(
['Metric', 'Count'],
[
['Issued', $this->issued],
['Renewed', $this->renewed],
['Failed', $this->failed],
['Skipped', $this->skipped],
]
);
// Log summary
$this->log('');
$this->log('=== SSL Check Complete ===');
$this->log("Issued: {$this->issued}");
$this->log("Renewed: {$this->renewed}");
$this->log("Failed: {$this->failed}");
$this->log("Skipped: {$this->skipped}");
// Save log file
$this->saveLog();
// Clean old logs (older than 3 months)
$this->cleanOldLogs();
return $this->failed > 0 ? 1 : 0;
}
private function initializeLogging(): void
{
$logDir = storage_path('logs/ssl');
try {
if (!is_dir($logDir)) {
mkdir($logDir, 0775, true);
}
// Ensure directory is writable
if (!is_writable($logDir)) {
chmod($logDir, 0775);
}
$this->logFile = $logDir . '/ssl-check-' . date('Y-m-d_H-i-s') . '.log';
} catch (\Exception $e) {
// Fall back to temp directory if storage is not writable
$this->logFile = sys_get_temp_dir() . '/ssl-check-' . date('Y-m-d_H-i-s') . '.log';
}
$this->logEntries = [];
}
private function log(string $message, string $level = 'INFO'): void
{
$timestamp = date('Y-m-d H:i:s');
$this->logEntries[] = "[{$timestamp}] [{$level}] {$message}";
}
private function saveLog(): void
{
if (empty($this->logFile) || empty($this->logEntries)) {
return;
}
try {
$content = implode("\n", $this->logEntries) . "\n";
// Ensure parent directory exists
$logDir = dirname($this->logFile);
if (!is_dir($logDir)) {
@mkdir($logDir, 0775, true);
}
if (@file_put_contents($this->logFile, $content) !== false) {
// Also create/update a symlink to latest log
$latestLink = storage_path('logs/ssl/latest.log');
@unlink($latestLink);
@symlink($this->logFile, $latestLink);
$this->line("Log saved to: {$this->logFile}");
} else {
$this->warn("Could not save log to: {$this->logFile}");
}
} catch (\Exception $e) {
$this->warn("Log save failed: {$e->getMessage()}");
}
}
private function cleanOldLogs(): void
{
$logDir = storage_path('logs/ssl');
$cutoffDate = now()->subMonths(3);
$deletedCount = 0;
foreach (glob("{$logDir}/ssl-check-*.log") as $file) {
$fileTime = filemtime($file);
if ($fileTime < $cutoffDate->timestamp) {
unlink($file);
$deletedCount++;
}
}
if ($deletedCount > 0) {
$this->log("Cleaned up {$deletedCount} old log files (older than 3 months)");
}
}
private function processSingleDomain(string $domainName): void
{
$domain = Domain::where('domain', $domainName)->with(['user', 'sslCertificate'])->first();
if (!$domain) {
$this->log("Domain not found: {$domainName}", 'ERROR');
$this->error("Domain not found: {$domainName}");
$this->failed++;
return;
}
$this->log("Processing domain: {$domainName}");
$this->line("Processing domain: {$domainName}");
$ssl = $domain->sslCertificate;
if (!$ssl || $ssl->status === 'failed') {
$this->issueCertificate($domain);
} elseif ($ssl->needsRenewal()) {
$this->renewCertificate($domain);
} else {
$this->log("Certificate is valid for {$domainName}, expires: {$ssl->expires_at->format('Y-m-d')}");
$this->line(" - Certificate is valid, expires: {$ssl->expires_at->format('Y-m-d')}");
$this->skipped++;
}
}
private function issueMissingCertificates(): void
{
$this->log('Checking domains without SSL certificates...');
$this->info('Checking domains without SSL certificates...');
$domains = Domain::whereDoesntHave('sslCertificate')
->orWhereHas('sslCertificate', function ($q) {
$q->where('status', 'failed')
->where('renewal_attempts', '<', 3)
->where(function ($q2) {
$q2->whereNull('last_check_at')
->orWhere('last_check_at', '<', now()->subHours(6));
});
})
->with(['user', 'sslCertificate'])
->get();
$this->log("Found {$domains->count()} domains without valid SSL");
$this->line("Found {$domains->count()} domains without valid SSL");
foreach ($domains as $domain) {
$this->issueCertificate($domain);
}
}
private function renewExpiringCertificates(): void
{
$this->log('Checking certificates that need renewal...');
$this->info('Checking certificates that need renewal...');
$certificates = SslCertificate::where('auto_renew', true)
->where('type', 'lets_encrypt')
->where('status', 'active')
->where('expires_at', '<=', now()->addDays(30))
->where('renewal_attempts', '<', 5)
->with(['domain.user'])
->get();
$this->log("Found {$certificates->count()} certificates needing renewal");
$this->line("Found {$certificates->count()} certificates needing renewal");
foreach ($certificates as $ssl) {
if ($ssl->domain) {
$this->renewCertificate($ssl->domain);
}
}
}
private function notifyExpiringSoon(): void
{
$certificates = SslCertificate::where('status', 'active')
->where('expires_at', '<=', now()->addDays(7))
->where('expires_at', '>', now())
->with(['domain'])
->get();
foreach ($certificates as $ssl) {
if ($ssl->domain) {
$daysLeft = (int) now()->diffInDays($ssl->expires_at);
$this->log("Certificate expiring soon: {$ssl->domain->domain} ({$daysLeft} days left)", 'WARN');
AdminNotificationService::sslExpiring($ssl->domain->domain, $daysLeft);
}
}
}
private function issueCertificate(Domain $domain): void
{
if (!$domain->user) {
$this->log("Skipping {$domain->domain}: No user associated", 'WARN');
$this->warn(" Skipping {$domain->domain}: No user associated");
$this->skipped++;
return;
}
// Check if domain DNS points to this server
if (!$this->domainPointsToServer($domain->domain)) {
$this->log("Skipping {$domain->domain}: DNS does not point to this server", 'WARN');
$this->warn(" Skipping {$domain->domain}: DNS does not point to this server");
$this->skipped++;
return;
}
$this->log("Issuing SSL for: {$domain->domain} (user: {$domain->user->username})");
$this->line(" Issuing SSL for: {$domain->domain}");
try {
$result = $this->agent->sslIssue(
$domain->domain,
$domain->user->username,
$domain->user->email,
true
);
if ($result['success'] ?? false) {
$expiresAt = isset($result['valid_to']) ? Carbon::parse($result['valid_to']) : now()->addMonths(3);
SslCertificate::updateOrCreate(
['domain_id' => $domain->id],
[
'type' => 'lets_encrypt',
'status' => 'active',
'issuer' => "Let's Encrypt",
'certificate' => $result['certificate'] ?? null,
'issued_at' => now(),
'expires_at' => $expiresAt,
'last_check_at' => now(),
'last_error' => null,
'renewal_attempts' => 0,
'auto_renew' => true,
]
);
$domain->update(['ssl_enabled' => true]);
$this->log("SUCCESS: Certificate issued for {$domain->domain}, expires: {$expiresAt->format('Y-m-d')}", 'SUCCESS');
$this->info(" ✓ Certificate issued successfully");
$this->issued++;
} else {
$error = $result['error'] ?? 'Unknown error';
$ssl = SslCertificate::firstOrNew(['domain_id' => $domain->id]);
$ssl->type = 'lets_encrypt';
$ssl->status = 'failed';
$ssl->last_check_at = now();
$ssl->last_error = $error;
$ssl->increment('renewal_attempts');
$ssl->save();
$this->log("FAILED: Certificate issue for {$domain->domain}: {$error}", 'ERROR');
$this->error(" ✗ Failed: {$error}");
$this->failed++;
// Send admin notification
AdminNotificationService::sslError($domain->domain, $error);
}
} catch (Exception $e) {
$this->log("EXCEPTION: Certificate issue for {$domain->domain}: {$e->getMessage()}", 'ERROR');
$this->error(" ✗ Exception: {$e->getMessage()}");
$this->failed++;
// Send admin notification
AdminNotificationService::sslError($domain->domain, $e->getMessage());
}
}
private function renewCertificate(Domain $domain): void
{
if (!$domain->user) {
$this->log("Skipping renewal for {$domain->domain}: No user associated", 'WARN');
$this->warn(" Skipping {$domain->domain}: No user associated");
$this->skipped++;
return;
}
// Check if domain DNS still points to this server
if (!$this->domainPointsToServer($domain->domain)) {
$this->log("Skipping renewal for {$domain->domain}: DNS does not point to this server", 'WARN');
$this->warn(" Skipping {$domain->domain}: DNS does not point to this server");
$this->skipped++;
return;
}
$this->log("Renewing SSL for: {$domain->domain} (user: {$domain->user->username})");
$this->line(" Renewing SSL for: {$domain->domain}");
try {
$result = $this->agent->sslRenew($domain->domain, $domain->user->username);
if ($result['success'] ?? false) {
$ssl = $domain->sslCertificate;
$expiresAt = isset($result['valid_to']) ? Carbon::parse($result['valid_to']) : now()->addMonths(3);
if ($ssl) {
$ssl->update([
'status' => 'active',
'issued_at' => now(),
'expires_at' => $expiresAt,
'last_check_at' => now(),
'last_error' => null,
'renewal_attempts' => 0,
]);
}
$this->log("SUCCESS: Certificate renewed for {$domain->domain}, expires: {$expiresAt->format('Y-m-d')}", 'SUCCESS');
$this->info(" ✓ Certificate renewed successfully");
$this->renewed++;
} else {
$error = $result['error'] ?? 'Unknown error';
$ssl = $domain->sslCertificate;
if ($ssl) {
$ssl->incrementRenewalAttempts();
$ssl->update(['last_error' => $error]);
}
$this->log("FAILED: Certificate renewal for {$domain->domain}: {$error}", 'ERROR');
$this->error(" ✗ Failed: {$error}");
$this->failed++;
// Send admin notification
AdminNotificationService::sslError($domain->domain, "Renewal failed: {$error}");
}
} catch (Exception $e) {
$this->log("EXCEPTION: Certificate renewal for {$domain->domain}: {$e->getMessage()}", 'ERROR');
$this->error(" ✗ Exception: {$e->getMessage()}");
$this->failed++;
// Send admin notification
AdminNotificationService::sslError($domain->domain, "Renewal exception: {$e->getMessage()}");
}
}
private function domainPointsToServer(string $domain): bool
{
// Get server's public IP
$serverIp = $this->getServerPublicIp();
if (!$serverIp) {
// If we can't determine server IP, assume it's okay to try
return true;
}
// Get domain's DNS resolution
$domainIp = gethostbyname($domain);
// gethostbyname returns the original string if resolution fails
if ($domainIp === $domain) {
return false;
}
return $domainIp === $serverIp;
}
private function getServerPublicIp(): ?string
{
static $cachedIp = null;
if ($cachedIp !== null) {
return $cachedIp ?: null;
}
// Try multiple services to get public IP
$services = [
'https://api.ipify.org',
'https://ipv4.icanhazip.com',
'https://checkip.amazonaws.com',
];
foreach ($services as $service) {
$ip = @file_get_contents($service, false, stream_context_create([
'http' => ['timeout' => 5],
]));
if ($ip) {
$ip = trim($ip);
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$cachedIp = $ip;
return $ip;
}
}
}
$cachedIp = '';
return null;
}
}