diff --git a/VERSION b/VERSION index 9465149..6520d80 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -VERSION=0.9-rc10 +VERSION=0.9-rc11 diff --git a/app/Filament/Admin/Pages/DatabaseTuning.php b/app/Filament/Admin/Pages/DatabaseTuning.php index 26a13b4..abac809 100644 --- a/app/Filament/Admin/Pages/DatabaseTuning.php +++ b/app/Filament/Admin/Pages/DatabaseTuning.php @@ -18,7 +18,6 @@ use Filament\Tables\Concerns\InteractsWithTable; use Filament\Tables\Contracts\HasTable; use Filament\Tables\Table; use Illuminate\Contracts\Support\Htmlable; -use Illuminate\Support\Facades\DB; class DatabaseTuning extends Page implements HasActions, HasTable { @@ -50,6 +49,11 @@ class DatabaseTuning extends Page implements HasActions, HasTable $this->loadVariables(); } + protected function getAgent(): AgentClient + { + return app(AgentClient::class); + } + public function loadVariables(): void { $names = [ @@ -62,19 +66,21 @@ class DatabaseTuning extends Page implements HasActions, HasTable ]; try { - $placeholders = implode("','", $names); - $rows = DB::connection('mysql')->select("SHOW VARIABLES WHERE Variable_name IN ('$placeholders')"); + $result = $this->getAgent()->databaseGetVariables($names); + if (! ($result['success'] ?? false)) { + throw new \RuntimeException($result['error'] ?? __('Unable to load variables')); + } - $this->variables = collect($rows)->map(function ($row) { + $this->variables = collect($result['variables'] ?? [])->map(function (array $row) { return [ - 'name' => $row->Variable_name, - 'value' => $row->Value, + 'name' => $row['name'] ?? '', + 'value' => $row['value'] ?? '', ]; })->toArray(); } catch (\Exception $e) { $this->variables = []; Notification::make() - ->title(__('Unable to load MySQL variables')) + ->title(__('Unable to load database variables')) ->body($e->getMessage()) ->warning() ->send(); @@ -106,20 +112,31 @@ class DatabaseTuning extends Page implements HasActions, HasTable ]) ->action(function (array $record, array $data): void { try { - DB::connection('mysql')->statement('SET GLOBAL '.$record['name'].' = ?', [$data['value']]); try { - $agent = new AgentClient; - $agent->databasePersistTuning($record['name'], (string) $data['value']); + $agent = $this->getAgent(); + $setResult = $agent->databaseSetGlobal($record['name'], (string) $data['value']); + if (! ($setResult['success'] ?? false)) { + throw new \RuntimeException($setResult['error'] ?? __('Update failed')); + } - Notification::make() - ->title(__('Variable updated')) - ->success() - ->send(); + $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(__('Variable updated, but not persisted')) + ->title(__('Update failed')) ->body($e->getMessage()) - ->warning() + ->danger() ->send(); } } catch (\Exception $e) { diff --git a/app/Filament/Admin/Pages/ServerSettings.php b/app/Filament/Admin/Pages/ServerSettings.php index bcad2ed..f712c7c 100644 --- a/app/Filament/Admin/Pages/ServerSettings.php +++ b/app/Filament/Admin/Pages/ServerSettings.php @@ -7,6 +7,8 @@ namespace App\Filament\Admin\Pages; use App\Filament\Admin\Widgets\Settings\DnssecTable; use App\Filament\Admin\Widgets\Settings\NotificationLogTable; use App\Models\DnsSetting; +use App\Models\HostingPackage; +use App\Models\User; use App\Services\Agent\AgentClient; use BackedEnum; use Exception; @@ -29,6 +31,7 @@ use Filament\Schemas\Components\View; use Filament\Schemas\Schema; use Illuminate\Contracts\Support\Htmlable; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Response; @@ -164,6 +167,9 @@ class ServerSettings extends Page implements HasActions, HasForms } $resolvers = [$ns[0] ?? '', $ns[1] ?? '', $ns[2] ?? '']; } + if (trim($searchDomain) === 'example.com') { + $searchDomain = ''; + } // Fill form data $this->brandingData = [ @@ -381,6 +387,17 @@ class ServerSettings extends Page implements HasActions, HasForms ->description($this->isSystemdResolved ? __('systemd-resolved active') : null) ->icon('heroicon-o-signal') ->schema([ + Actions::make([ + FormAction::make('applyCloudflareResolvers') + ->label(__('Use Cloudflare')) + ->action(fn () => $this->applyResolverTemplate('cloudflare')), + FormAction::make('applyGoogleResolvers') + ->label(__('Use Google')) + ->action(fn () => $this->applyResolverTemplate('google')), + FormAction::make('applyQuad9Resolvers') + ->label(__('Use Quad9')) + ->action(fn () => $this->applyResolverTemplate('quad9')), + ])->alignment('left'), Grid::make(['default' => 1, 'md' => 2, 'lg' => 4])->schema([ TextInput::make('resolversData.resolver1')->label(__('Resolver 1'))->placeholder('8.8.8.8'), TextInput::make('resolversData.resolver2')->label(__('Resolver 2'))->placeholder('8.8.4.4'), @@ -402,6 +419,33 @@ class ServerSettings extends Page implements HasActions, HasForms ]; } + protected function applyResolverTemplate(string $template): void + { + $templates = [ + 'cloudflare' => ['1.1.1.1', '1.0.0.1', '2606:4700:4700::1111'], + 'google' => ['8.8.8.8', '8.8.4.4', '2001:4860:4860::8888'], + 'quad9' => ['9.9.9.9', '149.112.112.112', '2620:fe::fe'], + ]; + + $resolvers = $templates[$template] ?? null; + + if (! $resolvers) { + return; + } + + $current = $this->resolversData ?? []; + $this->resolversData = array_merge($current, [ + 'resolver1' => $resolvers[0] ?? '', + 'resolver2' => $resolvers[1] ?? '', + 'resolver3' => $resolvers[2] ?? '', + ]); + + Notification::make() + ->title(__('DNS resolver template applied')) + ->success() + ->send(); + } + protected function storageTabContent(): array { return [ @@ -504,7 +548,7 @@ class ServerSettings extends Page implements HasActions, HasForms ->placeholder('admin@example.com, alerts@example.com') ->helperText(__('Comma-separated list of email addresses to receive notifications')), ]), - Section::make(__('Notification Types')) + Section::make(__('Notification Types & High Load Alerts')) ->icon('heroicon-o-bell-alert') ->schema([ Grid::make(['default' => 1, 'md' => 2])->schema([ @@ -533,10 +577,6 @@ class ServerSettings extends Page implements HasActions, HasForms ->label(__('Service Health Alerts')) ->helperText(__('Service failures and auto-restarts')), ]), - ]), - Section::make(__('High Load Alerts')) - ->icon('heroicon-o-cpu-chip') - ->schema([ Grid::make(['default' => 1, 'md' => 3])->schema([ Toggle::make('notificationsData.notify_high_load') ->label(__('Enable High Load Alerts')) @@ -906,19 +946,14 @@ class ServerSettings extends Page implements HasActions, HasForms public function saveEmailNotificationSettings(): void { $data = $this->notificationsData; - - if (! empty($data['admin_email_recipients'])) { - $emails = array_map('trim', explode(',', $data['admin_email_recipients'])); - foreach ($emails as $email) { - if (! filter_var($email, FILTER_VALIDATE_EMAIL)) { - Notification::make()->title(__('Invalid recipient email'))->body(__(':email is not a valid email address', ['email' => $email]))->danger()->send(); - - return; - } - } + $emails = $this->parseNotificationRecipients($data['admin_email_recipients'] ?? '', false); + if ($emails === null) { + return; } - DnsSetting::set('admin_email_recipients', $data['admin_email_recipients']); + $emailsValue = $emails === [] ? '' : implode(', ', $emails); + $this->notificationsData['admin_email_recipients'] = $emailsValue; + DnsSetting::set('admin_email_recipients', $emailsValue); DnsSetting::set('notify_ssl_errors', $data['notify_ssl_errors'] ? '1' : '0'); DnsSetting::set('notify_backup_failures', $data['notify_backup_failures'] ? '1' : '0'); DnsSetting::set('notify_backup_success', $data['notify_backup_success'] ? '1' : '0'); @@ -938,15 +973,12 @@ class ServerSettings extends Page implements HasActions, HasForms public function sendTestEmail(): void { $recipients = $this->notificationsData['admin_email_recipients'] ?? ''; - - if (empty($recipients)) { - Notification::make()->title(__('No recipients configured'))->body(__('Please add at least one admin email address'))->warning()->send(); - + $recipientList = $this->parseNotificationRecipients($recipients, true); + if ($recipientList === null) { return; } try { - $recipientList = array_map('trim', explode(',', $recipients)); $hostname = gethostname() ?: 'localhost'; $sender = "webmaster@{$hostname}"; $subject = __('Test Email'); @@ -988,6 +1020,50 @@ class ServerSettings extends Page implements HasActions, HasForms } } + /** + * @return array|null + */ + protected function parseNotificationRecipients(?string $recipients, bool $requireOne): ?array + { + $value = trim((string) $recipients); + if ($value === '') { + if ($requireOne) { + $this->addError('notificationsData.admin_email_recipients', __('Please add at least one email address.')); + Notification::make()->title(__('Email Addresses required'))->body(__('Please add at least one email address in Email Addresses.'))->warning()->send(); + + return null; + } + + return []; + } + + if (str_contains($value, ';')) { + $this->addError('notificationsData.admin_email_recipients', __('Use a comma-separated list of email addresses.')); + Notification::make()->title(__('Invalid recipient format'))->body(__('Use commas to separate email addresses.'))->danger()->send(); + + return null; + } + + $emails = array_values(array_filter(array_map('trim', explode(',', $value)), fn (string $email): bool => $email !== '')); + if ($requireOne && $emails === []) { + $this->addError('notificationsData.admin_email_recipients', __('Please add at least one email address.')); + Notification::make()->title(__('Email Addresses required'))->body(__('Please add at least one email address in Email Addresses.'))->warning()->send(); + + return null; + } + + foreach ($emails as $email) { + if (! filter_var($email, FILTER_VALIDATE_EMAIL)) { + $this->addError('notificationsData.admin_email_recipients', __('Use a comma-separated list of valid email addresses.')); + Notification::make()->title(__('Invalid recipient email'))->body(__(':email is not a valid email address', ['email' => $email]))->danger()->send(); + + return null; + } + } + + return $emails; + } + public function saveFpmSettings(): void { $data = $this->phpFpmData; @@ -1160,15 +1236,7 @@ class ServerSettings extends Page implements HasActions, HasForms public function exportConfig(): \Symfony\Component\HttpFoundation\StreamedResponse { - $settings = DnsSetting::getAll(); - unset($settings['custom_logo']); - - $exportData = [ - 'version' => '1.0', - 'exported_at' => now()->toIso8601String(), - 'hostname' => gethostname(), - 'settings' => $settings, - ]; + $exportData = $this->buildExportPayload(); $filename = 'jabali-config-'.date('Y-m-d-His').'.json'; $content = json_encode($exportData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); @@ -1204,22 +1272,258 @@ class ServerSettings extends Page implements HasActions, HasForms throw new Exception(__('Invalid configuration file format')); } - $imported = 0; + $importedSettings = 0; foreach ($importData['settings'] as $key => $value) { if (in_array($key, ['custom_logo'])) { continue; } DnsSetting::set($key, $value); - $imported++; + $importedSettings++; + } + + $importedPackages = 0; + if (isset($importData['hosting_packages']) && is_array($importData['hosting_packages'])) { + $importedPackages = $this->importHostingPackages($importData['hosting_packages']); + } + + $tokenSummary = ['imported' => 0, 'skipped' => 0]; + if (isset($importData['api_tokens']) && is_array($importData['api_tokens'])) { + $tokenSummary = $this->importApiTokens($importData['api_tokens']); + } + + $tuningSummary = ['applied' => 0, 'failed' => 0]; + if (isset($importData['database_tuning']) && is_array($importData['database_tuning'])) { + $tuningSummary = $this->applyDatabaseTuningFromImport($importData['database_tuning']); } DnsSetting::clearCache(); Storage::disk('local')->delete($data['config_file']); $this->mount(); - Notification::make()->title(__('Configuration imported'))->body(__(':count settings imported successfully', ['count' => $imported]))->success()->send(); + $message = __(':settings settings imported, :packages packages updated, :tokens tokens imported, :tuning tuning entries applied', [ + 'settings' => $importedSettings, + 'packages' => $importedPackages, + 'tokens' => $tokenSummary['imported'], + 'tuning' => $tuningSummary['applied'], + ]); + Notification::make()->title(__('Configuration imported'))->body($message)->success()->send(); } catch (Exception $e) { Notification::make()->title(__('Import failed'))->body($e->getMessage())->danger()->send(); } } + + /** + * @return array + */ + public function buildExportPayload(): array + { + $settings = DnsSetting::getAll(); + unset($settings['custom_logo']); + + return [ + 'version' => '1.1', + 'exported_at' => now()->toIso8601String(), + 'hostname' => gethostname(), + 'settings' => $settings, + 'hosting_packages' => HostingPackage::query() + ->orderBy('name') + ->get() + ->map(fn (HostingPackage $package): array => [ + 'name' => $package->name, + 'description' => $package->description, + 'disk_quota_mb' => $package->disk_quota_mb, + 'bandwidth_gb' => $package->bandwidth_gb, + 'domains_limit' => $package->domains_limit, + 'databases_limit' => $package->databases_limit, + 'mailboxes_limit' => $package->mailboxes_limit, + 'is_active' => $package->is_active, + ]) + ->toArray(), + 'api_tokens' => $this->exportApiTokens(), + 'database_tuning' => $this->getDatabaseTuningSettings(), + ]; + } + + /** + * @return array> + */ + protected function exportApiTokens(): array + { + $adminUsers = User::query() + ->where('is_admin', true) + ->get(['id', 'email', 'username']); + + $tokens = []; + foreach ($adminUsers as $admin) { + foreach ($admin->tokens as $token) { + $tokens[] = [ + 'name' => $token->name, + 'token' => $token->token, + 'abilities' => $token->abilities ?? [], + 'last_used_at' => $token->last_used_at?->toIso8601String(), + 'expires_at' => $token->expires_at?->toIso8601String(), + 'created_at' => $token->created_at?->toIso8601String(), + 'owner_email' => $admin->email, + 'owner_username' => $admin->username, + ]; + } + } + + return $tokens; + } + + /** + * @return array + */ + protected function getDatabaseTuningSettings(): array + { + $paths = [ + '/etc/mysql/mariadb.conf.d/90-jabali-tuning.cnf', + '/etc/mysql/conf.d/90-jabali-tuning.cnf', + ]; + + foreach ($paths as $path) { + if (! file_exists($path)) { + continue; + } + + $lines = file($path, FILE_IGNORE_NEW_LINES) ?: []; + $settings = []; + foreach ($lines as $line) { + $line = trim($line); + if ($line === '' || str_starts_with($line, '#') || str_starts_with($line, '[')) { + continue; + } + + if (! str_contains($line, '=')) { + continue; + } + + [$key, $value] = array_map('trim', explode('=', $line, 2)); + if ($key !== '') { + $settings[$key] = $value; + } + } + + return $settings; + } + + return []; + } + + /** + * @param array> $packages + */ + protected function importHostingPackages(array $packages): int + { + $imported = 0; + foreach ($packages as $package) { + if (! is_array($package) || empty($package['name'])) { + continue; + } + + HostingPackage::updateOrCreate( + ['name' => $package['name']], + [ + 'description' => $package['description'] ?? '', + 'disk_quota_mb' => $package['disk_quota_mb'] ?? null, + 'bandwidth_gb' => $package['bandwidth_gb'] ?? null, + 'domains_limit' => $package['domains_limit'] ?? null, + 'databases_limit' => $package['databases_limit'] ?? null, + 'mailboxes_limit' => $package['mailboxes_limit'] ?? null, + 'is_active' => (bool) ($package['is_active'] ?? true), + ] + ); + $imported++; + } + + return $imported; + } + + /** + * @param array> $tokens + * @return array{imported:int,skipped:int} + */ + protected function importApiTokens(array $tokens): array + { + $imported = 0; + $skipped = 0; + + foreach ($tokens as $token) { + if (! is_array($token) || empty($token['token'])) { + $skipped++; + + continue; + } + + $ownerEmail = $token['owner_email'] ?? null; + $ownerUsername = $token['owner_username'] ?? null; + $owner = User::query() + ->where('is_admin', true) + ->when($ownerEmail, fn ($query) => $query->where('email', $ownerEmail)) + ->when(! $ownerEmail && $ownerUsername, fn ($query) => $query->where('username', $ownerUsername)) + ->first(); + + if (! $owner) { + $skipped++; + + continue; + } + + $tokenHash = $token['token']; + $exists = DB::table('personal_access_tokens')->where('token', $tokenHash)->exists(); + if ($exists) { + $skipped++; + + continue; + } + + DB::table('personal_access_tokens')->insert([ + 'tokenable_type' => User::class, + 'tokenable_id' => $owner->id, + 'name' => $token['name'] ?? 'API Token', + 'token' => $tokenHash, + 'abilities' => json_encode($token['abilities'] ?? []), + 'last_used_at' => $token['last_used_at'] ?? null, + 'expires_at' => $token['expires_at'] ?? null, + 'created_at' => $token['created_at'] ?? now()->toIso8601String(), + 'updated_at' => now()->toIso8601String(), + ]); + $imported++; + } + + return ['imported' => $imported, 'skipped' => $skipped]; + } + + /** + * @param array $tuning + * @return array{applied:int,failed:int} + */ + protected function applyDatabaseTuningFromImport(array $tuning): array + { + $applied = 0; + $failed = 0; + + foreach ($tuning as $name => $value) { + if (! is_string($name) || $name === '') { + $failed++; + + continue; + } + + try { + $agent = new AgentClient; + $result = $agent->databasePersistTuning($name, (string) $value); + if ($result['success'] ?? false) { + $applied++; + } else { + $failed++; + } + } catch (Exception $e) { + $failed++; + } + } + + return ['applied' => $applied, 'failed' => $failed]; + } } diff --git a/app/Filament/Admin/Resources/GeoBlockRules/Pages/ListGeoBlockRules.php b/app/Filament/Admin/Resources/GeoBlockRules/Pages/ListGeoBlockRules.php index a6a650a..57600fe 100644 --- a/app/Filament/Admin/Resources/GeoBlockRules/Pages/ListGeoBlockRules.php +++ b/app/Filament/Admin/Resources/GeoBlockRules/Pages/ListGeoBlockRules.php @@ -5,7 +5,13 @@ declare(strict_types=1); namespace App\Filament\Admin\Resources\GeoBlockRules\Pages; use App\Filament\Admin\Resources\GeoBlockRules\GeoBlockRuleResource; +use App\Models\DnsSetting; +use App\Services\Agent\AgentClient; +use Exception; +use Filament\Actions\Action; use Filament\Actions\CreateAction; +use Filament\Forms\Components\TextInput; +use Filament\Notifications\Notification; use Filament\Resources\Pages\ListRecords; class ListGeoBlockRules extends ListRecords @@ -15,6 +21,60 @@ class ListGeoBlockRules extends ListRecords protected function getHeaderActions(): array { return [ + Action::make('updateGeoIpDatabase') + ->label(__('Update GeoIP Database')) + ->icon('heroicon-o-arrow-down-tray') + ->form([ + TextInput::make('account_id') + ->label(__('MaxMind Account ID')) + ->required() + ->default(fn (): string => (string) (DnsSetting::get('geoip_account_id') ?? '')), + TextInput::make('license_key') + ->label(__('MaxMind License Key')) + ->password() + ->revealable() + ->required() + ->default(fn (): string => (string) (DnsSetting::get('geoip_license_key') ?? '')), + TextInput::make('edition_ids') + ->label(__('Edition IDs')) + ->helperText(__('Comma-separated (default: GeoLite2-Country)')) + ->default(fn (): string => (string) (DnsSetting::get('geoip_edition_ids') ?? 'GeoLite2-Country')), + ]) + ->action(function (array $data): void { + $accountId = trim((string) ($data['account_id'] ?? '')); + $licenseKey = trim((string) ($data['license_key'] ?? '')); + $editionIds = trim((string) ($data['edition_ids'] ?? 'GeoLite2-Country')); + + if ($accountId === '' || $licenseKey === '') { + Notification::make() + ->title(__('MaxMind credentials are required')) + ->danger() + ->send(); + + return; + } + + DnsSetting::set('geoip_account_id', $accountId); + DnsSetting::set('geoip_license_key', $licenseKey); + DnsSetting::set('geoip_edition_ids', $editionIds); + + try { + $agent = new AgentClient; + $result = $agent->geoUpdateDatabase($accountId, $licenseKey, $editionIds); + + Notification::make() + ->title(__('GeoIP database updated')) + ->body($result['path'] ?? null) + ->success() + ->send(); + } catch (Exception $e) { + Notification::make() + ->title(__('GeoIP update failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + }), CreateAction::make(), ]; } diff --git a/app/Services/Agent/AgentClient.php b/app/Services/Agent/AgentClient.php index da8a4bb..dbf0b83 100644 --- a/app/Services/Agent/AgentClient.php +++ b/app/Services/Agent/AgentClient.php @@ -1350,6 +1350,15 @@ class AgentClient ]); } + public function geoUpdateDatabase(string $accountId, string $licenseKey, string $editionIds = 'GeoLite2-Country'): array + { + return $this->send('geo.update_database', [ + 'account_id' => $accountId, + 'license_key' => $licenseKey, + 'edition_ids' => $editionIds, + ]); + } + public function databasePersistTuning(string $name, string $value): array { return $this->send('database.persist_tuning', [ @@ -1357,4 +1366,22 @@ class AgentClient 'value' => $value, ]); } + + /** + * @param array $names + */ + public function databaseGetVariables(array $names): array + { + return $this->send('database.get_variables', [ + 'names' => $names, + ]); + } + + public function databaseSetGlobal(string $name, string $value): array + { + return $this->send('database.set_global', [ + 'name' => $name, + 'value' => $value, + ]); + } } diff --git a/bin/jabali-agent b/bin/jabali-agent index 766e432..c6f9d38 100755 --- a/bin/jabali-agent +++ b/bin/jabali-agent @@ -547,7 +547,10 @@ function handleAction(array $request): array 'updates.run' => updatesRun($params), 'waf.apply' => wafApplySettings($params), 'geo.apply_rules' => geoApplyRules($params), + 'geo.update_database' => geoUpdateDatabase($params), 'database.persist_tuning' => databasePersistTuning($params), + 'database.get_variables' => databaseGetVariables($params), + 'database.set_global' => databaseSetGlobal($params), 'server.export_config' => serverExportConfig($params), 'server.import_config' => serverImportConfig($params), 'server.get_resolvers' => serverGetResolvers($params), @@ -2916,6 +2919,133 @@ function wafApplySettings(array $params): array return ['success' => true, 'enabled' => $enabled, 'paranoia' => $paranoia, 'audit_log' => $auditLog]; } +function geoUpdateDatabase(array $params): array +{ + $accountId = trim((string) ($params['account_id'] ?? '')); + $licenseKey = trim((string) ($params['license_key'] ?? '')); + $editionIdsRaw = $params['edition_ids'] ?? 'GeoLite2-Country'; + $useExisting = !empty($params['use_existing']); + + if (!toolExists('geoipupdate')) { + return ['success' => false, 'error' => 'geoipupdate is not installed']; + } + + if (!$useExisting && ($accountId === '' || $licenseKey === '')) { + return ['success' => false, 'error' => 'MaxMind Account ID and License Key are required']; + } + + $editionIds = []; + if (is_array($editionIdsRaw)) { + $editionIds = $editionIdsRaw; + } else { + $editionIds = preg_split('/[,\s]+/', (string) $editionIdsRaw, -1, PREG_SPLIT_NO_EMPTY) ?: []; + } + + $editionIds = array_values(array_filter(array_map('trim', $editionIds))); + if (empty($editionIds)) { + $editionIds = ['GeoLite2-Country']; + } + + $configLines = [ + '# Managed by Jabali', + 'AccountID ' . $accountId, + 'LicenseKey ' . $licenseKey, + 'EditionIDs ' . implode(' ', $editionIds), + 'DatabaseDirectory /usr/share/GeoIP', + ]; + $config = implode("\n", $configLines) . "\n"; + + if (!is_dir('/usr/share/GeoIP')) { + @mkdir('/usr/share/GeoIP', 0755, true); + } + + $configPaths = [ + '/etc/GeoIP.conf', + '/etc/geoipupdate/GeoIP.conf', + ]; + + foreach ($configPaths as $path) { + $dir = dirname($path); + if (!is_dir($dir)) { + @mkdir($dir, 0755, true); + } + + if (!$useExisting) { + file_put_contents($path, $config); + @chmod($path, 0600); + } elseif (!file_exists($path)) { + continue; + } + } + + exec('geoipupdate -v 2>&1', $output, $code); + $outputText = trim(implode("\n", $output)); + if ($code !== 0) { + return [ + 'success' => false, + 'error' => $outputText !== '' ? $outputText : 'geoipupdate failed', + ]; + } + + $paths = []; + foreach ($editionIds as $edition) { + $paths[] = '/usr/share/GeoIP/' . $edition . '.mmdb'; + $paths[] = '/usr/local/share/GeoIP/' . $edition . '.mmdb'; + } + + foreach ($paths as $path) { + if (file_exists($path)) { + return ['success' => true, 'path' => $path]; + } + } + + return ['success' => false, 'error' => 'GeoIP database not found after update']; +} + +function ensureGeoIpModuleEnabled(): ?string +{ + $modulePaths = [ + '/usr/lib/nginx/modules/ngx_http_geoip2_module.so', + '/usr/share/nginx/modules/ngx_http_geoip2_module.so', + ]; + + $modulePath = null; + foreach ($modulePaths as $path) { + if (file_exists($path)) { + $modulePath = $path; + break; + } + } + + if (!$modulePath) { + return 'nginx geoip2 module not installed'; + } + + $modulesEnabledDir = '/etc/nginx/modules-enabled'; + if (!is_dir($modulesEnabledDir)) { + return 'nginx modules-enabled directory not found'; + } + + $alreadyEnabled = false; + foreach (glob($modulesEnabledDir . '/*.conf') ?: [] as $file) { + $contents = file_get_contents($file); + if ($contents !== false && strpos($contents, 'geoip2_module') !== false) { + $alreadyEnabled = true; + break; + } + } + + if ($alreadyEnabled) { + return null; + } + + $loadLine = 'load_module ' . $modulePath . ';'; + $target = $modulesEnabledDir . '/50-jabali-geoip2.conf'; + file_put_contents($target, $loadLine . "\n"); + + return null; +} + function geoApplyRules(array $params): array { $rules = $params['rules'] ?? []; @@ -2959,13 +3089,22 @@ function geoApplyRules(array $params): array } if (!$mmdb) { - return ['success' => false, 'error' => 'GeoIP database not found']; + $update = geoUpdateDatabase([ + 'use_existing' => true, + 'edition_ids' => 'GeoLite2-Country', + ]); + if (!empty($update['success'])) { + $mmdb = $update['path'] ?? null; + } } - exec('nginx -V 2>&1', $versionOutput, $versionCode); - $versionText = implode(' ', $versionOutput); - if (strpos($versionText, 'http_geoip2_module') === false && strpos($versionText, 'ngx_http_geoip2_module') === false) { - return ['success' => false, 'error' => 'nginx geoip2 module not available']; + if (!$mmdb) { + return ['success' => false, 'error' => 'GeoIP database not found. Update the GeoIP database in the panel.']; + } + + $geoModule = ensureGeoIpModuleEnabled(); + if ($geoModule !== null) { + return ['success' => false, 'error' => $geoModule]; } $countryVar = '$jabali_geo_country_code'; @@ -4046,6 +4185,76 @@ function updateVhostServerNames(string $vhostFile, callable $mutator): array return ['success' => true]; } +function databaseGetVariables(array $params): array +{ + $names = $params['names'] ?? []; + if (!is_array($names) || $names === []) { + return ['success' => false, 'error' => 'No variables requested']; + } + + $safeNames = []; + foreach ($names as $name) { + if (preg_match('/^[a-zA-Z0-9_]+$/', (string) $name)) { + $safeNames[] = $name; + } + } + + if ($safeNames === []) { + return ['success' => false, 'error' => 'No valid variable names']; + } + + $inList = implode("','", array_map(fn($name) => str_replace("'", "\\'", (string) $name), $safeNames)); + $query = "SHOW VARIABLES WHERE Variable_name IN ('{$inList}')"; + $command = 'mysql --batch --skip-column-names -e ' . escapeshellarg($query) . ' 2>&1'; + + exec($command, $output, $code); + + if ($code !== 0) { + return ['success' => false, 'error' => implode("\n", $output)]; + } + + $variables = []; + foreach ($output as $line) { + $line = trim($line); + if ($line === '') { + continue; + } + $parts = explode("\t", $line, 2); + $name = $parts[0] ?? null; + if ($name === null || $name === '') { + continue; + } + $variables[] = [ + 'name' => $name, + 'value' => $parts[1] ?? '', + ]; + } + + return ['success' => true, 'variables' => $variables]; +} + +function databaseSetGlobal(array $params): array +{ + $name = $params['name'] ?? ''; + $value = (string) ($params['value'] ?? ''); + + if (!preg_match('/^[a-zA-Z0-9_]+$/', $name)) { + return ['success' => false, 'error' => 'Invalid variable name']; + } + + $escapedValue = addslashes($value); + $query = "SET GLOBAL {$name} = '{$escapedValue}'"; + $command = 'mysql -e ' . escapeshellarg($query) . ' 2>&1'; + + exec($command, $output, $code); + + if ($code !== 0) { + return ['success' => false, 'error' => implode("\n", $output)]; + } + + return ['success' => true]; +} + function domainAliasAdd(array $params): array { $username = $params['username'] ?? ''; diff --git a/install.sh b/install.sh index 8a13fcf..9595753 100755 --- a/install.sh +++ b/install.sh @@ -326,6 +326,19 @@ add_repositories() { # Detect codename local codename=$(lsb_release -sc) + # Ensure Debian contrib repository for geoipupdate and related packages + if [[ -f /etc/debian_version ]]; then + info "Ensuring Debian contrib repository..." + local contrib_list="/etc/apt/sources.list.d/jabali-contrib.list" + if [[ ! -f "$contrib_list" ]]; then + cat > "$contrib_list" <admin()->create(); + + $response = $this->actingAs($admin, 'admin')->get('/jabali-admin/geo-block-rules'); + + $response->assertStatus(200); + } +} diff --git a/tests/Feature/Filament/DatabaseTuningPageTest.php b/tests/Feature/Filament/DatabaseTuningPageTest.php new file mode 100644 index 0000000..8670958 --- /dev/null +++ b/tests/Feature/Filament/DatabaseTuningPageTest.php @@ -0,0 +1,60 @@ + true, + 'variables' => [ + ['name' => 'max_connections', 'value' => '200'], + ], + ]; + } + + if ($action === 'database.set_global') { + return ['success' => true]; + } + + if ($action === 'database.persist_tuning') { + return ['success' => true]; + } + + return ['success' => false]; + } + }; + } +} + +class DatabaseTuningPageTest extends TestCase +{ + use RefreshDatabase; + + public function test_database_tuning_loads_variables_from_agent(): void + { + $admin = User::factory()->admin()->create(); + + $this->actingAs($admin); + + Livewire::test(TestableDatabaseTuning::class) + ->call('loadVariables') + ->assertSet('variables.0.name', 'max_connections') + ->assertSet('variables.0.value', '200'); + } +} diff --git a/tests/Unit/ServerSettingsExportImportTest.php b/tests/Unit/ServerSettingsExportImportTest.php new file mode 100644 index 0000000..822e07d --- /dev/null +++ b/tests/Unit/ServerSettingsExportImportTest.php @@ -0,0 +1,105 @@ + 'Starter', + 'description' => 'Basic plan', + 'disk_quota_mb' => 1024, + 'bandwidth_gb' => 50, + 'domains_limit' => 1, + 'databases_limit' => 1, + 'mailboxes_limit' => 5, + 'is_active' => true, + ]); + + $admin = User::factory()->admin()->create([ + 'email' => 'admin@example.com', + 'username' => 'admin', + ]); + $admin->createToken('Automation', ['automation']); + + $page = new ServerSettings; + $payload = $page->buildExportPayload(); + + $this->assertSame('Jabali Test', $payload['settings']['panel_name'] ?? null); + $this->assertSame('Starter', $payload['hosting_packages'][0]['name'] ?? null); + $this->assertSame('Automation', $payload['api_tokens'][0]['name'] ?? null); + $this->assertSame('admin@example.com', $payload['api_tokens'][0]['owner_email'] ?? null); + $this->assertIsArray($payload['database_tuning']); + } + + public function test_import_creates_packages_and_tokens(): void + { + $admin = User::factory()->admin()->create([ + 'email' => 'admin@example.com', + 'username' => 'admin', + ]); + $tokenHash = hash('sha256', 'imported-token-value'); + + $payload = [ + 'version' => '1.1', + 'settings' => [ + 'panel_name' => 'Imported Panel', + ], + 'hosting_packages' => [ + [ + 'name' => 'Imported Starter', + 'description' => 'Imported', + 'disk_quota_mb' => 2048, + 'bandwidth_gb' => 100, + 'domains_limit' => 2, + 'databases_limit' => 2, + 'mailboxes_limit' => 10, + 'is_active' => true, + ], + ], + 'api_tokens' => [ + [ + 'name' => 'Imported Token', + 'token' => $tokenHash, + 'abilities' => ['automation'], + 'owner_email' => 'admin@example.com', + ], + ], + 'database_tuning' => [ + 'max_connections' => '200', + ], + ]; + + Storage::disk('local')->put('tests/jabali-import.json', json_encode($payload)); + + $page = new ServerSettings; + $page->importConfig(['config_file' => 'tests/jabali-import.json']); + + $this->assertDatabaseHas('hosting_packages', [ + 'name' => 'Imported Starter', + 'disk_quota_mb' => 2048, + ]); + + $this->assertDatabaseHas('personal_access_tokens', [ + 'name' => 'Imported Token', + 'tokenable_id' => $admin->id, + ]); + + $this->assertSame('Imported Panel', DnsSetting::get('panel_name')); + } +}