Files
jabali-panel/app/Filament/Admin/Pages/SslManager.php
2026-01-24 19:36:46 +02:00

593 lines
22 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Filament\Admin\Pages;
use App\Filament\Admin\Widgets\SslStatsOverview;
use App\Filament\Concerns\HasPageTour;
use App\Models\Domain;
use App\Models\SslCertificate;
use App\Models\User;
use App\Services\Agent\AgentClient;
use BackedEnum;
use Exception;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Artisan;
class SslManager extends Page implements HasTable
{
use HasPageTour;
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
protected static ?int $navigationSort = 8;
public static function getNavigationLabel(): string
{
return __('SSL Manager');
}
public function getTitle(): string
{
return __('SSL Manager');
}
protected string $view = 'filament.admin.pages.ssl-manager';
public bool $isRunning = false;
public string $autoSslLog = '';
public ?string $lastUpdated = null;
protected ?AgentClient $agent = null;
protected function getHeaderWidgets(): array
{
return [
SslStatsOverview::class,
];
}
public function getHeaderWidgetsColumns(): int|array
{
return 6;
}
public function getAgent(): AgentClient
{
if ($this->agent === null) {
$this->agent = new AgentClient;
}
return $this->agent;
}
public function mount(): void
{
$this->lastUpdated = now()->format('H:i:s');
}
public function table(Table $table): Table
{
return $table
->query(Domain::with(['user', 'sslCertificate']))
->columns([
TextColumn::make('domain')
->label(__('Domain'))
->searchable()
->sortable()
->description(fn (Domain $record) => $record->user?->username ?? __('Unknown')),
TextColumn::make('sslCertificate.type')
->label(__('Type'))
->badge()
->color('gray')
->formatStateUsing(fn ($state) => $state ? ucfirst(str_replace('_', ' ', $state)) : __('No SSL')),
TextColumn::make('sslCertificate.status')
->label(__('Status'))
->badge()
->color(fn ($state) => match ($state) {
'active' => 'success',
'expired' => 'danger',
'expiring' => 'warning',
'failed' => 'danger',
default => 'gray',
})
->formatStateUsing(fn ($state) => $state ? ucfirst($state) : __('No Certificate')),
TextColumn::make('sslCertificate.expires_at')
->label(__('Expires'))
->date('M d, Y')
->description(fn (Domain $record) => $record->sslCertificate?->days_until_expiry !== null
? __(':days days', ['days' => $record->sslCertificate->days_until_expiry])
: null)
->color(fn (Domain $record) => match (true) {
$record->sslCertificate?->days_until_expiry <= 7 => 'danger',
$record->sslCertificate?->days_until_expiry <= 30 => 'warning',
default => 'gray',
}),
TextColumn::make('sslCertificate.last_check_at')
->label(__('Last Check'))
->since()
->sortable(),
TextColumn::make('sslCertificate.last_error')
->label(__('Error'))
->limit(30)
->tooltip(fn ($state) => $state)
->color('danger')
->placeholder('-'),
])
->filters([
SelectFilter::make('ssl_status')
->label(__('Status'))
->options([
'active' => __('Active'),
'no_ssl' => __('No SSL'),
'expiring' => __('Expiring Soon'),
'expired' => __('Expired'),
'failed' => __('Failed'),
])
->query(function (Builder $query, array $data) {
if (! $data['value']) {
return $query;
}
return match ($data['value']) {
'active' => $query->whereHas('sslCertificate', fn ($q) => $q->where('status', 'active')),
'no_ssl' => $query->whereDoesntHave('sslCertificate'),
'expiring' => $query->whereHas('sslCertificate', fn ($q) => $q->where('status', 'active')
->where('expires_at', '<=', now()->addDays(30))
->where('expires_at', '>', now())),
'expired' => $query->whereHas('sslCertificate', fn ($q) => $q->where('status', 'expired')
->orWhere('expires_at', '<', now())),
'failed' => $query->whereHas('sslCertificate', fn ($q) => $q->where('status', 'failed')),
default => $query,
};
}),
SelectFilter::make('user_id')
->label(__('User'))
->relationship('user', 'username'),
])
->recordActions([
Action::make('issue')
->label(__('Issue'))
->icon('heroicon-o-lock-closed')
->color('success')
->visible(fn (Domain $record) => ! $record->sslCertificate || $record->sslCertificate->status === 'failed')
->action(fn (Domain $record) => $this->issueSslForDomain($record->id)),
Action::make('renew')
->label(__('Renew'))
->icon('heroicon-o-arrow-path')
->color('primary')
->visible(fn (Domain $record) => $record->sslCertificate?->type === 'lets_encrypt' && $record->sslCertificate?->status === 'active')
->action(fn (Domain $record) => $this->renewSslForDomain($record->id)),
Action::make('check')
->label(__('Check'))
->icon('heroicon-o-magnifying-glass')
->color('gray')
->action(fn (Domain $record) => $this->checkSslForDomain($record->id)),
])
->heading(__('Domain Certificates'))
->poll('30s');
}
public function issueSslForDomain(int $domainId): void
{
try {
$domain = Domain::with('user')->findOrFail($domainId);
$result = $this->getAgent()->sslIssue(
$domain->domain,
$domain->user->username,
$domain->user->email,
true
);
if ($result['success'] ?? false) {
SslCertificate::updateOrCreate(
['domain_id' => $domain->id],
[
'type' => 'lets_encrypt',
'status' => 'active',
'issuer' => "Let's Encrypt",
'certificate' => $result['certificate'] ?? null,
'issued_at' => now(),
'expires_at' => isset($result['valid_to']) ? \Carbon\Carbon::parse($result['valid_to']) : now()->addMonths(3),
'last_check_at' => now(),
'last_error' => null,
'renewal_attempts' => 0,
'auto_renew' => true,
]
);
$domain->update(['ssl_enabled' => true]);
Notification::make()
->title(__('SSL Certificate Issued'))
->body(__('Certificate issued for :domain', ['domain' => $domain->domain]))
->success()
->send();
} else {
SslCertificate::updateOrCreate(
['domain_id' => $domain->id],
[
'type' => 'lets_encrypt',
'status' => 'failed',
'last_check_at' => now(),
'last_error' => $result['error'] ?? __('Unknown error'),
]
);
Notification::make()
->title(__('SSL Certificate Failed'))
->body($result['error'] ?? __('Unknown error'))
->danger()
->send();
}
} catch (Exception $e) {
Notification::make()
->title(__('Error'))
->body($e->getMessage())
->danger()
->send();
}
$this->lastUpdated = now()->format('H:i:s');
}
public function renewSslForDomain(int $domainId): void
{
try {
$domain = Domain::with('user')->findOrFail($domainId);
$result = $this->getAgent()->sslRenew($domain->domain, $domain->user->username);
if ($result['success'] ?? false) {
$ssl = $domain->sslCertificate;
if ($ssl) {
$ssl->update([
'status' => 'active',
'issued_at' => now(),
'expires_at' => isset($result['valid_to']) ? \Carbon\Carbon::parse($result['valid_to']) : now()->addMonths(3),
'last_check_at' => now(),
'last_error' => null,
'renewal_attempts' => 0,
]);
}
Notification::make()
->title(__('Certificate Renewed'))
->body(__('SSL certificate renewed for :domain', ['domain' => $domain->domain]))
->success()
->send();
} else {
Notification::make()
->title(__('Renewal Failed'))
->body($result['error'] ?? __('Unknown error'))
->danger()
->send();
}
} catch (Exception $e) {
Notification::make()
->title(__('Error'))
->body($e->getMessage())
->danger()
->send();
}
$this->lastUpdated = now()->format('H:i:s');
}
public function checkSslForDomain(int $domainId): void
{
try {
$domain = Domain::with('user')->findOrFail($domainId);
$result = $this->getAgent()->sslCheck($domain->domain, $domain->user->username);
if ($result['success'] ?? false) {
$sslData = $result['ssl'] ?? [];
if ($sslData['has_ssl'] ?? false) {
SslCertificate::updateOrCreate(
['domain_id' => $domain->id],
[
'type' => $sslData['type'] ?? 'custom',
'status' => ($sslData['is_expired'] ?? false) ? 'expired' : 'active',
'issuer' => $sslData['issuer'],
'certificate' => $sslData['certificate'] ?? null,
'issued_at' => isset($sslData['valid_from']) ? \Carbon\Carbon::parse($sslData['valid_from']) : null,
'expires_at' => isset($sslData['valid_to']) ? \Carbon\Carbon::parse($sslData['valid_to']) : null,
'last_check_at' => now(),
]
);
$domain->update(['ssl_enabled' => true]);
}
Notification::make()
->title(__('Certificate Checked'))
->body($sslData['has_ssl'] ? __('Found: :issuer', ['issuer' => $sslData['issuer']]) : __('No certificate found'))
->success()
->send();
} else {
Notification::make()
->title(__('Check Failed'))
->body($result['error'] ?? __('Unknown error'))
->danger()
->send();
}
} catch (Exception $e) {
Notification::make()
->title(__('Error'))
->body($e->getMessage())
->danger()
->send();
}
$this->lastUpdated = now()->format('H:i:s');
}
public function runAutoSsl(?string $domain = null): void
{
$this->isRunning = true;
$this->autoSslLog = '';
try {
// Ensure log directory exists with proper permissions
$logDir = storage_path('logs/ssl');
if (! is_dir($logDir)) {
@mkdir($logDir, 0775, true);
}
$params = [];
if ($domain) {
$params['--domain'] = $domain;
}
Artisan::call('jabali:ssl-check', $params);
$this->autoSslLog = Artisan::output();
Notification::make()
->title(__('SSL Check Complete'))
->body($domain
? __('SSL check completed for :domain', ['domain' => $domain])
: __('SSL certificate check completed for all domains'))
->success()
->send();
} catch (Exception $e) {
$this->autoSslLog = __('Error: :message', ['message' => $e->getMessage()]);
Notification::make()
->title(__('SSL Check Failed'))
->body($e->getMessage())
->danger()
->send();
}
$this->isRunning = false;
$this->lastUpdated = now()->format('H:i:s');
}
public function runSslCheckForUser(int $userId): void
{
$this->isRunning = true;
$this->autoSslLog = '';
try {
$user = User::findOrFail($userId);
$domains = Domain::where('user_id', $userId)->pluck('domain')->toArray();
if (empty($domains)) {
$this->autoSslLog = __('No domains found for user :user', ['user' => $user->username]);
Notification::make()
->title(__('No Domains'))
->body(__('User :user has no domains', ['user' => $user->username]))
->warning()
->send();
$this->isRunning = false;
return;
}
$this->autoSslLog = __('Checking SSL for :count domains of user :user', ['count' => count($domains), 'user' => $user->username])."\n\n";
foreach ($domains as $domain) {
Artisan::call('jabali:ssl-check', ['--domain' => $domain]);
$this->autoSslLog .= Artisan::output()."\n";
}
Notification::make()
->title(__('SSL Check Complete'))
->body(__('SSL check completed for :count domains of user :user', ['count' => count($domains), 'user' => $user->username]))
->success()
->send();
} catch (Exception $e) {
$this->autoSslLog = __('Error: :message', ['message' => $e->getMessage()]);
Notification::make()
->title(__('SSL Check Failed'))
->body($e->getMessage())
->danger()
->send();
}
$this->isRunning = false;
$this->lastUpdated = now()->format('H:i:s');
}
public function issueAllPending(): void
{
$domainsWithoutSsl = Domain::whereDoesntHave('sslCertificate')
->orWhereHas('sslCertificate', function ($q) {
$q->where('status', 'failed');
})
->with('user')
->get();
$issued = 0;
$failed = 0;
foreach ($domainsWithoutSsl as $domain) {
try {
$result = $this->getAgent()->sslIssue(
$domain->domain,
$domain->user->username,
$domain->user->email,
true
);
if ($result['success'] ?? false) {
SslCertificate::updateOrCreate(
['domain_id' => $domain->id],
[
'type' => 'lets_encrypt',
'status' => 'active',
'issuer' => "Let's Encrypt",
'certificate' => $result['certificate'] ?? null,
'issued_at' => now(),
'expires_at' => isset($result['valid_to']) ? \Carbon\Carbon::parse($result['valid_to']) : now()->addMonths(3),
'last_check_at' => now(),
'last_error' => null,
'renewal_attempts' => 0,
'auto_renew' => true,
]
);
$domain->update(['ssl_enabled' => true]);
$issued++;
} else {
SslCertificate::updateOrCreate(
['domain_id' => $domain->id],
[
'type' => 'lets_encrypt',
'status' => 'failed',
'last_check_at' => now(),
'last_error' => $result['error'] ?? __('Unknown error'),
]
);
$failed++;
}
} catch (Exception $e) {
$failed++;
}
}
Notification::make()
->title(__('Bulk SSL Issuance Complete'))
->body(__('Issued: :issued, Failed: :failed', ['issued' => $issued, 'failed' => $failed]))
->success()
->send();
$this->lastUpdated = now()->format('H:i:s');
}
public function getLetsEncryptLog(): string
{
$logFiles = [
'/var/log/letsencrypt/letsencrypt.log',
'/var/log/certbot/letsencrypt.log',
'/var/log/certbot.log',
];
$logContent = '';
$foundFile = null;
foreach ($logFiles as $logFile) {
if (file_exists($logFile)) {
$foundFile = $logFile;
$lines = file($logFile);
$lastLines = array_slice($lines, -500);
$logContent .= "=== {$logFile} ===\n".implode('', $lastLines);
break;
}
}
if (! $foundFile) {
$certbotLogs = glob('/var/log/letsencrypt/*.log');
if (! empty($certbotLogs)) {
$foundFile = end($certbotLogs);
$lines = file($foundFile);
$lastLines = array_slice($lines, -500);
$logContent = "=== {$foundFile} ===\n".implode('', $lastLines);
}
}
if (! $foundFile) {
return __("No Let's Encrypt/Certbot log files found.")."\n\n".__('Searched locations:')."\n".implode("\n", $logFiles)."\n/var/log/letsencrypt/*.log";
}
return $logContent;
}
protected function getHeaderActions(): array
{
return [
$this->getTourAction(),
Action::make('runAutoSsl')
->label(__('Run SSL Check'))
->icon('heroicon-o-play')
->color('success')
->modalHeading(__('Run SSL Check'))
->modalDescription(__('Check SSL certificates and automatically issue/renew them.'))
->modalWidth('md')
->form([
Select::make('scope')
->label(__('Scope'))
->options([
'all' => __('All Domains'),
'user' => __('Specific User'),
'domain' => __('Specific Domain'),
])
->default('all')
->live()
->required(),
Select::make('user_id')
->label(__('User'))
->options(fn () => User::pluck('username', 'id')->toArray())
->searchable()
->visible(fn ($get) => $get('scope') === 'user')
->required(fn ($get) => $get('scope') === 'user'),
Select::make('domain')
->label(__('Domain'))
->options(fn () => Domain::pluck('domain', 'domain')->toArray())
->searchable()
->visible(fn ($get) => $get('scope') === 'domain')
->required(fn ($get) => $get('scope') === 'domain'),
])
->action(function (array $data): void {
match ($data['scope']) {
'user' => $this->runSslCheckForUser((int) $data['user_id']),
'domain' => $this->runAutoSsl($data['domain']),
default => $this->runAutoSsl(),
};
}),
Action::make('issueAllPending')
->label(__('Issue All Pending'))
->icon('heroicon-o-shield-check')
->color('primary')
->requiresConfirmation()
->modalHeading(__('Issue SSL for All Pending Domains'))
->modalDescription(__('This will attempt to issue SSL certificates for all domains without active certificates. This may take a while.'))
->action(fn () => $this->issueAllPending()),
Action::make('viewLog')
->label(__('View Log'))
->icon('heroicon-o-document-text')
->color('gray')
->modalHeading(__("Let's Encrypt Log"))
->modalWidth('4xl')
->modalContent(fn () => view('filament.admin.pages.ssl-log-modal', ['log' => $this->getLetsEncryptLog()]))
->modalSubmitAction(false)
->modalCancelActionLabel(__('Close')),
];
}
}