diff --git a/app/Filament/Admin/Pages/Dashboard.php b/app/Filament/Admin/Pages/Dashboard.php index dd31dbc..3ce05dd 100644 --- a/app/Filament/Admin/Pages/Dashboard.php +++ b/app/Filament/Admin/Pages/Dashboard.php @@ -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'; diff --git a/app/Filament/Admin/Pages/DatabaseTuning.php b/app/Filament/Admin/Pages/DatabaseTuning.php index abac809..583a305 100644 --- a/app/Filament/Admin/Pages/DatabaseTuning.php +++ b/app/Filament/Admin/Pages/DatabaseTuning.php @@ -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 = []; diff --git a/app/Filament/Admin/Pages/Security.php b/app/Filament/Admin/Pages/Security.php index 19047be..954dc23 100644 --- a/app/Filament/Admin/Pages/Security.php +++ b/app/Filament/Admin/Pages/Security.php @@ -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 { diff --git a/app/Filament/Admin/Pages/ServerSettings.php b/app/Filament/Admin/Pages/ServerSettings.php index f712c7c..3e7f4f8 100644 --- a/app/Filament/Admin/Pages/ServerSettings.php +++ b/app/Filament/Admin/Pages/ServerSettings.php @@ -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 [ diff --git a/app/Filament/Admin/Pages/Services.php b/app/Filament/Admin/Pages/Services.php index 0acdef9..596f737 100644 --- a/app/Filament/Admin/Pages/Services.php +++ b/app/Filament/Admin/Pages/Services.php @@ -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() diff --git a/app/Filament/Admin/Pages/Waf.php b/app/Filament/Admin/Pages/Waf.php index fa9db3c..2308c94 100644 --- a/app/Filament/Admin/Pages/Waf.php +++ b/app/Filament/Admin/Pages/Waf.php @@ -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 = []; diff --git a/app/Filament/Admin/Resources/HostingPackages/HostingPackageResource.php b/app/Filament/Admin/Resources/HostingPackages/HostingPackageResource.php index aa8f4b9..6414330 100644 --- a/app/Filament/Admin/Resources/HostingPackages/HostingPackageResource.php +++ b/app/Filament/Admin/Resources/HostingPackages/HostingPackageResource.php @@ -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 { diff --git a/app/Filament/Admin/Resources/Users/UserResource.php b/app/Filament/Admin/Resources/Users/UserResource.php index 08d3569..4112c39 100644 --- a/app/Filament/Admin/Resources/Users/UserResource.php +++ b/app/Filament/Admin/Resources/Users/UserResource.php @@ -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 { diff --git a/app/Filament/Admin/Widgets/Settings/DatabaseTuningTable.php b/app/Filament/Admin/Widgets/Settings/DatabaseTuningTable.php new file mode 100644 index 0000000..12f4dcf --- /dev/null +++ b/app/Filament/Admin/Widgets/Settings/DatabaseTuningTable.php @@ -0,0 +1,151 @@ +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(); + } +} diff --git a/app/Filament/Admin/Widgets/SslStatsOverview.php b/app/Filament/Admin/Widgets/SslStatsOverview.php index 6fa7f05..c34d32f 100644 --- a/app/Filament/Admin/Widgets/SslStatsOverview.php +++ b/app/Filament/Admin/Widgets/SslStatsOverview.php @@ -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', + ], ]; } } diff --git a/app/Livewire/Admin/SecurityWafPanel.php b/app/Livewire/Admin/SecurityWafPanel.php new file mode 100644 index 0000000..a7726af --- /dev/null +++ b/app/Livewire/Admin/SecurityWafPanel.php @@ -0,0 +1,693 @@ +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'); + } +} diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 78995c1..c1214b3 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -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'). - ''. - \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( diff --git a/app/Providers/Filament/JabaliPanelProvider.php b/app/Providers/Filament/JabaliPanelProvider.php index 15f6546..63db9dd 100644 --- a/app/Providers/Filament/JabaliPanelProvider.php +++ b/app/Providers/Filament/JabaliPanelProvider.php @@ -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'). - ''. - \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( diff --git a/app/Services/Agent/AgentClient.php b/app/Services/Agent/AgentClient.php index ac1bf69..7df7bd1 100644 --- a/app/Services/Agent/AgentClient.php +++ b/app/Services/Agent/AgentClient.php @@ -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 diff --git a/bin/jabali-agent b/bin/jabali-agent index 48a48f1..d59da21 100755 --- a/bin/jabali-agent +++ b/bin/jabali-agent @@ -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 diff --git a/public/css/filament-custom.css b/public/css/filament-custom.css deleted file mode 100644 index 32fa941..0000000 --- a/public/css/filament-custom.css +++ /dev/null @@ -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; -} diff --git a/resources/views/filament/admin/components/security-tabs-nav.blade.php b/resources/views/filament/admin/components/security-tabs-nav.blade.php index 5237e96..8582fa5 100644 --- a/resources/views/filament/admin/components/security-tabs-nav.blade.php +++ b/resources/views/filament/admin/components/security-tabs-nav.blade.php @@ -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'], diff --git a/resources/views/filament/admin/components/security-waf.blade.php b/resources/views/filament/admin/components/security-waf.blade.php new file mode 100644 index 0000000..44af525 --- /dev/null +++ b/resources/views/filament/admin/components/security-waf.blade.php @@ -0,0 +1 @@ +@livewire('admin.security-waf-panel') diff --git a/resources/views/filament/admin/components/server-settings-tabs-nav.blade.php b/resources/views/filament/admin/components/server-settings-tabs-nav.blade.php index 99821cf..75b20e7 100644 --- a/resources/views/filament/admin/components/server-settings-tabs-nav.blade.php +++ b/resources/views/filament/admin/components/server-settings-tabs-nav.blade.php @@ -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 diff --git a/resources/views/livewire/admin/security-waf-panel.blade.php b/resources/views/livewire/admin/security-waf-panel.blade.php new file mode 100644 index 0000000..1197527 --- /dev/null +++ b/resources/views/livewire/admin/security-waf-panel.blade.php @@ -0,0 +1,36 @@ +
+ @if(! $wafInstalled) + + {{ __('ModSecurity not detected') }} + + {{ __('Install ModSecurity on the server to enable WAF controls. Settings can be saved now and applied later.') }} + + + @endif + +
+ {{ $this->wafForm }} +
+ +
+ + {{ __('Whitelist Rules') }} + + {{ __('Exclude trusted traffic from specific ModSecurity rule IDs.') }} + + @livewire('admin.waf-whitelist-table') + +
+ +
+ + {{ __('Blocked Requests') }} + + {{ __('Recent ModSecurity denials from the audit log. Use whitelist to allow trusted traffic.') }} + + {{ $this->table }} + +
+ + +