Sync pending admin UI and security panel updates
This commit is contained in:
@@ -27,7 +27,7 @@ class Dashboard extends Page implements HasActions, HasForms
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-home';
|
||||
|
||||
protected static ?int $navigationSort = 1;
|
||||
protected static ?int $navigationSort = 0;
|
||||
|
||||
protected static ?string $slug = 'dashboard';
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ class DatabaseTuning extends Page implements HasActions, HasTable
|
||||
|
||||
protected static ?string $slug = 'database-tuning';
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected string $view = 'filament.admin.pages.database-tuning';
|
||||
|
||||
public array $variables = [];
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Filament\Admin\Widgets\Security\QuarantinedFilesTable;
|
||||
use App\Filament\Admin\Widgets\Security\ThreatsTable;
|
||||
use App\Filament\Admin\Widgets\Security\WpscanResultsTable;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Setting;
|
||||
use App\Services\Agent\AgentClient;
|
||||
use BackedEnum;
|
||||
use Exception;
|
||||
@@ -76,6 +77,11 @@ class Security extends Page implements HasActions, HasForms, HasTable
|
||||
|
||||
public ?int $ruleToDelete = null;
|
||||
|
||||
// WAF
|
||||
public bool $wafInstalled = false;
|
||||
|
||||
public bool $wafEnabled = false;
|
||||
|
||||
// Fail2ban
|
||||
public bool $fail2banInstalled = false;
|
||||
|
||||
@@ -190,7 +196,7 @@ class Security extends Page implements HasActions, HasForms, HasTable
|
||||
protected function normalizeTabName(?string $tab): string
|
||||
{
|
||||
return match ($tab) {
|
||||
'overview', 'firewall', 'fail2ban', 'antivirus', 'ssh', 'scanner' => $tab,
|
||||
'overview', 'firewall', 'waf', 'fail2ban', 'antivirus', 'ssh', 'scanner' => $tab,
|
||||
default => 'overview',
|
||||
};
|
||||
}
|
||||
@@ -218,6 +224,7 @@ class Security extends Page implements HasActions, HasForms, HasTable
|
||||
{
|
||||
$this->activeTab = $this->normalizeTabName($this->activeTab);
|
||||
$this->loadFirewallStatus();
|
||||
$this->loadWafStatus();
|
||||
$this->loadFail2banStatusLight();
|
||||
$this->loadClamavStatusLight();
|
||||
$this->loadSshSettings();
|
||||
@@ -241,6 +248,7 @@ class Security extends Page implements HasActions, HasForms, HasTable
|
||||
#[On('refresh-security-data')]
|
||||
public function refreshSecurityData(): void
|
||||
{
|
||||
$this->loadWafStatus();
|
||||
$this->loadFail2banStatus();
|
||||
$this->loadClamavStatus();
|
||||
}
|
||||
@@ -313,6 +321,7 @@ class Security extends Page implements HasActions, HasForms, HasTable
|
||||
return match ($this->activeTab) {
|
||||
'overview' => $this->overviewTabContent(),
|
||||
'firewall' => $this->firewallTabContent(),
|
||||
'waf' => $this->wafTabContent(),
|
||||
'fail2ban' => $this->fail2banTabContent(),
|
||||
'antivirus' => $this->antivirusTabContent(),
|
||||
'ssh' => $this->sshTabContent(),
|
||||
@@ -321,6 +330,13 @@ class Security extends Page implements HasActions, HasForms, HasTable
|
||||
};
|
||||
}
|
||||
|
||||
protected function wafTabContent(): array
|
||||
{
|
||||
return [
|
||||
View::make('filament.admin.components.security-waf'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function overviewTabContent(): array
|
||||
{
|
||||
return [
|
||||
@@ -330,6 +346,18 @@ class Security extends Page implements HasActions, HasForms, HasTable
|
||||
->description(__('Firewall'))
|
||||
->icon('heroicon-o-shield-check')
|
||||
->iconColor($this->firewallEnabled ? 'success' : 'danger'),
|
||||
Section::make($this->wafInstalled
|
||||
? ($this->wafEnabled ? __('Enabled') : __('Disabled'))
|
||||
: __('Not Installed'))
|
||||
->description(__('WAF'))
|
||||
->icon('heroicon-o-shield-exclamation')
|
||||
->iconColor($this->wafInstalled
|
||||
? ($this->wafEnabled ? 'success' : 'danger')
|
||||
: 'gray'),
|
||||
Section::make($this->fail2banRunning ? __('Running') : __('Stopped'))
|
||||
->description(__('Fail2ban'))
|
||||
->icon('heroicon-o-lock-closed')
|
||||
->iconColor($this->fail2banRunning ? 'success' : 'danger'),
|
||||
Section::make($this->totalBanned !== null ? (string) $this->totalBanned : __('N/A'))
|
||||
->description(__('IPs Banned'))
|
||||
->icon('heroicon-o-lock-closed')
|
||||
@@ -338,6 +366,10 @@ class Security extends Page implements HasActions, HasForms, HasTable
|
||||
->description(__('Threats Detected'))
|
||||
->icon('heroicon-o-bug-ant')
|
||||
->iconColor($this->clamavInstalled ? 'success' : 'gray'),
|
||||
Section::make($this->clamavRunning ? __('Running') : __('Stopped'))
|
||||
->description(__('Antivirus'))
|
||||
->icon('heroicon-o-shield-check')
|
||||
->iconColor($this->clamavRunning ? 'success' : 'danger'),
|
||||
]),
|
||||
Section::make(__('Quick Actions'))
|
||||
->icon('heroicon-o-bolt')
|
||||
@@ -1008,6 +1040,30 @@ class Security extends Page implements HasActions, HasForms, HasTable
|
||||
}
|
||||
}
|
||||
|
||||
protected function loadWafStatus(): void
|
||||
{
|
||||
$this->wafInstalled = $this->detectWaf();
|
||||
$this->wafEnabled = $this->wafInstalled && Setting::get('waf_enabled', '0') === '1';
|
||||
}
|
||||
|
||||
protected function detectWaf(): bool
|
||||
{
|
||||
$paths = [
|
||||
'/etc/nginx/modsec/main.conf',
|
||||
'/etc/nginx/modsecurity.conf',
|
||||
'/etc/modsecurity/modsecurity.conf',
|
||||
'/etc/modsecurity/modsecurity.conf-recommended',
|
||||
];
|
||||
|
||||
foreach ($paths as $path) {
|
||||
if (file_exists($path)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function loadFail2banStatusLight(): void
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Admin\Pages;
|
||||
|
||||
use App\Filament\Admin\Widgets\Settings\DatabaseTuningTable;
|
||||
use App\Filament\Admin\Widgets\Settings\DnssecTable;
|
||||
use App\Filament\Admin\Widgets\Settings\NotificationLogTable;
|
||||
use App\Models\DnsSetting;
|
||||
@@ -78,18 +79,6 @@ class ServerSettings extends Page implements HasActions, HasForms
|
||||
public ?array $phpFpmData = [];
|
||||
|
||||
// Version info (non-form)
|
||||
public string $currentVersion = '';
|
||||
|
||||
public string $latestVersion = '';
|
||||
|
||||
public int $updatesAvailable = 0;
|
||||
|
||||
public bool $isChecking = false;
|
||||
|
||||
public bool $isUpgrading = false;
|
||||
|
||||
public string $upgradeLog = '';
|
||||
|
||||
public bool $isSystemdResolved = false;
|
||||
|
||||
public ?string $currentLogo = null;
|
||||
@@ -119,7 +108,7 @@ class ServerSettings extends Page implements HasActions, HasForms
|
||||
protected function normalizeTabName(?string $tab): string
|
||||
{
|
||||
return match ($tab) {
|
||||
'general', 'dns', 'storage', 'email', 'notifications', 'php-fpm' => $tab,
|
||||
'general', 'dns', 'storage', 'email', 'notifications', 'php-fpm', 'database' => $tab,
|
||||
default => 'general',
|
||||
};
|
||||
}
|
||||
@@ -239,7 +228,6 @@ class ServerSettings extends Page implements HasActions, HasForms
|
||||
'memory_limit' => $settings['fpm_memory_limit'] ?? '512M',
|
||||
];
|
||||
|
||||
$this->loadVersionInfo();
|
||||
}
|
||||
|
||||
public function settingsForm(Schema $schema): Schema
|
||||
@@ -260,6 +248,7 @@ class ServerSettings extends Page implements HasActions, HasForms
|
||||
'email' => $this->emailTabContent(),
|
||||
'notifications' => $this->notificationsTabContent(),
|
||||
'php-fpm' => $this->phpFpmTabContent(),
|
||||
'database' => $this->databaseTabContent(),
|
||||
default => $this->generalTabContent(),
|
||||
};
|
||||
}
|
||||
@@ -267,25 +256,6 @@ class ServerSettings extends Page implements HasActions, HasForms
|
||||
protected function generalTabContent(): array
|
||||
{
|
||||
return [
|
||||
Section::make(__('Panel Version & Updates'))
|
||||
->description($this->currentVersion ?: __('Unknown'))
|
||||
->icon('heroicon-o-arrow-up-tray')
|
||||
->schema([
|
||||
Actions::make([
|
||||
FormAction::make('checkForUpdates')
|
||||
->label(__('Check for Updates'))
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('gray')
|
||||
->action('checkForUpdates'),
|
||||
FormAction::make('performUpgrade')
|
||||
->label(__('Upgrade Now'))
|
||||
->icon('heroicon-o-arrow-up-tray')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->action('performUpgrade')
|
||||
->visible(fn () => $this->updatesAvailable > 0),
|
||||
]),
|
||||
]),
|
||||
Section::make(__('Panel Branding'))
|
||||
->icon('heroicon-o-paint-brush')
|
||||
->schema([
|
||||
@@ -678,6 +648,18 @@ class ServerSettings extends Page implements HasActions, HasForms
|
||||
];
|
||||
}
|
||||
|
||||
protected function databaseTabContent(): array
|
||||
{
|
||||
return [
|
||||
Section::make(__('Database Tuning'))
|
||||
->description(__('Adjust MariaDB/MySQL global variables.'))
|
||||
->icon('heroicon-o-circle-stack')
|
||||
->schema([
|
||||
EmbeddedTable::make(DatabaseTuningTable::class),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getForms(): array
|
||||
{
|
||||
return [
|
||||
@@ -1133,78 +1115,6 @@ class ServerSettings extends Page implements HasActions, HasForms
|
||||
}
|
||||
}
|
||||
|
||||
protected function loadVersionInfo(): void
|
||||
{
|
||||
$versionFile = base_path('VERSION');
|
||||
if (File::exists($versionFile)) {
|
||||
$content = File::get($versionFile);
|
||||
if (preg_match('/VERSION=(.+)/', $content, $matches)) {
|
||||
$this->currentVersion = trim($matches[1]);
|
||||
if (preg_match('/BUILD=(\d+)/', $content, $buildMatches)) {
|
||||
$this->currentVersion .= ' ('.__('build').' '.trim($buildMatches[1]).')';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$this->currentVersion = __('Unknown');
|
||||
}
|
||||
}
|
||||
|
||||
public function checkForUpdates(): void
|
||||
{
|
||||
$this->isChecking = true;
|
||||
$this->updatesAvailable = 0;
|
||||
|
||||
try {
|
||||
$basePath = base_path();
|
||||
|
||||
if (! is_dir("{$basePath}/.git")) {
|
||||
throw new Exception(__('Not a git repository.'));
|
||||
}
|
||||
|
||||
exec("cd {$basePath} && timeout 30 git fetch origin main 2>&1", $fetchOutput, $fetchCode);
|
||||
|
||||
if ($fetchCode !== 0) {
|
||||
throw new Exception(__('Failed to fetch from repository.'));
|
||||
}
|
||||
|
||||
$behindCount = trim(shell_exec("cd {$basePath} && git rev-list HEAD..origin/main --count 2>&1") ?? '0');
|
||||
$this->updatesAvailable = (int) $behindCount;
|
||||
|
||||
if ($this->updatesAvailable > 0) {
|
||||
$this->latestVersion = trim(shell_exec("cd {$basePath} && git log origin/main -1 --format='%s' 2>&1") ?? '');
|
||||
Notification::make()->title(__('Updates Available'))->body(__(':count update(s) available', ['count' => $this->updatesAvailable]))->warning()->send();
|
||||
} else {
|
||||
Notification::make()->title(__('Up to Date'))->body(__('Running the latest version'))->success()->send();
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
Notification::make()->title(__('Update Check Failed'))->body($e->getMessage())->danger()->send();
|
||||
}
|
||||
|
||||
$this->isChecking = false;
|
||||
}
|
||||
|
||||
public function performUpgrade(): void
|
||||
{
|
||||
$this->isUpgrading = true;
|
||||
$this->upgradeLog = __('Starting upgrade...')."\n";
|
||||
|
||||
try {
|
||||
$exitCode = Artisan::call('jabali:upgrade', ['--force' => true]);
|
||||
$this->upgradeLog .= Artisan::output();
|
||||
if ($exitCode !== 0) {
|
||||
throw new Exception(__('Upgrade failed. Check the log for details.'));
|
||||
}
|
||||
$this->loadVersionInfo();
|
||||
$this->updatesAvailable = 0;
|
||||
Notification::make()->title(__('Upgrade Complete'))->body(__('Refresh to see changes.'))->success()->send();
|
||||
} catch (Exception $e) {
|
||||
$this->upgradeLog .= "\n".__('Error').': '.$e->getMessage();
|
||||
Notification::make()->title(__('Upgrade Failed'))->body($e->getMessage())->danger()->send();
|
||||
}
|
||||
|
||||
$this->isUpgrading = false;
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
|
||||
@@ -180,8 +180,7 @@ class Services extends Page implements HasActions, HasForms, HasTable
|
||||
})
|
||||
->iconColor(fn (array $record): string => $record['is_active'] ? 'success' : 'danger')
|
||||
->description(fn (array $record): string => $record['description'] ?? '')
|
||||
->weight('medium')
|
||||
->searchable(),
|
||||
->weight('medium'),
|
||||
TextColumn::make('is_active')
|
||||
->label(__('Status'))
|
||||
->badge()
|
||||
|
||||
@@ -37,6 +37,8 @@ class Waf extends Page implements HasForms, HasTable
|
||||
|
||||
protected string $view = 'filament.admin.pages.waf';
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
public bool $wafInstalled = false;
|
||||
|
||||
public array $wafFormData = [];
|
||||
|
||||
@@ -22,7 +22,7 @@ class HostingPackageResource extends Resource
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedCube;
|
||||
|
||||
protected static ?int $navigationSort = 13;
|
||||
protected static ?int $navigationSort = 2;
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
|
||||
@@ -20,7 +20,7 @@ class UserResource extends Resource
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
|
||||
|
||||
protected static ?int $navigationSort = 2;
|
||||
protected static ?int $navigationSort = 1;
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
|
||||
151
app/Filament/Admin/Widgets/Settings/DatabaseTuningTable.php
Normal file
151
app/Filament/Admin/Widgets/Settings/DatabaseTuningTable.php
Normal file
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Admin\Widgets\Settings;
|
||||
|
||||
use App\Services\Agent\AgentClient;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\Concerns\InteractsWithActions;
|
||||
use Filament\Actions\Contracts\HasActions;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Schemas\Concerns\InteractsWithSchemas;
|
||||
use Filament\Schemas\Contracts\HasSchemas;
|
||||
use Filament\Support\Contracts\TranslatableContentDriver;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Table;
|
||||
use Livewire\Component;
|
||||
|
||||
class DatabaseTuningTable extends Component implements HasTable, HasSchemas, HasActions
|
||||
{
|
||||
use InteractsWithActions;
|
||||
use InteractsWithSchemas;
|
||||
use InteractsWithTable;
|
||||
|
||||
public array $variables = [];
|
||||
|
||||
protected ?AgentClient $agent = null;
|
||||
|
||||
public function makeFilamentTranslatableContentDriver(): ?TranslatableContentDriver
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->loadVariables();
|
||||
}
|
||||
|
||||
protected function getAgent(): AgentClient
|
||||
{
|
||||
return $this->agent ??= new AgentClient();
|
||||
}
|
||||
|
||||
public function loadVariables(): void
|
||||
{
|
||||
$names = [
|
||||
'innodb_buffer_pool_size',
|
||||
'max_connections',
|
||||
'tmp_table_size',
|
||||
'max_heap_table_size',
|
||||
'innodb_log_file_size',
|
||||
'innodb_flush_log_at_trx_commit',
|
||||
];
|
||||
|
||||
try {
|
||||
$result = $this->getAgent()->databaseGetVariables($names);
|
||||
if (! ($result['success'] ?? false)) {
|
||||
throw new \RuntimeException($result['error'] ?? __('Unable to load variables'));
|
||||
}
|
||||
|
||||
$this->variables = collect($result['variables'] ?? [])->map(function (array $row) {
|
||||
return [
|
||||
'name' => $row['name'] ?? '',
|
||||
'value' => $row['value'] ?? '',
|
||||
];
|
||||
})->toArray();
|
||||
} catch (\Exception $e) {
|
||||
$this->variables = [];
|
||||
Notification::make()
|
||||
->title(__('Unable to load database variables'))
|
||||
->body($e->getMessage())
|
||||
->warning()
|
||||
->send();
|
||||
}
|
||||
|
||||
$this->resetTable();
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->records(fn () => $this->variables)
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label(__('Variable'))
|
||||
->fontFamily('mono'),
|
||||
TextColumn::make('value')
|
||||
->label(__('Current Value'))
|
||||
->fontFamily('mono'),
|
||||
])
|
||||
->recordActions([
|
||||
Action::make('update')
|
||||
->label(__('Update'))
|
||||
->icon('heroicon-o-pencil-square')
|
||||
->form([
|
||||
TextInput::make('value')
|
||||
->label(__('New Value'))
|
||||
->required(),
|
||||
])
|
||||
->action(function (array $record, array $data): void {
|
||||
try {
|
||||
$agent = $this->getAgent();
|
||||
$setResult = $agent->databaseSetGlobal($record['name'], (string) $data['value']);
|
||||
if (! ($setResult['success'] ?? false)) {
|
||||
throw new \RuntimeException($setResult['error'] ?? __('Update failed'));
|
||||
}
|
||||
|
||||
$persistResult = $agent->databasePersistTuning($record['name'], (string) $data['value']);
|
||||
if (! ($persistResult['success'] ?? false)) {
|
||||
Notification::make()
|
||||
->title(__('Variable updated, but not persisted'))
|
||||
->body($persistResult['error'] ?? __('Unable to persist value'))
|
||||
->warning()
|
||||
->send();
|
||||
} else {
|
||||
Notification::make()
|
||||
->title(__('Variable updated'))
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Notification::make()
|
||||
->title(__('Update failed'))
|
||||
->body($e->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
|
||||
$this->loadVariables();
|
||||
}),
|
||||
])
|
||||
->headerActions([
|
||||
Action::make('refresh')
|
||||
->label(__('Refresh'))
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('gray')
|
||||
->action(fn () => $this->loadVariables()),
|
||||
])
|
||||
->striped()
|
||||
->emptyStateHeading(__('No variables found'))
|
||||
->emptyStateDescription(__('Database variables could not be loaded.'));
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return $this->getTable()->render();
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,16 @@ namespace App\Filament\Admin\Widgets;
|
||||
|
||||
use App\Models\Domain;
|
||||
use App\Models\SslCertificate;
|
||||
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
use Filament\Widgets\Widget;
|
||||
|
||||
class SslStatsOverview extends BaseWidget
|
||||
class SslStatsOverview extends Widget
|
||||
{
|
||||
protected ?string $pollingInterval = '30s';
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
protected string $view = 'filament.admin.widgets.dashboard-stats';
|
||||
|
||||
protected function getStats(): array
|
||||
{
|
||||
$totalDomains = Domain::count();
|
||||
@@ -30,35 +33,36 @@ class SslStatsOverview extends BaseWidget
|
||||
$withoutSsl = $totalDomains - $domainsWithSsl;
|
||||
|
||||
return [
|
||||
Stat::make(__('Total Domains'), (string) $totalDomains)
|
||||
->description(__('All registered domains'))
|
||||
->descriptionIcon('heroicon-m-globe-alt')
|
||||
->color('gray'),
|
||||
|
||||
Stat::make(__('With SSL'), (string) $domainsWithSsl)
|
||||
->description(__('Active certificates'))
|
||||
->descriptionIcon('heroicon-m-shield-check')
|
||||
->color('success'),
|
||||
|
||||
Stat::make(__('Without SSL'), (string) $withoutSsl)
|
||||
->description(__('No certificate'))
|
||||
->descriptionIcon('heroicon-m-shield-exclamation')
|
||||
->color('gray'),
|
||||
|
||||
Stat::make(__('Expiring Soon'), (string) $expiringSoon)
|
||||
->description(__('Within 30 days'))
|
||||
->descriptionIcon('heroicon-m-clock')
|
||||
->color($expiringSoon > 0 ? 'warning' : 'success'),
|
||||
|
||||
Stat::make(__('Expired'), (string) $expired)
|
||||
->description(__('Need renewal'))
|
||||
->descriptionIcon('heroicon-m-x-circle')
|
||||
->color($expired > 0 ? 'danger' : 'success'),
|
||||
|
||||
Stat::make(__('Failed'), (string) $failed)
|
||||
->description(__('Issuance failed'))
|
||||
->descriptionIcon('heroicon-m-exclamation-triangle')
|
||||
->color($failed > 0 ? 'danger' : 'success'),
|
||||
[
|
||||
'value' => $domainsWithSsl,
|
||||
'label' => __('With SSL'),
|
||||
'icon' => 'heroicon-m-shield-check',
|
||||
'color' => 'success',
|
||||
],
|
||||
[
|
||||
'value' => $withoutSsl,
|
||||
'label' => __('Without SSL'),
|
||||
'icon' => 'heroicon-m-shield-exclamation',
|
||||
'color' => 'gray',
|
||||
],
|
||||
[
|
||||
'value' => $expiringSoon,
|
||||
'label' => __('Expiring Soon'),
|
||||
'icon' => 'heroicon-m-clock',
|
||||
'color' => $expiringSoon > 0 ? 'warning' : 'success',
|
||||
],
|
||||
[
|
||||
'value' => $expired,
|
||||
'label' => __('Expired'),
|
||||
'icon' => 'heroicon-m-x-circle',
|
||||
'color' => $expired > 0 ? 'danger' : 'success',
|
||||
],
|
||||
[
|
||||
'value' => $failed,
|
||||
'label' => __('Failed'),
|
||||
'icon' => 'heroicon-m-exclamation-triangle',
|
||||
'color' => $failed > 0 ? 'danger' : 'success',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
693
app/Livewire/Admin/SecurityWafPanel.php
Normal file
693
app/Livewire/Admin/SecurityWafPanel.php
Normal file
@@ -0,0 +1,693 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Services\Agent\AgentClient;
|
||||
use Exception;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Actions\Concerns\InteractsWithActions;
|
||||
use Filament\Actions\Contracts\HasActions;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Contracts\TranslatableContentDriver;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
class SecurityWafPanel extends Component implements HasForms, HasTable, HasActions
|
||||
{
|
||||
use InteractsWithActions;
|
||||
use InteractsWithForms;
|
||||
use InteractsWithTable;
|
||||
|
||||
public bool $wafInstalled = false;
|
||||
|
||||
public array $wafFormData = [];
|
||||
|
||||
public array $auditEntries = [];
|
||||
|
||||
public bool $auditLoaded = false;
|
||||
|
||||
public function makeFilamentTranslatableContentDriver(): ?TranslatableContentDriver
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function getForms(): array
|
||||
{
|
||||
return ['wafForm'];
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->wafInstalled = $this->detectWaf();
|
||||
$this->wafFormData = [
|
||||
'enabled' => Setting::get('waf_enabled', '0') === '1',
|
||||
'paranoia' => Setting::get('waf_paranoia', '1'),
|
||||
'audit_log' => Setting::get('waf_audit_log', '1') === '1',
|
||||
];
|
||||
|
||||
$this->loadAuditLogs(false);
|
||||
}
|
||||
|
||||
protected function detectWaf(): bool
|
||||
{
|
||||
$paths = [
|
||||
'/etc/nginx/modsec/main.conf',
|
||||
'/etc/nginx/modsecurity.conf',
|
||||
'/etc/modsecurity/modsecurity.conf',
|
||||
'/etc/modsecurity/modsecurity.conf-recommended',
|
||||
];
|
||||
|
||||
foreach ($paths as $path) {
|
||||
if (file_exists($path)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function wafForm(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->statePath('wafFormData')
|
||||
->schema([
|
||||
Section::make(__('WAF Settings'))
|
||||
->headerActions([
|
||||
Action::make('saveWafSettings')
|
||||
->label(__('Save WAF Settings'))
|
||||
->icon('heroicon-o-check')
|
||||
->color('primary')
|
||||
->action('saveWafSettings'),
|
||||
])
|
||||
->extraAttributes(['class' => 'mb-8'])
|
||||
->schema([
|
||||
Toggle::make('enabled')
|
||||
->label(__('Enable ModSecurity'))
|
||||
->disabled(fn () => ! $this->wafInstalled)
|
||||
->helperText(fn () => $this->wafInstalled ? null : __('ModSecurity is not installed. Install it to enable WAF.')),
|
||||
Select::make('paranoia')
|
||||
->label(__('Paranoia Level'))
|
||||
->options([
|
||||
'1' => '1 - Basic',
|
||||
'2' => '2 - Moderate',
|
||||
'3' => '3 - Strict',
|
||||
'4' => '4 - Very Strict',
|
||||
])
|
||||
->default('1'),
|
||||
Toggle::make('audit_log')
|
||||
->label(__('Enable Audit Log')),
|
||||
])
|
||||
->columns(2),
|
||||
]);
|
||||
}
|
||||
|
||||
public function saveWafSettings(): void
|
||||
{
|
||||
$data = $this->wafForm->getState();
|
||||
$requestedEnabled = ! empty($data['enabled']);
|
||||
if ($requestedEnabled && ! $this->wafInstalled) {
|
||||
$requestedEnabled = false;
|
||||
}
|
||||
|
||||
Setting::set('waf_enabled', $requestedEnabled ? '1' : '0');
|
||||
Setting::set('waf_paranoia', (string) ($data['paranoia'] ?? '1'));
|
||||
Setting::set('waf_audit_log', ! empty($data['audit_log']) ? '1' : '0');
|
||||
$whitelistRules = $this->getWhitelistRules();
|
||||
|
||||
try {
|
||||
$agent = new AgentClient;
|
||||
$agent->wafApplySettings(
|
||||
$requestedEnabled,
|
||||
(string) ($data['paranoia'] ?? '1'),
|
||||
! empty($data['audit_log']),
|
||||
$whitelistRules
|
||||
);
|
||||
|
||||
if (! $this->wafInstalled && ! empty($data['enabled'])) {
|
||||
Notification::make()
|
||||
->title(__('ModSecurity is not installed'))
|
||||
->body(__('WAF was disabled automatically. Install ModSecurity to enable it.'))
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title(__('WAF settings applied'))
|
||||
->success()
|
||||
->send();
|
||||
} catch (Exception $e) {
|
||||
Notification::make()
|
||||
->title(__('WAF settings saved, but apply failed'))
|
||||
->body($e->getMessage())
|
||||
->warning()
|
||||
->send();
|
||||
}
|
||||
}
|
||||
|
||||
public function loadAuditLogs(bool $notify = true): void
|
||||
{
|
||||
try {
|
||||
$agent = new AgentClient;
|
||||
$response = $agent->wafAuditLogList();
|
||||
$entries = $response['entries'] ?? [];
|
||||
if (! is_array($entries)) {
|
||||
$entries = [];
|
||||
}
|
||||
|
||||
$this->auditEntries = $this->normalizeAuditEntries($this->markWhitelisted($entries));
|
||||
$this->auditLoaded = true;
|
||||
|
||||
if ($notify) {
|
||||
Notification::make()
|
||||
->title(__('WAF logs refreshed'))
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
Notification::make()
|
||||
->title(__('Failed to load WAF logs'))
|
||||
->body($e->getMessage())
|
||||
->warning()
|
||||
->send();
|
||||
}
|
||||
}
|
||||
|
||||
protected function getWhitelistRules(): array
|
||||
{
|
||||
$raw = Setting::get('waf_whitelist_rules', '[]');
|
||||
$rules = json_decode($raw, true);
|
||||
if (! is_array($rules)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$changed = false;
|
||||
|
||||
foreach ($rules as &$rule) {
|
||||
if (! is_array($rule)) {
|
||||
$rule = [];
|
||||
$changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (array_key_exists('enabled', $rule)) {
|
||||
unset($rule['enabled']);
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
$label = (string) ($rule['label'] ?? '');
|
||||
if ($label === '' || str_contains($label, '{rule}') || str_contains($label, ':rule')) {
|
||||
$rule['label'] = $this->defaultWhitelistLabel($rule);
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
$rules = array_values(array_filter($rules, fn ($rule) => is_array($rule) && ($rule !== [])));
|
||||
|
||||
if ($changed) {
|
||||
Setting::set('waf_whitelist_rules', json_encode($rules, JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
protected function defaultWhitelistLabel(array $rule): string
|
||||
{
|
||||
$ids = trim((string) ($rule['rule_ids'] ?? ''));
|
||||
if ($ids !== '') {
|
||||
return __('Rule :id', ['id' => $ids]);
|
||||
}
|
||||
|
||||
$matchValue = trim((string) ($rule['match_value'] ?? ''));
|
||||
if ($matchValue !== '') {
|
||||
return $matchValue;
|
||||
}
|
||||
|
||||
return __('Whitelist rule');
|
||||
}
|
||||
|
||||
protected function markWhitelisted(array $entries): array
|
||||
{
|
||||
$rules = $this->getWhitelistRules();
|
||||
|
||||
foreach ($entries as &$entry) {
|
||||
$entry['whitelisted'] = $this->matchesWhitelist($entry, $rules);
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
protected function normalizeAuditEntries(array $entries): array
|
||||
{
|
||||
return array_map(function (array $entry): array {
|
||||
$entry['__key'] = md5(implode('|', [
|
||||
(string) ($entry['timestamp'] ?? ''),
|
||||
(string) ($entry['rule_id'] ?? ''),
|
||||
(string) ($entry['uri'] ?? ''),
|
||||
(string) ($entry['remote_ip'] ?? ''),
|
||||
(string) ($entry['host'] ?? ''),
|
||||
]));
|
||||
|
||||
return $entry;
|
||||
}, $entries);
|
||||
}
|
||||
|
||||
protected function matchesWhitelist(array $entry, array $rules): bool
|
||||
{
|
||||
$ruleId = (string) ($entry['rule_id'] ?? '');
|
||||
$uri = (string) ($entry['uri'] ?? '');
|
||||
$uriPath = $this->stripQueryString($uri);
|
||||
$host = (string) ($entry['host'] ?? '');
|
||||
$ip = (string) ($entry['remote_ip'] ?? '');
|
||||
|
||||
foreach ($rules as $rule) {
|
||||
if (! is_array($rule)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$idsRaw = (string) ($rule['rule_ids'] ?? '');
|
||||
$ids = preg_split('/[,\s]+/', $idsRaw, -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
||||
$ids = array_map('trim', $ids);
|
||||
if ($ruleId !== '' && ! empty($ids) && ! in_array($ruleId, $ids, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$matchType = (string) ($rule['match_type'] ?? '');
|
||||
$matchValue = (string) ($rule['match_value'] ?? '');
|
||||
|
||||
if ($matchType === 'ip' && $matchValue !== '' && $this->ipMatches($ip, $matchValue)) {
|
||||
return true;
|
||||
}
|
||||
if ($matchType === 'uri_exact' && $matchValue !== '' && ($uri === $matchValue || $uriPath === $matchValue)) {
|
||||
return true;
|
||||
}
|
||||
if ($matchType === 'uri_prefix' && $matchValue !== '' && (str_starts_with($uri, $matchValue) || str_starts_with($uriPath, $matchValue))) {
|
||||
return true;
|
||||
}
|
||||
if ($matchType === 'host' && $matchValue !== '' && $host === $matchValue) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function ruleMatchesEntry(array $rule, array $entry): bool
|
||||
{
|
||||
if (! is_array($rule)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$ruleId = (string) ($entry['rule_id'] ?? '');
|
||||
$uri = (string) ($entry['uri'] ?? '');
|
||||
$uriPath = $this->stripQueryString($uri);
|
||||
$host = (string) ($entry['host'] ?? '');
|
||||
$ip = (string) ($entry['remote_ip'] ?? '');
|
||||
|
||||
$idsRaw = (string) ($rule['rule_ids'] ?? '');
|
||||
$ids = preg_split('/[,\s]+/', $idsRaw, -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
||||
$ids = array_map('trim', $ids);
|
||||
if ($ruleId !== '' && ! empty($ids) && ! in_array($ruleId, $ids, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$matchType = (string) ($rule['match_type'] ?? '');
|
||||
$matchValue = (string) ($rule['match_value'] ?? '');
|
||||
|
||||
if ($matchType === 'ip' && $matchValue !== '' && $this->ipMatches($ip, $matchValue)) {
|
||||
return true;
|
||||
}
|
||||
if ($matchType === 'uri_exact' && $matchValue !== '' && ($uri === $matchValue || $uriPath === $matchValue)) {
|
||||
return true;
|
||||
}
|
||||
if ($matchType === 'uri_prefix' && $matchValue !== '' && (str_starts_with($uri, $matchValue) || str_starts_with($uriPath, $matchValue))) {
|
||||
return true;
|
||||
}
|
||||
if ($matchType === 'host' && $matchValue !== '' && $host === $matchValue) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function ipMatches(string $ip, string $rule): bool
|
||||
{
|
||||
if ($ip === '' || $rule === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! str_contains($rule, '/')) {
|
||||
return $ip === $rule;
|
||||
}
|
||||
|
||||
[$subnet, $bits] = array_pad(explode('/', $rule, 2), 2, null);
|
||||
$bits = is_numeric($bits) ? (int) $bits : null;
|
||||
if ($bits === null || $bits < 0 || $bits > 32) {
|
||||
return $ip === $rule;
|
||||
}
|
||||
|
||||
$ipLong = ip2long($ip);
|
||||
$subnetLong = ip2long($subnet);
|
||||
if ($ipLong === false || $subnetLong === false) {
|
||||
return $ip === $rule;
|
||||
}
|
||||
|
||||
$mask = -1 << (32 - $bits);
|
||||
|
||||
return ($ipLong & $mask) === ($subnetLong & $mask);
|
||||
}
|
||||
|
||||
protected function formatUriForDisplay(array $record): string
|
||||
{
|
||||
$host = (string) ($record['host'] ?? '');
|
||||
$uri = (string) ($record['uri'] ?? '');
|
||||
|
||||
if ($host !== '' && $uri !== '') {
|
||||
return $host.$uri;
|
||||
}
|
||||
|
||||
return $uri !== '' ? $uri : $host;
|
||||
}
|
||||
|
||||
public function whitelistEntry(array $record): void
|
||||
{
|
||||
$rules = $this->getWhitelistRules();
|
||||
$matchType = 'uri_prefix';
|
||||
$rawUri = (string) ($record['uri'] ?? '');
|
||||
$matchValue = $this->stripQueryString($rawUri);
|
||||
if ($matchValue === '') {
|
||||
$matchType = 'ip';
|
||||
$matchValue = (string) ($record['remote_ip'] ?? '');
|
||||
}
|
||||
|
||||
if ($matchValue === '' || empty($record['rule_id'])) {
|
||||
Notification::make()
|
||||
->title(__('Unable to whitelist entry'))
|
||||
->body(__('Missing URI/IP or rule ID for this entry.'))
|
||||
->warning()
|
||||
->send();
|
||||
return;
|
||||
}
|
||||
|
||||
$rules[] = [
|
||||
'label' => __('Rule :rule', ['rule' => $record['rule_id'] ?? '']),
|
||||
'match_type' => $matchType,
|
||||
'match_value' => $matchValue,
|
||||
'rule_ids' => $record['rule_id'] ?? '',
|
||||
];
|
||||
|
||||
Setting::set('waf_whitelist_rules', json_encode(array_values($rules), JSON_UNESCAPED_SLASHES));
|
||||
|
||||
try {
|
||||
$agent = new AgentClient;
|
||||
$agent->wafApplySettings(
|
||||
Setting::get('waf_enabled', '0') === '1',
|
||||
(string) Setting::get('waf_paranoia', '1'),
|
||||
Setting::get('waf_audit_log', '1') === '1',
|
||||
$rules
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
Notification::make()
|
||||
->title(__('Whitelist saved, but apply failed'))
|
||||
->body($e->getMessage())
|
||||
->warning()
|
||||
->send();
|
||||
}
|
||||
|
||||
$this->loadAuditLogs(false);
|
||||
$this->resetTable();
|
||||
$this->dispatch('waf-whitelist-updated');
|
||||
$this->dispatch('waf-blocked-updated');
|
||||
|
||||
Notification::make()
|
||||
->title(__('Rule whitelisted'))
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
protected function stripQueryString(string $uri): string
|
||||
{
|
||||
$pos = strpos($uri, '?');
|
||||
if ($pos === false) {
|
||||
return $uri;
|
||||
}
|
||||
|
||||
return substr($uri, 0, $pos);
|
||||
}
|
||||
|
||||
public function removeWhitelistEntry(array $record): void
|
||||
{
|
||||
$rules = $this->getWhitelistRules();
|
||||
$beforeCount = count($rules);
|
||||
|
||||
$rules = array_values(array_filter($rules, function (array $rule) use ($record): bool {
|
||||
return ! $this->ruleMatchesEntry($rule, $record);
|
||||
}));
|
||||
|
||||
if (count($rules) === $beforeCount) {
|
||||
Notification::make()
|
||||
->title(__('No matching whitelist rule found'))
|
||||
->warning()
|
||||
->send();
|
||||
return;
|
||||
}
|
||||
|
||||
Setting::set('waf_whitelist_rules', json_encode(array_values($rules), JSON_UNESCAPED_SLASHES));
|
||||
|
||||
try {
|
||||
$agent = new AgentClient;
|
||||
$agent->wafApplySettings(
|
||||
Setting::get('waf_enabled', '0') === '1',
|
||||
(string) Setting::get('waf_paranoia', '1'),
|
||||
Setting::get('waf_audit_log', '1') === '1',
|
||||
$rules
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
Notification::make()
|
||||
->title(__('Whitelist updated, but apply failed'))
|
||||
->body($e->getMessage())
|
||||
->warning()
|
||||
->send();
|
||||
}
|
||||
|
||||
$this->loadAuditLogs(false);
|
||||
$this->resetTable();
|
||||
$this->dispatch('waf-whitelist-updated');
|
||||
$this->dispatch('waf-blocked-updated');
|
||||
|
||||
Notification::make()
|
||||
->title(__('Whitelist removed'))
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->persistColumnsInSession(false)
|
||||
->paginated([25, 50, 100])
|
||||
->defaultPaginationPageOption(25)
|
||||
->records(function (?array $filters, ?string $search, int|string $page, int|string $recordsPerPage, ?string $sortColumn, ?string $sortDirection) {
|
||||
if (! $this->auditLoaded) {
|
||||
$this->loadAuditLogs(false);
|
||||
}
|
||||
|
||||
$records = $this->auditEntries;
|
||||
|
||||
$records = $this->filterRecords($records, $search);
|
||||
$records = $this->sortRecords($records, $sortColumn, $sortDirection);
|
||||
|
||||
return $this->paginateRecords($records, $page, $recordsPerPage);
|
||||
})
|
||||
->columns([
|
||||
TextColumn::make('timestamp')
|
||||
->label(__('Time'))
|
||||
->formatStateUsing(function (array $record): string {
|
||||
$timestamp = (int) ($record['timestamp'] ?? 0);
|
||||
return $timestamp > 0 ? date('Y-m-d H:i:s', $timestamp) : '';
|
||||
})
|
||||
->sortable(),
|
||||
TextColumn::make('rule_id')
|
||||
->label(__('Rule ID'))
|
||||
->fontFamily('mono')
|
||||
->sortable()
|
||||
->searchable(),
|
||||
TextColumn::make('event_type')
|
||||
->label(__('Type'))
|
||||
->badge()
|
||||
->getStateUsing(function (array $record): string {
|
||||
if (! empty($record['blocked'])) {
|
||||
return __('Blocked');
|
||||
}
|
||||
|
||||
$severity = (int) ($record['severity'] ?? 0);
|
||||
if ($severity >= 4) {
|
||||
return __('Error');
|
||||
}
|
||||
|
||||
return __('Warning');
|
||||
})
|
||||
->color(function (array $record): string {
|
||||
if (! empty($record['blocked'])) {
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
$severity = (int) ($record['severity'] ?? 0);
|
||||
if ($severity >= 4) {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
return 'gray';
|
||||
})
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
TextColumn::make('message')
|
||||
->label(__('Message'))
|
||||
->wrap()
|
||||
->limit(80)
|
||||
->searchable(),
|
||||
TextColumn::make('uri')
|
||||
->label(__('URI'))
|
||||
->getStateUsing(fn (array $record): string => $this->formatUriForDisplay($record))
|
||||
->formatStateUsing(fn (string $state): string => Str::limit($state, 52, '…'))
|
||||
->tooltip(fn (array $record): string => $this->formatUriForDisplay($record))
|
||||
->copyable()
|
||||
->copyableState(fn (array $record): string => $this->formatUriForDisplay($record))
|
||||
->extraAttributes([
|
||||
'class' => 'inline-block max-w-[240px] truncate',
|
||||
'style' => 'max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block;',
|
||||
])
|
||||
->wrap(false)
|
||||
->searchable(),
|
||||
TextColumn::make('remote_ip')
|
||||
->label(__('Source IP'))
|
||||
->fontFamily('mono')
|
||||
->copyable()
|
||||
->toggleable(isToggledHiddenByDefault: false),
|
||||
TextColumn::make('host')
|
||||
->label(__('Host'))
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('whitelisted')
|
||||
->label(__('Whitelisted'))
|
||||
->badge()
|
||||
->formatStateUsing(fn (array $record): string => ! empty($record['whitelisted']) ? __('Yes') : __('No'))
|
||||
->color(fn (array $record): string => ! empty($record['whitelisted']) ? 'success' : 'gray'),
|
||||
])
|
||||
->recordActions([
|
||||
\Filament\Actions\Action::make('whitelist')
|
||||
->label(__('Whitelist'))
|
||||
->icon('heroicon-o-check-badge')
|
||||
->color('primary')
|
||||
->visible(fn (array $record): bool => empty($record['whitelisted']))
|
||||
->action(fn (array $record) => $this->whitelistEntry($record)),
|
||||
\Filament\Actions\Action::make('removeWhitelist')
|
||||
->label(__('Remove whitelist'))
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('danger')
|
||||
->visible(fn (array $record): bool => ! empty($record['whitelisted']))
|
||||
->requiresConfirmation()
|
||||
->action(fn (array $record) => $this->removeWhitelistEntry($record)),
|
||||
])
|
||||
->emptyStateHeading(__('No blocked rules found'))
|
||||
->emptyStateDescription(__('No ModSecurity denials found in the audit log.'))
|
||||
->headerActions([
|
||||
\Filament\Actions\Action::make('refresh')
|
||||
->label(__('Refresh Logs'))
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->action(fn () => $this->loadAuditLogs()),
|
||||
]);
|
||||
}
|
||||
|
||||
#[On('waf-blocked-updated')]
|
||||
public function refreshBlockedTable(): void
|
||||
{
|
||||
$this->loadAuditLogs(false);
|
||||
$this->resetTable();
|
||||
}
|
||||
|
||||
|
||||
|
||||
protected function filterRecords(array $records, ?string $search): array
|
||||
{
|
||||
if (! $search) {
|
||||
return $records;
|
||||
}
|
||||
|
||||
return array_values(array_filter($records, function (array $record) use ($search) {
|
||||
$haystack = implode(' ', array_filter([
|
||||
(string) ($record['rule_id'] ?? ''),
|
||||
(string) ($record['message'] ?? ''),
|
||||
(string) ($record['uri'] ?? ''),
|
||||
(string) ($record['remote_ip'] ?? ''),
|
||||
(string) ($record['host'] ?? ''),
|
||||
]));
|
||||
|
||||
return str_contains(Str::lower($haystack), Str::lower($search));
|
||||
}));
|
||||
}
|
||||
|
||||
protected function sortRecords(array $records, ?string $sortColumn, ?string $sortDirection): array
|
||||
{
|
||||
$direction = $sortDirection === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
if (! $sortColumn) {
|
||||
return $records;
|
||||
}
|
||||
|
||||
usort($records, function (array $a, array $b) use ($sortColumn, $direction): int {
|
||||
$aValue = $a[$sortColumn] ?? null;
|
||||
$bValue = $b[$sortColumn] ?? null;
|
||||
|
||||
if (is_numeric($aValue) && is_numeric($bValue)) {
|
||||
$result = (float) $aValue <=> (float) $bValue;
|
||||
} else {
|
||||
$result = strcmp((string) $aValue, (string) $bValue);
|
||||
}
|
||||
|
||||
return $direction === 'asc' ? $result : -$result;
|
||||
});
|
||||
|
||||
return $records;
|
||||
}
|
||||
|
||||
protected function paginateRecords(array $records, int|string $page, int|string $recordsPerPage): LengthAwarePaginator
|
||||
{
|
||||
$page = max(1, (int) $page);
|
||||
$perPage = max(1, (int) $recordsPerPage);
|
||||
|
||||
$total = count($records);
|
||||
$items = array_slice($records, ($page - 1) * $perPage, $perPage);
|
||||
|
||||
return new LengthAwarePaginator(
|
||||
$items,
|
||||
$total,
|
||||
$perPage,
|
||||
$page,
|
||||
[
|
||||
'path' => request()->url(),
|
||||
'pageName' => $this->getTablePaginationPageName(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.security-waf-panel');
|
||||
}
|
||||
}
|
||||
@@ -43,8 +43,7 @@ class AdminPanelProvider extends PanelProvider
|
||||
->renderHook(
|
||||
PanelsRenderHook::HEAD_END,
|
||||
fn () => $this->getOpenGraphTags('Jabali Admin', 'Server administration panel for Jabali - Manage your hosting infrastructure').
|
||||
'<link rel="stylesheet" href="'.asset('css/filament-custom.css').'">'.
|
||||
\Illuminate\Support\Facades\Vite::useBuildDirectory('build')->withEntryPoints(['resources/js/server-charts.js'])->toHtml().
|
||||
\Illuminate\Support\Facades\Vite::useBuildDirectory('build')->withEntryPoints(['resources/css/app.css', 'resources/js/server-charts.js'])->toHtml().
|
||||
$this->getRtlScript()
|
||||
)
|
||||
->renderHook(
|
||||
|
||||
@@ -46,8 +46,7 @@ class JabaliPanelProvider extends PanelProvider
|
||||
->renderHook(
|
||||
PanelsRenderHook::HEAD_END,
|
||||
fn () => $this->getOpenGraphTags('Jabali Panel', 'Web hosting control panel - Manage your domains, emails, databases and more').
|
||||
'<link rel="stylesheet" href="'.asset('css/filament-custom.css').'?v='.filemtime(public_path('css/filament-custom.css')).'">'.
|
||||
\Illuminate\Support\Facades\Vite::useBuildDirectory('build')->withEntryPoints(['resources/js/server-charts.js'])->toHtml().
|
||||
\Illuminate\Support\Facades\Vite::useBuildDirectory('build')->withEntryPoints(['resources/css/app.css', 'resources/js/server-charts.js'])->toHtml().
|
||||
$this->getRtlScript()
|
||||
)
|
||||
->renderHook(
|
||||
|
||||
@@ -1329,9 +1329,9 @@ class AgentClient
|
||||
}
|
||||
|
||||
// Server updates
|
||||
public function updatesList(): array
|
||||
public function updatesList(bool $refresh = false): array
|
||||
{
|
||||
return $this->send('updates.list');
|
||||
return $this->send('updates.list', ['refresh' => $refresh]);
|
||||
}
|
||||
|
||||
public function updatesRun(): array
|
||||
|
||||
@@ -3781,6 +3781,11 @@ function geoApplyRules(array $params): array
|
||||
|
||||
$httpConf = [
|
||||
'# Managed by Jabali',
|
||||
'real_ip_header X-Forwarded-For;',
|
||||
'real_ip_recursive on;',
|
||||
'set_real_ip_from 127.0.0.1;',
|
||||
'set_real_ip_from ::1;',
|
||||
'',
|
||||
"geoip2 {$mmdb} {",
|
||||
" {$countryVar} country iso_code;",
|
||||
'}',
|
||||
@@ -25028,6 +25033,18 @@ function fail2banLogs(array $params): array
|
||||
|
||||
function updatesList(array $params): array
|
||||
{
|
||||
$warnings = [];
|
||||
$refresh = (bool) ($params['refresh'] ?? false);
|
||||
$refreshOutput = [];
|
||||
$refreshSuccess = null;
|
||||
if ($refresh) {
|
||||
exec('apt-get update -y 2>&1', $refreshOutput, $codeUpdate);
|
||||
$refreshSuccess = $codeUpdate === 0;
|
||||
if ($codeUpdate !== 0) {
|
||||
$warnings[] = implode("\n", $refreshOutput);
|
||||
}
|
||||
}
|
||||
|
||||
$output = [];
|
||||
exec('apt list --upgradable 2>/dev/null', $output, $code);
|
||||
if ($code !== 0) {
|
||||
@@ -25049,7 +25066,13 @@ function updatesList(array $params): array
|
||||
}
|
||||
}
|
||||
|
||||
return ['success' => true, 'packages' => $packages];
|
||||
return [
|
||||
'success' => true,
|
||||
'packages' => $packages,
|
||||
'warnings' => $warnings,
|
||||
'refresh_output' => $refreshOutput,
|
||||
'refresh_success' => $refreshSuccess,
|
||||
];
|
||||
}
|
||||
|
||||
function updatesRun(array $params): array
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
/**
|
||||
* Jabali Panel - Custom Styling
|
||||
* Removes all rounded corners for a sharp, professional look
|
||||
*/
|
||||
|
||||
/* Reset all border-radius to 0 */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* Filament specific overrides */
|
||||
.fi-sidebar,
|
||||
.fi-sidebar-nav,
|
||||
.fi-topbar,
|
||||
.fi-main,
|
||||
.fi-section,
|
||||
.fi-card,
|
||||
.fi-modal,
|
||||
.fi-dropdown,
|
||||
.fi-btn,
|
||||
.fi-badge,
|
||||
.fi-avatar,
|
||||
.fi-input-wrapper,
|
||||
.fi-select,
|
||||
.fi-checkbox,
|
||||
.fi-radio,
|
||||
.fi-toggle,
|
||||
.fi-tabs,
|
||||
.fi-tab,
|
||||
.fi-notification,
|
||||
.fi-pagination,
|
||||
.fi-table,
|
||||
.fi-table-cell,
|
||||
.fi-widget,
|
||||
.fi-breadcrumbs,
|
||||
.fi-header,
|
||||
.fi-footer {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* Form inputs */
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
button,
|
||||
.form-input,
|
||||
.form-select,
|
||||
.form-textarea,
|
||||
.form-checkbox,
|
||||
.form-radio {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
[class*="rounded"],
|
||||
.rounded,
|
||||
.rounded-sm,
|
||||
.rounded-md,
|
||||
.rounded-lg,
|
||||
.rounded-xl,
|
||||
.rounded-2xl,
|
||||
.rounded-3xl,
|
||||
.rounded-full {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* Cards and containers */
|
||||
.bg-white,
|
||||
.bg-gray-50,
|
||||
.bg-gray-100,
|
||||
[class*="shadow"] {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* Dropdown menus */
|
||||
[x-float],
|
||||
[x-menu],
|
||||
[x-dropdown] {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* Modal dialogs */
|
||||
[x-dialog],
|
||||
.fi-modal-window {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* Notifications/Toasts */
|
||||
.fi-notification-body,
|
||||
[class*="toast"] {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* Progress bars */
|
||||
.fi-progress,
|
||||
progress {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* Images and avatars */
|
||||
img,
|
||||
.fi-avatar-image {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* Specific Filament 4 component overrides */
|
||||
.fi-simple-layout,
|
||||
.fi-simple-main,
|
||||
.fi-simple-main-ctn {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* Table cells */
|
||||
.fi-ta-cell,
|
||||
.fi-ta-header-cell {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* Action buttons */
|
||||
.fi-ac-btn,
|
||||
.fi-ac-action,
|
||||
.fi-ac-icon-btn {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* Widgets */
|
||||
.fi-wi-stats-overview-stat,
|
||||
.fi-wi-chart {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
$tabs = [
|
||||
'overview' => ['label' => __('Overview'), 'icon' => 'heroicon-o-home'],
|
||||
'firewall' => ['label' => __('Firewall'), 'icon' => 'heroicon-o-shield-check'],
|
||||
'waf' => ['label' => __('ModSecurity / WAF'), 'icon' => 'heroicon-o-shield-exclamation'],
|
||||
'fail2ban' => ['label' => __('Fail2ban'), 'icon' => 'heroicon-o-lock-closed'],
|
||||
'antivirus' => ['label' => __('Antivirus'), 'icon' => 'heroicon-o-bug-ant'],
|
||||
'ssh' => ['label' => __('SSH'), 'icon' => 'heroicon-o-command-line'],
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
@livewire('admin.security-waf-panel')
|
||||
@@ -6,6 +6,7 @@
|
||||
'email' => ['label' => __('Email'), 'icon' => 'heroicon-o-envelope'],
|
||||
'notifications' => ['label' => __('Notifications'), 'icon' => 'heroicon-o-bell'],
|
||||
'php-fpm' => ['label' => __('PHP-FPM'), 'icon' => 'heroicon-o-cpu-chip'],
|
||||
'database' => ['label' => __('Database Tuning'), 'icon' => 'heroicon-o-circle-stack'],
|
||||
];
|
||||
@endphp
|
||||
|
||||
|
||||
36
resources/views/livewire/admin/security-waf-panel.blade.php
Normal file
36
resources/views/livewire/admin/security-waf-panel.blade.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<div>
|
||||
@if(! $wafInstalled)
|
||||
<x-filament::section icon="heroicon-o-exclamation-triangle" icon-color="warning">
|
||||
<x-slot name="heading">{{ __('ModSecurity not detected') }}</x-slot>
|
||||
<x-slot name="description">
|
||||
{{ __('Install ModSecurity on the server to enable WAF controls. Settings can be saved now and applied later.') }}
|
||||
</x-slot>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
|
||||
<div class="mt-6 mb-8">
|
||||
{{ $this->wafForm }}
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<x-filament::section icon="heroicon-o-list-bullet" icon-color="primary">
|
||||
<x-slot name="heading">{{ __('Whitelist Rules') }}</x-slot>
|
||||
<x-slot name="description">
|
||||
{{ __('Exclude trusted traffic from specific ModSecurity rule IDs.') }}
|
||||
</x-slot>
|
||||
@livewire('admin.waf-whitelist-table')
|
||||
</x-filament::section>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<x-filament::section icon="heroicon-o-document-text" icon-color="primary">
|
||||
<x-slot name="heading">{{ __('Blocked Requests') }}</x-slot>
|
||||
<x-slot name="description">
|
||||
{{ __('Recent ModSecurity denials from the audit log. Use whitelist to allow trusted traffic.') }}
|
||||
</x-slot>
|
||||
{{ $this->table }}
|
||||
</x-filament::section>
|
||||
</div>
|
||||
|
||||
<x-filament-actions::modals />
|
||||
</div>
|
||||
Reference in New Issue
Block a user