Add GeoIP update flow and improve settings export
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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<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
|
||||
{
|
||||
$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<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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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<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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
219
bin/jabali-agent
219
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'] ?? '';
|
||||
|
||||
15
install.sh
15
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" <<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)
|
||||
info "Using Sury PHP repository for $codename"
|
||||
|
||||
@@ -408,6 +421,8 @@ install_packages() {
|
||||
|
||||
# Security (always installed)
|
||||
fail2ban
|
||||
geoipupdate
|
||||
libnginx-mod-http-geoip2
|
||||
|
||||
# For screenshots (Puppeteer)
|
||||
chromium
|
||||
|
||||
23
tests/Feature/AdminGeoBlockRulesPageTest.php
Normal file
23
tests/Feature/AdminGeoBlockRulesPageTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
60
tests/Feature/Filament/DatabaseTuningPageTest.php
Normal file
60
tests/Feature/Filament/DatabaseTuningPageTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
105
tests/Unit/ServerSettingsExportImportTest.php
Normal file
105
tests/Unit/ServerSettingsExportImportTest.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user