Add GeoIP update flow and improve settings export

This commit is contained in:
root
2026-01-28 17:13:39 +02:00
parent 0aec48acaf
commit 1d1859cc58
10 changed files with 875 additions and 55 deletions

View File

@@ -1 +1 @@
VERSION=0.9-rc10 VERSION=0.9-rc11

View File

@@ -18,7 +18,6 @@ use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable; use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable; use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Facades\DB;
class DatabaseTuning extends Page implements HasActions, HasTable class DatabaseTuning extends Page implements HasActions, HasTable
{ {
@@ -50,6 +49,11 @@ class DatabaseTuning extends Page implements HasActions, HasTable
$this->loadVariables(); $this->loadVariables();
} }
protected function getAgent(): AgentClient
{
return app(AgentClient::class);
}
public function loadVariables(): void public function loadVariables(): void
{ {
$names = [ $names = [
@@ -62,19 +66,21 @@ class DatabaseTuning extends Page implements HasActions, HasTable
]; ];
try { try {
$placeholders = implode("','", $names); $result = $this->getAgent()->databaseGetVariables($names);
$rows = DB::connection('mysql')->select("SHOW VARIABLES WHERE Variable_name IN ('$placeholders')"); 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 [ return [
'name' => $row->Variable_name, 'name' => $row['name'] ?? '',
'value' => $row->Value, 'value' => $row['value'] ?? '',
]; ];
})->toArray(); })->toArray();
} catch (\Exception $e) { } catch (\Exception $e) {
$this->variables = []; $this->variables = [];
Notification::make() Notification::make()
->title(__('Unable to load MySQL variables')) ->title(__('Unable to load database variables'))
->body($e->getMessage()) ->body($e->getMessage())
->warning() ->warning()
->send(); ->send();
@@ -106,20 +112,31 @@ class DatabaseTuning extends Page implements HasActions, HasTable
]) ])
->action(function (array $record, array $data): void { ->action(function (array $record, array $data): void {
try { try {
DB::connection('mysql')->statement('SET GLOBAL '.$record['name'].' = ?', [$data['value']]);
try { try {
$agent = new AgentClient; $agent = $this->getAgent();
$agent->databasePersistTuning($record['name'], (string) $data['value']); $setResult = $agent->databaseSetGlobal($record['name'], (string) $data['value']);
if (! ($setResult['success'] ?? false)) {
throw new \RuntimeException($setResult['error'] ?? __('Update failed'));
}
Notification::make() $persistResult = $agent->databasePersistTuning($record['name'], (string) $data['value']);
->title(__('Variable updated')) if (! ($persistResult['success'] ?? false)) {
->success() Notification::make()
->send(); ->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) { } catch (\Exception $e) {
Notification::make() Notification::make()
->title(__('Variable updated, but not persisted')) ->title(__('Update failed'))
->body($e->getMessage()) ->body($e->getMessage())
->warning() ->danger()
->send(); ->send();
} }
} catch (\Exception $e) { } catch (\Exception $e) {

View File

@@ -7,6 +7,8 @@ namespace App\Filament\Admin\Pages;
use App\Filament\Admin\Widgets\Settings\DnssecTable; use App\Filament\Admin\Widgets\Settings\DnssecTable;
use App\Filament\Admin\Widgets\Settings\NotificationLogTable; use App\Filament\Admin\Widgets\Settings\NotificationLogTable;
use App\Models\DnsSetting; use App\Models\DnsSetting;
use App\Models\HostingPackage;
use App\Models\User;
use App\Services\Agent\AgentClient; use App\Services\Agent\AgentClient;
use BackedEnum; use BackedEnum;
use Exception; use Exception;
@@ -29,6 +31,7 @@ use Filament\Schemas\Components\View;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Illuminate\Contracts\Support\Htmlable; use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Response; use Illuminate\Support\Facades\Response;
@@ -164,6 +167,9 @@ class ServerSettings extends Page implements HasActions, HasForms
} }
$resolvers = [$ns[0] ?? '', $ns[1] ?? '', $ns[2] ?? '']; $resolvers = [$ns[0] ?? '', $ns[1] ?? '', $ns[2] ?? ''];
} }
if (trim($searchDomain) === 'example.com') {
$searchDomain = '';
}
// Fill form data // Fill form data
$this->brandingData = [ $this->brandingData = [
@@ -381,6 +387,17 @@ class ServerSettings extends Page implements HasActions, HasForms
->description($this->isSystemdResolved ? __('systemd-resolved active') : null) ->description($this->isSystemdResolved ? __('systemd-resolved active') : null)
->icon('heroicon-o-signal') ->icon('heroicon-o-signal')
->schema([ ->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([ Grid::make(['default' => 1, 'md' => 2, 'lg' => 4])->schema([
TextInput::make('resolversData.resolver1')->label(__('Resolver 1'))->placeholder('8.8.8.8'), TextInput::make('resolversData.resolver1')->label(__('Resolver 1'))->placeholder('8.8.8.8'),
TextInput::make('resolversData.resolver2')->label(__('Resolver 2'))->placeholder('8.8.4.4'), 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 protected function storageTabContent(): array
{ {
return [ return [
@@ -504,7 +548,7 @@ class ServerSettings extends Page implements HasActions, HasForms
->placeholder('admin@example.com, alerts@example.com') ->placeholder('admin@example.com, alerts@example.com')
->helperText(__('Comma-separated list of email addresses to receive notifications')), ->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') ->icon('heroicon-o-bell-alert')
->schema([ ->schema([
Grid::make(['default' => 1, 'md' => 2])->schema([ Grid::make(['default' => 1, 'md' => 2])->schema([
@@ -533,10 +577,6 @@ class ServerSettings extends Page implements HasActions, HasForms
->label(__('Service Health Alerts')) ->label(__('Service Health Alerts'))
->helperText(__('Service failures and auto-restarts')), ->helperText(__('Service failures and auto-restarts')),
]), ]),
]),
Section::make(__('High Load Alerts'))
->icon('heroicon-o-cpu-chip')
->schema([
Grid::make(['default' => 1, 'md' => 3])->schema([ Grid::make(['default' => 1, 'md' => 3])->schema([
Toggle::make('notificationsData.notify_high_load') Toggle::make('notificationsData.notify_high_load')
->label(__('Enable High Load Alerts')) ->label(__('Enable High Load Alerts'))
@@ -906,19 +946,14 @@ class ServerSettings extends Page implements HasActions, HasForms
public function saveEmailNotificationSettings(): void public function saveEmailNotificationSettings(): void
{ {
$data = $this->notificationsData; $data = $this->notificationsData;
$emails = $this->parseNotificationRecipients($data['admin_email_recipients'] ?? '', false);
if (! empty($data['admin_email_recipients'])) { if ($emails === null) {
$emails = array_map('trim', explode(',', $data['admin_email_recipients'])); return;
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;
}
}
} }
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_ssl_errors', $data['notify_ssl_errors'] ? '1' : '0');
DnsSetting::set('notify_backup_failures', $data['notify_backup_failures'] ? '1' : '0'); DnsSetting::set('notify_backup_failures', $data['notify_backup_failures'] ? '1' : '0');
DnsSetting::set('notify_backup_success', $data['notify_backup_success'] ? '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 public function sendTestEmail(): void
{ {
$recipients = $this->notificationsData['admin_email_recipients'] ?? ''; $recipients = $this->notificationsData['admin_email_recipients'] ?? '';
$recipientList = $this->parseNotificationRecipients($recipients, true);
if (empty($recipients)) { if ($recipientList === null) {
Notification::make()->title(__('No recipients configured'))->body(__('Please add at least one admin email address'))->warning()->send();
return; return;
} }
try { try {
$recipientList = array_map('trim', explode(',', $recipients));
$hostname = gethostname() ?: 'localhost'; $hostname = gethostname() ?: 'localhost';
$sender = "webmaster@{$hostname}"; $sender = "webmaster@{$hostname}";
$subject = __('Test Email'); $subject = __('Test Email');
@@ -988,6 +1020,50 @@ class ServerSettings extends Page implements HasActions, HasForms
} }
} }
/**
* @return array<int, string>|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 public function saveFpmSettings(): void
{ {
$data = $this->phpFpmData; $data = $this->phpFpmData;
@@ -1160,15 +1236,7 @@ class ServerSettings extends Page implements HasActions, HasForms
public function exportConfig(): \Symfony\Component\HttpFoundation\StreamedResponse public function exportConfig(): \Symfony\Component\HttpFoundation\StreamedResponse
{ {
$settings = DnsSetting::getAll(); $exportData = $this->buildExportPayload();
unset($settings['custom_logo']);
$exportData = [
'version' => '1.0',
'exported_at' => now()->toIso8601String(),
'hostname' => gethostname(),
'settings' => $settings,
];
$filename = 'jabali-config-'.date('Y-m-d-His').'.json'; $filename = 'jabali-config-'.date('Y-m-d-His').'.json';
$content = json_encode($exportData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); $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')); throw new Exception(__('Invalid configuration file format'));
} }
$imported = 0; $importedSettings = 0;
foreach ($importData['settings'] as $key => $value) { foreach ($importData['settings'] as $key => $value) {
if (in_array($key, ['custom_logo'])) { if (in_array($key, ['custom_logo'])) {
continue; continue;
} }
DnsSetting::set($key, $value); 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(); DnsSetting::clearCache();
Storage::disk('local')->delete($data['config_file']); Storage::disk('local')->delete($data['config_file']);
$this->mount(); $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) { } catch (Exception $e) {
Notification::make()->title(__('Import failed'))->body($e->getMessage())->danger()->send(); Notification::make()->title(__('Import failed'))->body($e->getMessage())->danger()->send();
} }
} }
/**
* @return array<string, mixed>
*/
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<int, array<string, mixed>>
*/
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<string, string>
*/
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<int, array<string, mixed>> $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<int, array<string, mixed>> $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<string, string> $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];
}
} }

View File

@@ -5,7 +5,13 @@ declare(strict_types=1);
namespace App\Filament\Admin\Resources\GeoBlockRules\Pages; namespace App\Filament\Admin\Resources\GeoBlockRules\Pages;
use App\Filament\Admin\Resources\GeoBlockRules\GeoBlockRuleResource; 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\Actions\CreateAction;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
class ListGeoBlockRules extends ListRecords class ListGeoBlockRules extends ListRecords
@@ -15,6 +21,60 @@ class ListGeoBlockRules extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ 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(), CreateAction::make(),
]; ];
} }

View File

@@ -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 public function databasePersistTuning(string $name, string $value): array
{ {
return $this->send('database.persist_tuning', [ return $this->send('database.persist_tuning', [
@@ -1357,4 +1366,22 @@ class AgentClient
'value' => $value, 'value' => $value,
]); ]);
} }
/**
* @param array<int, string> $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,
]);
}
} }

View File

@@ -547,7 +547,10 @@ function handleAction(array $request): array
'updates.run' => updatesRun($params), 'updates.run' => updatesRun($params),
'waf.apply' => wafApplySettings($params), 'waf.apply' => wafApplySettings($params),
'geo.apply_rules' => geoApplyRules($params), 'geo.apply_rules' => geoApplyRules($params),
'geo.update_database' => geoUpdateDatabase($params),
'database.persist_tuning' => databasePersistTuning($params), 'database.persist_tuning' => databasePersistTuning($params),
'database.get_variables' => databaseGetVariables($params),
'database.set_global' => databaseSetGlobal($params),
'server.export_config' => serverExportConfig($params), 'server.export_config' => serverExportConfig($params),
'server.import_config' => serverImportConfig($params), 'server.import_config' => serverImportConfig($params),
'server.get_resolvers' => serverGetResolvers($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]; 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 function geoApplyRules(array $params): array
{ {
$rules = $params['rules'] ?? []; $rules = $params['rules'] ?? [];
@@ -2959,13 +3089,22 @@ function geoApplyRules(array $params): array
} }
if (!$mmdb) { 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); if (!$mmdb) {
$versionText = implode(' ', $versionOutput); return ['success' => false, 'error' => 'GeoIP database not found. Update the GeoIP database in the panel.'];
if (strpos($versionText, 'http_geoip2_module') === false && strpos($versionText, 'ngx_http_geoip2_module') === false) { }
return ['success' => false, 'error' => 'nginx geoip2 module not available'];
$geoModule = ensureGeoIpModuleEnabled();
if ($geoModule !== null) {
return ['success' => false, 'error' => $geoModule];
} }
$countryVar = '$jabali_geo_country_code'; $countryVar = '$jabali_geo_country_code';
@@ -4046,6 +4185,76 @@ function updateVhostServerNames(string $vhostFile, callable $mutator): array
return ['success' => true]; 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 function domainAliasAdd(array $params): array
{ {
$username = $params['username'] ?? ''; $username = $params['username'] ?? '';

View File

@@ -326,6 +326,19 @@ add_repositories() {
# Detect codename # Detect codename
local codename=$(lsb_release -sc) 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" <<EOF
deb https://deb.debian.org/debian ${codename} contrib
deb https://deb.debian.org/debian ${codename}-updates contrib
deb https://security.debian.org/debian-security ${codename}-security contrib
EOF
fi
fi
# Sury supports: bookworm, bullseye, buster, trixie (Debian) and jammy, focal, noble (Ubuntu) # Sury supports: bookworm, bullseye, buster, trixie (Debian) and jammy, focal, noble (Ubuntu)
info "Using Sury PHP repository for $codename" info "Using Sury PHP repository for $codename"
@@ -408,6 +421,8 @@ install_packages() {
# Security (always installed) # Security (always installed)
fail2ban fail2ban
geoipupdate
libnginx-mod-http-geoip2
# For screenshots (Puppeteer) # For screenshots (Puppeteer)
chromium chromium

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AdminGeoBlockRulesPageTest extends TestCase
{
use RefreshDatabase;
public function test_geo_block_rules_page_is_accessible(): void
{
$admin = User::factory()->admin()->create();
$response = $this->actingAs($admin, 'admin')->get('/jabali-admin/geo-block-rules');
$response->assertStatus(200);
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Filament;
use App\Filament\Admin\Pages\DatabaseTuning;
use App\Models\User;
use App\Services\Agent\AgentClient;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class TestableDatabaseTuning extends DatabaseTuning
{
protected function getAgent(): AgentClient
{
return new class extends AgentClient
{
public function send(string $action, array $params = []): array
{
if ($action === 'database.get_variables') {
return [
'success' => 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');
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use App\Filament\Admin\Pages\ServerSettings;
use App\Models\DnsSetting;
use App\Models\HostingPackage;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class ServerSettingsExportImportTest extends TestCase
{
use RefreshDatabase;
public function test_export_includes_settings_packages_tokens_and_tuning(): void
{
DnsSetting::set('panel_name', 'Jabali Test');
HostingPackage::create([
'name' => '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'));
}
}