Compare commits
2 Commits
be0ec33ecd
...
12670f3546
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12670f3546 | ||
|
|
a3e7da7275 |
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
A modern web hosting control panel for WordPress and general PHP hosting. Built with Laravel 12, Filament v5, Livewire 4, and Tailwind CSS v4.
|
A modern web hosting control panel for WordPress and general PHP hosting. Built with Laravel 12, Filament v5, Livewire 4, and Tailwind CSS v4.
|
||||||
|
|
||||||
Version: 0.9-rc32 (release candidate)
|
Version: 0.9-rc34 (release candidate)
|
||||||
|
|
||||||
This is a release candidate. Expect rapid iteration and breaking changes until 1.0.
|
This is a release candidate. Expect rapid iteration and breaking changes until 1.0.
|
||||||
|
|
||||||
@@ -156,6 +156,8 @@ php artisan test --compact
|
|||||||
|
|
||||||
## Initial Release
|
## Initial Release
|
||||||
|
|
||||||
|
- 0.9-rc34: User deletion summary steps; notification re-dispatch on repeated actions; ModSecurity packages added to installer.
|
||||||
|
- 0.9-rc33: Email Logs unified with Mail Queue; journald fallback; agent response reading hardened.
|
||||||
- 0.9-rc32: Server Updates list loads reliably; admin sidebar order aligned; apt update parsing expanded.
|
- 0.9-rc32: Server Updates list loads reliably; admin sidebar order aligned; apt update parsing expanded.
|
||||||
- 0.9-rc31: File manager navigation uses Livewire actions; parent row excluded from bulk select.
|
- 0.9-rc31: File manager navigation uses Livewire actions; parent row excluded from bulk select.
|
||||||
- 0.9-rc30: Avoid IncludeOptional in ModSecurity CRS includes.
|
- 0.9-rc30: Avoid IncludeOptional in ModSecurity CRS includes.
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class AutomationApi extends Page implements HasActions, HasTable
|
|||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedKey;
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedKey;
|
||||||
|
|
||||||
protected static ?int $navigationSort = 16;
|
protected static ?int $navigationSort = 17;
|
||||||
|
|
||||||
protected static ?string $slug = 'automation-api';
|
protected static ?string $slug = 'automation-api';
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class DatabaseTuning extends Page implements HasActions, HasTable
|
|||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedCircleStack;
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedCircleStack;
|
||||||
|
|
||||||
protected static ?int $navigationSort = 18;
|
protected static ?int $navigationSort = 19;
|
||||||
|
|
||||||
protected static ?string $slug = 'database-tuning';
|
protected static ?string $slug = 'database-tuning';
|
||||||
|
|
||||||
|
|||||||
395
app/Filament/Admin/Pages/EmailLogs.php
Normal file
395
app/Filament/Admin/Pages/EmailLogs.php
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Admin\Pages;
|
||||||
|
|
||||||
|
use App\Services\Agent\AgentClient;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\Concerns\InteractsWithActions;
|
||||||
|
use Filament\Actions\Contracts\HasActions;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
|
use Filament\Tables\Contracts\HasTable;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Contracts\Support\Htmlable;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class EmailLogs extends Page implements HasActions, HasTable
|
||||||
|
{
|
||||||
|
use InteractsWithActions;
|
||||||
|
use InteractsWithTable;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedInbox;
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 14;
|
||||||
|
|
||||||
|
protected static ?string $slug = 'email-logs';
|
||||||
|
|
||||||
|
protected string $view = 'filament.admin.pages.email-logs';
|
||||||
|
|
||||||
|
public string $viewMode = 'logs';
|
||||||
|
|
||||||
|
public array $logs = [];
|
||||||
|
|
||||||
|
public array $queueItems = [];
|
||||||
|
|
||||||
|
protected ?AgentClient $agent = null;
|
||||||
|
|
||||||
|
protected bool $logsLoaded = false;
|
||||||
|
|
||||||
|
protected bool $queueLoaded = false;
|
||||||
|
|
||||||
|
public function getTitle(): string|Htmlable
|
||||||
|
{
|
||||||
|
return __('Email Logs');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
|
{
|
||||||
|
return __('Email Logs');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->loadLogs(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getAgent(): AgentClient
|
||||||
|
{
|
||||||
|
return $this->agent ??= new AgentClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadLogs(bool $refreshTable = true): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$result = $this->getAgent()->send('email.get_logs', [
|
||||||
|
'limit' => 200,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->logs = $result['logs'] ?? [];
|
||||||
|
$this->logsLoaded = true;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->logs = [];
|
||||||
|
$this->logsLoaded = true;
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Failed to load email logs'))
|
||||||
|
->body($e->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($refreshTable) {
|
||||||
|
$this->resetTable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadQueue(bool $refreshTable = true): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$result = $this->getAgent()->send('mail.queue_list');
|
||||||
|
$this->queueItems = $result['queue'] ?? [];
|
||||||
|
$this->queueLoaded = true;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->queueItems = [];
|
||||||
|
$this->queueLoaded = true;
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Failed to load mail queue'))
|
||||||
|
->body($e->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($refreshTable) {
|
||||||
|
$this->resetTable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setViewMode(string $mode): void
|
||||||
|
{
|
||||||
|
$mode = in_array($mode, ['logs', 'queue'], true) ? $mode : 'logs';
|
||||||
|
if ($this->viewMode === $mode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->viewMode = $mode;
|
||||||
|
|
||||||
|
if ($mode === 'queue') {
|
||||||
|
$this->loadQueue(false);
|
||||||
|
} else {
|
||||||
|
$this->loadLogs(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->resetTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->paginated([25, 50, 100])
|
||||||
|
->defaultPaginationPageOption(25)
|
||||||
|
->records(function (?array $filters, ?string $search, int|string $page, int|string $recordsPerPage, ?string $sortColumn, ?string $sortDirection) {
|
||||||
|
if ($this->viewMode === 'queue') {
|
||||||
|
if (! $this->queueLoaded) {
|
||||||
|
$this->loadQueue(false);
|
||||||
|
}
|
||||||
|
$records = $this->queueItems;
|
||||||
|
} else {
|
||||||
|
if (! $this->logsLoaded) {
|
||||||
|
$this->loadLogs(false);
|
||||||
|
}
|
||||||
|
$records = $this->logs;
|
||||||
|
}
|
||||||
|
|
||||||
|
$records = $this->filterRecords($records, $search);
|
||||||
|
$records = $this->sortRecords($records, $sortColumn, $sortDirection);
|
||||||
|
|
||||||
|
return $this->paginateRecords($records, $page, $recordsPerPage);
|
||||||
|
})
|
||||||
|
->columns($this->viewMode === 'queue' ? $this->getQueueColumns() : $this->getLogColumns())
|
||||||
|
->recordActions($this->viewMode === 'queue' ? $this->getQueueActions() : [])
|
||||||
|
->emptyStateHeading($this->viewMode === 'queue' ? __('Mail queue is empty') : __('No email logs found'))
|
||||||
|
->emptyStateDescription($this->viewMode === 'queue' ? __('No deferred messages found.') : __('Mail logs are empty or unavailable.'))
|
||||||
|
->headerActions([
|
||||||
|
Action::make('viewLogs')
|
||||||
|
->label(__('Logs'))
|
||||||
|
->color($this->viewMode === 'logs' ? 'primary' : 'gray')
|
||||||
|
->action(fn () => $this->setViewMode('logs')),
|
||||||
|
Action::make('viewQueue')
|
||||||
|
->label(__('Queue'))
|
||||||
|
->color($this->viewMode === 'queue' ? 'primary' : 'gray')
|
||||||
|
->action(fn () => $this->setViewMode('queue')),
|
||||||
|
Action::make('refresh')
|
||||||
|
->label(__('Refresh'))
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->action(function (): void {
|
||||||
|
if ($this->viewMode === 'queue') {
|
||||||
|
$this->loadQueue();
|
||||||
|
} else {
|
||||||
|
$this->loadLogs();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getLogColumns(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
TextColumn::make('timestamp')
|
||||||
|
->label(__('Time'))
|
||||||
|
->formatStateUsing(function (array $record): string {
|
||||||
|
$timestamp = (int) ($record['timestamp'] ?? 0);
|
||||||
|
|
||||||
|
return $timestamp > 0 ? date('Y-m-d H:i:s', $timestamp) : '';
|
||||||
|
})
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('queue_id')
|
||||||
|
->label(__('Queue ID'))
|
||||||
|
->fontFamily('mono')
|
||||||
|
->copyable()
|
||||||
|
->toggleable(),
|
||||||
|
TextColumn::make('component')
|
||||||
|
->label(__('Component'))
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
TextColumn::make('from')
|
||||||
|
->label(__('From'))
|
||||||
|
->wrap()
|
||||||
|
->searchable(),
|
||||||
|
TextColumn::make('to')
|
||||||
|
->label(__('To'))
|
||||||
|
->wrap()
|
||||||
|
->searchable(),
|
||||||
|
TextColumn::make('status')
|
||||||
|
->label(__('Status'))
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(fn (array $record): string => (string) ($record['status'] ?? 'unknown')),
|
||||||
|
TextColumn::make('relay')
|
||||||
|
->label(__('Relay'))
|
||||||
|
->toggleable(),
|
||||||
|
TextColumn::make('message')
|
||||||
|
->label(__('Details'))
|
||||||
|
->wrap()
|
||||||
|
->limit(80)
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getQueueColumns(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
TextColumn::make('id')
|
||||||
|
->label(__('Queue ID'))
|
||||||
|
->fontFamily('mono')
|
||||||
|
->copyable(),
|
||||||
|
TextColumn::make('arrival')
|
||||||
|
->label(__('Arrival')),
|
||||||
|
TextColumn::make('sender')
|
||||||
|
->label(__('Sender'))
|
||||||
|
->wrap()
|
||||||
|
->searchable(),
|
||||||
|
TextColumn::make('recipients')
|
||||||
|
->label(__('Recipients'))
|
||||||
|
->formatStateUsing(function ($state): string {
|
||||||
|
if (is_array($state)) {
|
||||||
|
$recipients = $state;
|
||||||
|
} elseif ($state === null || $state === '') {
|
||||||
|
$recipients = [];
|
||||||
|
} else {
|
||||||
|
$recipients = [(string) $state];
|
||||||
|
}
|
||||||
|
|
||||||
|
$recipients = array_values(array_filter(array_map(function ($recipient): ?string {
|
||||||
|
if (is_array($recipient)) {
|
||||||
|
return (string) ($recipient['address']
|
||||||
|
?? $recipient['recipient']
|
||||||
|
?? $recipient['email']
|
||||||
|
?? $recipient[0]
|
||||||
|
?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $recipient === null ? null : (string) $recipient;
|
||||||
|
}, $recipients), static fn (?string $value): bool => $value !== null && $value !== ''));
|
||||||
|
|
||||||
|
if (empty($recipients)) {
|
||||||
|
return __('Unknown');
|
||||||
|
}
|
||||||
|
|
||||||
|
$first = $recipients[0] ?? '';
|
||||||
|
$count = count($recipients);
|
||||||
|
|
||||||
|
return $count > 1 ? $first.' +'.($count - 1) : $first;
|
||||||
|
})
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('size')
|
||||||
|
->label(__('Size'))
|
||||||
|
->formatStateUsing(fn ($state): string => is_scalar($state) ? (string) $state : ''),
|
||||||
|
TextColumn::make('status')
|
||||||
|
->label(__('Status'))
|
||||||
|
->wrap(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getQueueActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Action::make('retry')
|
||||||
|
->label(__('Retry'))
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->color('info')
|
||||||
|
->action(function (array $record): void {
|
||||||
|
try {
|
||||||
|
$result = $this->getAgent()->send('mail.queue_retry', ['id' => $record['id'] ?? '']);
|
||||||
|
if ($result['success'] ?? false) {
|
||||||
|
Notification::make()->title(__('Message retried'))->success()->send();
|
||||||
|
$this->loadQueue();
|
||||||
|
} else {
|
||||||
|
throw new \Exception($result['error'] ?? __('Failed to retry message'));
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Notification::make()->title(__('Retry failed'))->body($e->getMessage())->danger()->send();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
Action::make('delete')
|
||||||
|
->label(__('Delete'))
|
||||||
|
->icon('heroicon-o-trash')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(function (array $record): void {
|
||||||
|
try {
|
||||||
|
$result = $this->getAgent()->send('mail.queue_delete', ['id' => $record['id'] ?? '']);
|
||||||
|
if ($result['success'] ?? false) {
|
||||||
|
Notification::make()->title(__('Message deleted'))->success()->send();
|
||||||
|
$this->loadQueue();
|
||||||
|
} else {
|
||||||
|
throw new \Exception($result['error'] ?? __('Failed to delete message'));
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Notification::make()->title(__('Delete failed'))->body($e->getMessage())->danger()->send();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function filterRecords(array $records, ?string $search): array
|
||||||
|
{
|
||||||
|
$search = trim((string) $search);
|
||||||
|
if ($search === '') {
|
||||||
|
return $records;
|
||||||
|
}
|
||||||
|
|
||||||
|
$search = Str::lower($search);
|
||||||
|
|
||||||
|
return array_values(array_filter($records, function (array $record) use ($search): bool {
|
||||||
|
if ($this->viewMode === 'queue') {
|
||||||
|
$recipients = $record['recipients'] ?? [];
|
||||||
|
$haystack = implode(' ', array_filter([
|
||||||
|
(string) ($record['id'] ?? ''),
|
||||||
|
(string) ($record['sender'] ?? ''),
|
||||||
|
implode(' ', $recipients),
|
||||||
|
(string) ($record['status'] ?? ''),
|
||||||
|
]));
|
||||||
|
} else {
|
||||||
|
$haystack = implode(' ', array_filter([
|
||||||
|
(string) ($record['queue_id'] ?? ''),
|
||||||
|
(string) ($record['from'] ?? ''),
|
||||||
|
(string) ($record['to'] ?? ''),
|
||||||
|
(string) ($record['status'] ?? ''),
|
||||||
|
(string) ($record['message'] ?? ''),
|
||||||
|
(string) ($record['component'] ?? ''),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return str_contains(Str::lower($haystack), $search);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function sortRecords(array $records, ?string $sortColumn, ?string $sortDirection): array
|
||||||
|
{
|
||||||
|
$direction = $sortDirection === 'asc' ? 'asc' : 'desc';
|
||||||
|
|
||||||
|
if (! $sortColumn) {
|
||||||
|
return $records;
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($records, function (array $a, array $b) use ($sortColumn, $direction): int {
|
||||||
|
$aValue = $a[$sortColumn] ?? null;
|
||||||
|
$bValue = $b[$sortColumn] ?? null;
|
||||||
|
|
||||||
|
if (is_numeric($aValue) && is_numeric($bValue)) {
|
||||||
|
$result = (float) $aValue <=> (float) $bValue;
|
||||||
|
} else {
|
||||||
|
$result = strcmp((string) $aValue, (string) $bValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $direction === 'asc' ? $result : -$result;
|
||||||
|
});
|
||||||
|
|
||||||
|
return $records;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function paginateRecords(array $records, int|string $page, int|string $recordsPerPage): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$page = max(1, (int) $page);
|
||||||
|
$perPage = max(1, (int) $recordsPerPage);
|
||||||
|
|
||||||
|
$total = count($records);
|
||||||
|
$items = array_slice($records, ($page - 1) * $perPage, $perPage);
|
||||||
|
|
||||||
|
return new LengthAwarePaginator(
|
||||||
|
$items,
|
||||||
|
$total,
|
||||||
|
$perPage,
|
||||||
|
$page,
|
||||||
|
[
|
||||||
|
'path' => request()->url(),
|
||||||
|
'pageName' => $this->getTablePaginationPageName(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,16 +25,20 @@ class EmailQueue extends Page implements HasActions, HasTable
|
|||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedQueueList;
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedQueueList;
|
||||||
|
|
||||||
protected static ?int $navigationSort = 14;
|
protected static ?int $navigationSort = null;
|
||||||
|
|
||||||
protected static ?string $slug = 'email-queue';
|
protected static ?string $slug = 'email-queue';
|
||||||
|
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
protected string $view = 'filament.admin.pages.email-queue';
|
protected string $view = 'filament.admin.pages.email-queue';
|
||||||
|
|
||||||
public array $queueItems = [];
|
public array $queueItems = [];
|
||||||
|
|
||||||
protected ?AgentClient $agent = null;
|
protected ?AgentClient $agent = null;
|
||||||
|
|
||||||
|
protected bool $queueLoaded = false;
|
||||||
|
|
||||||
public function getTitle(): string|Htmlable
|
public function getTitle(): string|Htmlable
|
||||||
{
|
{
|
||||||
return __('Email Queue Manager');
|
return __('Email Queue Manager');
|
||||||
@@ -47,7 +51,7 @@ class EmailQueue extends Page implements HasActions, HasTable
|
|||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$this->loadQueue();
|
$this->redirect(EmailLogs::getUrl());
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getAgent(): AgentClient
|
protected function getAgent(): AgentClient
|
||||||
@@ -55,13 +59,15 @@ class EmailQueue extends Page implements HasActions, HasTable
|
|||||||
return $this->agent ??= new AgentClient;
|
return $this->agent ??= new AgentClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function loadQueue(): void
|
public function loadQueue(bool $refreshTable = true): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$result = $this->getAgent()->send('mail.queue_list');
|
$result = $this->getAgent()->send('mail.queue_list');
|
||||||
$this->queueItems = $result['queue'] ?? [];
|
$this->queueItems = $result['queue'] ?? [];
|
||||||
|
$this->queueLoaded = true;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->queueItems = [];
|
$this->queueItems = [];
|
||||||
|
$this->queueLoaded = true;
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title(__('Failed to load mail queue'))
|
->title(__('Failed to load mail queue'))
|
||||||
->body($e->getMessage())
|
->body($e->getMessage())
|
||||||
@@ -69,13 +75,27 @@ class EmailQueue extends Page implements HasActions, HasTable
|
|||||||
->send();
|
->send();
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->resetTable();
|
if ($refreshTable) {
|
||||||
|
$this->resetTable();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
->records(fn () => $this->queueItems)
|
->records(function () {
|
||||||
|
if (! $this->queueLoaded) {
|
||||||
|
$this->loadQueue(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return collect($this->queueItems)
|
||||||
|
->mapWithKeys(function (array $record, int $index): array {
|
||||||
|
$key = $record['id'] ?? (string) $index;
|
||||||
|
|
||||||
|
return [$key !== '' ? $key : (string) $index => $record];
|
||||||
|
})
|
||||||
|
->all();
|
||||||
|
})
|
||||||
->columns([
|
->columns([
|
||||||
TextColumn::make('id')
|
TextColumn::make('id')
|
||||||
->label(__('Queue ID'))
|
->label(__('Queue ID'))
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class ServerUpdates extends Page implements HasActions, HasTable
|
|||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedArrowPathRoundedSquare;
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedArrowPathRoundedSquare;
|
||||||
|
|
||||||
protected static ?int $navigationSort = 15;
|
protected static ?int $navigationSort = 16;
|
||||||
|
|
||||||
protected static ?string $slug = 'server-updates';
|
protected static ?string $slug = 'server-updates';
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class Waf extends Page implements HasForms
|
|||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedShieldCheck;
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedShieldCheck;
|
||||||
|
|
||||||
protected static ?int $navigationSort = 19;
|
protected static ?int $navigationSort = 20;
|
||||||
|
|
||||||
protected static ?string $slug = 'waf';
|
protected static ?string $slug = 'waf';
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class GeoBlockRuleResource extends Resource
|
|||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedGlobeAlt;
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedGlobeAlt;
|
||||||
|
|
||||||
protected static ?int $navigationSort = 20;
|
protected static ?int $navigationSort = 21;
|
||||||
|
|
||||||
public static function getNavigationLabel(): string
|
public static function getNavigationLabel(): string
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -96,22 +96,22 @@ class EditUser extends EditRecord
|
|||||||
->action(function (array $data) {
|
->action(function (array $data) {
|
||||||
$removeHome = $data['remove_home'] ?? false;
|
$removeHome = $data['remove_home'] ?? false;
|
||||||
$username = $this->record->username;
|
$username = $this->record->username;
|
||||||
|
$steps = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$linuxService = new LinuxUserService;
|
$linuxService = new LinuxUserService;
|
||||||
|
$domains = $this->record->domains()->pluck('domain')->all();
|
||||||
|
|
||||||
if ($linuxService->userExists($username)) {
|
if ($linuxService->userExists($username)) {
|
||||||
$linuxService->deleteUser($username, $removeHome);
|
$result = $linuxService->deleteUser($username, $removeHome, $domains);
|
||||||
|
|
||||||
$body = $removeHome
|
if (! ($result['success'] ?? false)) {
|
||||||
? __("System user ':username' has been deleted along with home directory.", ['username' => $username])
|
throw new Exception($result['error'] ?? __('Failed to delete Linux user'));
|
||||||
: __("System user ':username' has been deleted.", ['username' => $username]);
|
}
|
||||||
|
|
||||||
Notification::make()
|
$steps = array_merge($steps, $result['steps'] ?? []);
|
||||||
->title(__('Linux user deleted'))
|
} else {
|
||||||
->body($body)
|
$steps[] = __('Linux user not found on the server');
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}
|
}
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
@@ -124,6 +124,15 @@ class EditUser extends EditRecord
|
|||||||
// Delete from database
|
// Delete from database
|
||||||
$this->record->delete();
|
$this->record->delete();
|
||||||
|
|
||||||
|
$steps[] = __('Removed user from admin list');
|
||||||
|
$details = implode("\n", array_map(fn ($step): string => '• '.$step, $steps));
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title(__('User :username removed', ['username' => $username]))
|
||||||
|
->body($details)
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
|
||||||
$this->redirect($this->getResource()::getUrl('index'));
|
$this->redirect($this->getResource()::getUrl('index'));
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -125,18 +125,22 @@ class UsersTable
|
|||||||
->action(function ($record, array $data) {
|
->action(function ($record, array $data) {
|
||||||
$removeHome = $data['remove_home'] ?? false;
|
$removeHome = $data['remove_home'] ?? false;
|
||||||
$username = $record->username;
|
$username = $record->username;
|
||||||
|
$steps = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$linuxService = new LinuxUserService;
|
$linuxService = new LinuxUserService;
|
||||||
|
$domains = $record->domains()->pluck('domain')->all();
|
||||||
|
|
||||||
if ($linuxService->userExists($username)) {
|
if ($linuxService->userExists($username)) {
|
||||||
$linuxService->deleteUser($username, $removeHome);
|
$result = $linuxService->deleteUser($username, $removeHome, $domains);
|
||||||
|
|
||||||
Notification::make()
|
if (! ($result['success'] ?? false)) {
|
||||||
->title(__('Linux user deleted'))
|
throw new Exception($result['error'] ?? __('Failed to delete Linux user'));
|
||||||
->body(__("System user ':username' has been deleted.", ['username' => $username]))
|
}
|
||||||
->success()
|
|
||||||
->send();
|
$steps = array_merge($steps, $result['steps'] ?? []);
|
||||||
|
} else {
|
||||||
|
$steps[] = __('Linux user not found on the server');
|
||||||
}
|
}
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
@@ -147,6 +151,15 @@ class UsersTable
|
|||||||
}
|
}
|
||||||
|
|
||||||
$record->delete();
|
$record->delete();
|
||||||
|
|
||||||
|
$steps[] = __('Removed user from admin list');
|
||||||
|
$details = implode("\n", array_map(fn ($step): string => '• '.$step, $steps));
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title(__('User :username removed', ['username' => $username]))
|
||||||
|
->body($details)
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class WebhookEndpointResource extends Resource
|
|||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedBellAlert;
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedBellAlert;
|
||||||
|
|
||||||
protected static ?int $navigationSort = 17;
|
protected static ?int $navigationSort = 18;
|
||||||
|
|
||||||
public static function getNavigationLabel(): string
|
public static function getNavigationLabel(): string
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,9 +2,13 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
|
||||||
use App\Models\Domain;
|
use App\Models\Domain;
|
||||||
use App\Observers\DomainObserver;
|
use App\Observers\DomainObserver;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
use function Livewire\on;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
@@ -25,5 +29,23 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
|
|
||||||
// Note: AuthEventListener is auto-discovered by Laravel 11+
|
// Note: AuthEventListener is auto-discovered by Laravel 11+
|
||||||
// Do not manually subscribe - it causes duplicate audit log entries
|
// Do not manually subscribe - it causes duplicate audit log entries
|
||||||
|
on('dehydrate', function (Component $component): void {
|
||||||
|
static $dispatched = false;
|
||||||
|
|
||||||
|
if ($dispatched) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Livewire::isLivewireRequest()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count(session()->get('filament.notifications') ?? []) <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dispatched = true;
|
||||||
|
$component->dispatch('notificationsSent');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,11 +44,12 @@ class AgentClient
|
|||||||
socket_write($socket, $request, strlen($request));
|
socket_write($socket, $request, strlen($request));
|
||||||
|
|
||||||
$response = '';
|
$response = '';
|
||||||
while ($buf = socket_read($socket, 8192)) {
|
while (true) {
|
||||||
$response .= $buf;
|
$buf = socket_read($socket, 8192);
|
||||||
if (strlen($buf) < 8192) {
|
if ($buf === '' || $buf === false) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
$response .= $buf;
|
||||||
}
|
}
|
||||||
|
|
||||||
socket_close($socket);
|
socket_close($socket);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class LinuxUserService
|
|||||||
|
|
||||||
public function __construct(?AgentClient $agent = null)
|
public function __construct(?AgentClient $agent = null)
|
||||||
{
|
{
|
||||||
$this->agent = $agent ?? new AgentClient();
|
$this->agent = $agent ?? new AgentClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,6 +28,7 @@ class LinuxUserService
|
|||||||
$user->update([
|
$user->update([
|
||||||
'home_directory' => $response['home_directory'] ?? "/home/{$user->username}",
|
'home_directory' => $response['home_directory'] ?? "/home/{$user->username}",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,11 +37,13 @@ class LinuxUserService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a Linux system user
|
* Delete a Linux system user
|
||||||
|
*
|
||||||
|
* @param array<string> $domains
|
||||||
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
public function deleteUser(string $username, bool $removeHome = false): bool
|
public function deleteUser(string $username, bool $removeHome = false, array $domains = []): array
|
||||||
{
|
{
|
||||||
$response = $this->agent->deleteUser($username, $removeHome);
|
return $this->agent->deleteUser($username, $removeHome, $domains);
|
||||||
return $response['success'] ?? false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,6 +60,7 @@ class LinuxUserService
|
|||||||
public function setPassword(string $username, string $password): bool
|
public function setPassword(string $username, string $password): bool
|
||||||
{
|
{
|
||||||
$response = $this->agent->setUserPassword($username, $password);
|
$response = $this->agent->setUserPassword($username, $password);
|
||||||
|
|
||||||
return $response['success'] ?? false;
|
return $response['success'] ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
174
bin/jabali-agent
174
bin/jabali-agent
@@ -834,6 +834,7 @@ function deleteUser(array $params): array
|
|||||||
$username = $params['username'] ?? '';
|
$username = $params['username'] ?? '';
|
||||||
$removeHome = $params['remove_home'] ?? false;
|
$removeHome = $params['remove_home'] ?? false;
|
||||||
$domains = $params['domains'] ?? []; // List of user's domains to clean up
|
$domains = $params['domains'] ?? []; // List of user's domains to clean up
|
||||||
|
$steps = [];
|
||||||
|
|
||||||
if (!validateUsername($username)) {
|
if (!validateUsername($username)) {
|
||||||
return ['success' => false, 'error' => 'Invalid username format'];
|
return ['success' => false, 'error' => 'Invalid username format'];
|
||||||
@@ -860,12 +861,17 @@ function deleteUser(array $params): array
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$domainConfigRemoved = false;
|
||||||
|
$domainsDirExists = is_dir("$homeDir/domains");
|
||||||
|
|
||||||
// Clean up domain-related files for each domain
|
// Clean up domain-related files for each domain
|
||||||
foreach ($domains as $domain) {
|
foreach ($domains as $domain) {
|
||||||
if (!validateDomain($domain)) {
|
if (!validateDomain($domain)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$domainTouched = false;
|
||||||
|
|
||||||
// Remove nginx vhost configs (with .conf extension)
|
// Remove nginx vhost configs (with .conf extension)
|
||||||
$nginxAvailable = "/etc/nginx/sites-available/{$domain}.conf";
|
$nginxAvailable = "/etc/nginx/sites-available/{$domain}.conf";
|
||||||
$nginxEnabled = "/etc/nginx/sites-enabled/{$domain}.conf";
|
$nginxEnabled = "/etc/nginx/sites-enabled/{$domain}.conf";
|
||||||
@@ -877,12 +883,14 @@ function deleteUser(array $params): array
|
|||||||
if (file_exists($file) || is_link($file)) {
|
if (file_exists($file) || is_link($file)) {
|
||||||
@unlink($file);
|
@unlink($file);
|
||||||
logger("Removed nginx symlink: $file");
|
logger("Removed nginx symlink: $file");
|
||||||
|
$domainTouched = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
foreach ([$nginxAvailable, $nginxAvailableOld] as $file) {
|
foreach ([$nginxAvailable, $nginxAvailableOld] as $file) {
|
||||||
if (file_exists($file)) {
|
if (file_exists($file)) {
|
||||||
@unlink($file);
|
@unlink($file);
|
||||||
logger("Removed nginx config: $file");
|
logger("Removed nginx config: $file");
|
||||||
|
$domainTouched = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -891,6 +899,7 @@ function deleteUser(array $params): array
|
|||||||
if (file_exists($zoneFile)) {
|
if (file_exists($zoneFile)) {
|
||||||
@unlink($zoneFile);
|
@unlink($zoneFile);
|
||||||
logger("Removed DNS zone: $zoneFile");
|
logger("Removed DNS zone: $zoneFile");
|
||||||
|
$domainTouched = true;
|
||||||
|
|
||||||
// Remove from named.conf.local
|
// Remove from named.conf.local
|
||||||
$namedConf = '/etc/bind/named.conf.local';
|
$namedConf = '/etc/bind/named.conf.local';
|
||||||
@@ -911,11 +920,13 @@ function deleteUser(array $params): array
|
|||||||
if (is_dir($mailDir)) {
|
if (is_dir($mailDir)) {
|
||||||
exec("rm -rf " . escapeshellarg($mailDir));
|
exec("rm -rf " . escapeshellarg($mailDir));
|
||||||
logger("Removed mail directory: $mailDir");
|
logger("Removed mail directory: $mailDir");
|
||||||
|
$domainTouched = true;
|
||||||
}
|
}
|
||||||
$vmailDir = "/var/vmail/$domain";
|
$vmailDir = "/var/vmail/$domain";
|
||||||
if (is_dir($vmailDir)) {
|
if (is_dir($vmailDir)) {
|
||||||
exec("rm -rf " . escapeshellarg($vmailDir));
|
exec("rm -rf " . escapeshellarg($vmailDir));
|
||||||
logger("Removed vmail directory: $vmailDir");
|
logger("Removed vmail directory: $vmailDir");
|
||||||
|
$domainTouched = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from Postfix virtual_mailbox_domains
|
// Remove from Postfix virtual_mailbox_domains
|
||||||
@@ -952,14 +963,22 @@ function deleteUser(array $params): array
|
|||||||
if (is_dir($certPath)) {
|
if (is_dir($certPath)) {
|
||||||
exec("rm -rf " . escapeshellarg($certPath));
|
exec("rm -rf " . escapeshellarg($certPath));
|
||||||
logger("Removed SSL certificate: $certPath");
|
logger("Removed SSL certificate: $certPath");
|
||||||
|
$domainTouched = true;
|
||||||
}
|
}
|
||||||
if (is_dir($certArchive)) {
|
if (is_dir($certArchive)) {
|
||||||
exec("rm -rf " . escapeshellarg($certArchive));
|
exec("rm -rf " . escapeshellarg($certArchive));
|
||||||
logger("Removed SSL archive: $certArchive");
|
logger("Removed SSL archive: $certArchive");
|
||||||
|
$domainTouched = true;
|
||||||
}
|
}
|
||||||
if (file_exists($certRenewal)) {
|
if (file_exists($certRenewal)) {
|
||||||
@unlink($certRenewal);
|
@unlink($certRenewal);
|
||||||
logger("Removed SSL renewal config: $certRenewal");
|
logger("Removed SSL renewal config: $certRenewal");
|
||||||
|
$domainTouched = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($domainTouched) {
|
||||||
|
$domainConfigRemoved = true;
|
||||||
|
$steps[] = "$domain config files removed";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -967,6 +986,9 @@ function deleteUser(array $params): array
|
|||||||
$dbPrefix = $username . '_';
|
$dbPrefix = $username . '_';
|
||||||
$mysqli = getMysqlConnection();
|
$mysqli = getMysqlConnection();
|
||||||
if ($mysqli) {
|
if ($mysqli) {
|
||||||
|
$dbDeletedCount = 0;
|
||||||
|
$dbUserDeletedCount = 0;
|
||||||
|
|
||||||
// Get all databases belonging to this user
|
// Get all databases belonging to this user
|
||||||
$result = $mysqli->query("SHOW DATABASES LIKE '{$mysqli->real_escape_string($dbPrefix)}%'");
|
$result = $mysqli->query("SHOW DATABASES LIKE '{$mysqli->real_escape_string($dbPrefix)}%'");
|
||||||
if ($result) {
|
if ($result) {
|
||||||
@@ -976,6 +998,7 @@ function deleteUser(array $params): array
|
|||||||
if (strpos($dbName, $dbPrefix) === 0) {
|
if (strpos($dbName, $dbPrefix) === 0) {
|
||||||
$mysqli->query("DROP DATABASE IF EXISTS `{$mysqli->real_escape_string($dbName)}`");
|
$mysqli->query("DROP DATABASE IF EXISTS `{$mysqli->real_escape_string($dbName)}`");
|
||||||
logger("Deleted MySQL database: $dbName");
|
logger("Deleted MySQL database: $dbName");
|
||||||
|
$dbDeletedCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$result->free();
|
$result->free();
|
||||||
@@ -991,18 +1014,33 @@ function deleteUser(array $params): array
|
|||||||
if (strpos($dbUser, $dbPrefix) === 0) {
|
if (strpos($dbUser, $dbPrefix) === 0) {
|
||||||
$mysqli->query("DROP USER IF EXISTS '{$mysqli->real_escape_string($dbUser)}'@'{$mysqli->real_escape_string($dbHost)}'");
|
$mysqli->query("DROP USER IF EXISTS '{$mysqli->real_escape_string($dbUser)}'@'{$mysqli->real_escape_string($dbHost)}'");
|
||||||
logger("Deleted MySQL user: $dbUser@$dbHost");
|
logger("Deleted MySQL user: $dbUser@$dbHost");
|
||||||
|
$dbUserDeletedCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$result->free();
|
$result->free();
|
||||||
}
|
}
|
||||||
$mysqli->query("FLUSH PRIVILEGES");
|
$mysqli->query("FLUSH PRIVILEGES");
|
||||||
$mysqli->close();
|
$mysqli->close();
|
||||||
|
|
||||||
|
if ($dbDeletedCount > 0) {
|
||||||
|
$steps[] = "MySQL databases removed ({$dbDeletedCount})";
|
||||||
|
}
|
||||||
|
if ($dbUserDeletedCount > 0) {
|
||||||
|
$steps[] = "MySQL users removed ({$dbUserDeletedCount})";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove PHP-FPM pool config
|
// Remove PHP-FPM pool config
|
||||||
|
$fpmRemovedCount = 0;
|
||||||
foreach (glob("/etc/php/*/fpm/pool.d/$username.conf") as $poolConf) {
|
foreach (glob("/etc/php/*/fpm/pool.d/$username.conf") as $poolConf) {
|
||||||
@unlink($poolConf);
|
if (@unlink($poolConf)) {
|
||||||
logger("Removed PHP-FPM pool: $poolConf");
|
logger("Removed PHP-FPM pool: $poolConf");
|
||||||
|
$fpmRemovedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($fpmRemovedCount > 0) {
|
||||||
|
$steps[] = 'PHP-FPM pool removed';
|
||||||
|
$domainConfigRemoved = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete user with --force to ignore warnings about mail spool
|
// Delete user with --force to ignore warnings about mail spool
|
||||||
@@ -1018,12 +1056,16 @@ function deleteUser(array $params): array
|
|||||||
return ['success' => false, 'error' => 'Failed to delete user: ' . implode("\n", $userdelOutput)];
|
return ['success' => false, 'error' => 'Failed to delete user: ' . implode("\n", $userdelOutput)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$steps[] = 'User removed from SSH';
|
||||||
|
$steps[] = 'Unix user removed from the server';
|
||||||
|
|
||||||
// Delete Redis ACL user (and all their cached keys)
|
// Delete Redis ACL user (and all their cached keys)
|
||||||
$redisResult = redisDeleteUser(['username' => $username]);
|
$redisResult = redisDeleteUser(['username' => $username]);
|
||||||
if (!$redisResult['success']) {
|
if (!$redisResult['success']) {
|
||||||
logger("Warning: Failed to delete Redis user for $username: " . ($redisResult['error'] ?? 'Unknown error'));
|
logger("Warning: Failed to delete Redis user for $username: " . ($redisResult['error'] ?? 'Unknown error'));
|
||||||
} else {
|
} else {
|
||||||
logger("Deleted Redis ACL user for $username");
|
logger("Deleted Redis ACL user for $username");
|
||||||
|
$steps[] = 'Redis ACL user removed';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manually remove home directory if requested (since it's owned by root)
|
// Manually remove home directory if requested (since it's owned by root)
|
||||||
@@ -1031,6 +1073,11 @@ function deleteUser(array $params): array
|
|||||||
exec(sprintf('rm -rf %s 2>&1', escapeshellarg($homeDir)), $rmOutput, $rmExit);
|
exec(sprintf('rm -rf %s 2>&1', escapeshellarg($homeDir)), $rmOutput, $rmExit);
|
||||||
if ($rmExit !== 0) {
|
if ($rmExit !== 0) {
|
||||||
logger("Warning: Failed to remove home directory for $username");
|
logger("Warning: Failed to remove home directory for $username");
|
||||||
|
} else {
|
||||||
|
$steps[] = "User's data directory removed";
|
||||||
|
if ($domainsDirExists) {
|
||||||
|
$steps[] = "User's domains directory removed";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1042,9 +1089,13 @@ function deleteUser(array $params): array
|
|||||||
exec('rndc reload 2>/dev/null');
|
exec('rndc reload 2>/dev/null');
|
||||||
exec('systemctl reload php*-fpm 2>/dev/null');
|
exec('systemctl reload php*-fpm 2>/dev/null');
|
||||||
|
|
||||||
|
if ($domainConfigRemoved) {
|
||||||
|
$steps[] = "User's config files deleted";
|
||||||
|
}
|
||||||
|
|
||||||
logger("Deleted user $username" . ($removeHome ? " with home directory" : "") . " and cleaned up " . count($domains) . " domain(s)");
|
logger("Deleted user $username" . ($removeHome ? " with home directory" : "") . " and cleaned up " . count($domains) . " domain(s)");
|
||||||
|
|
||||||
return ['success' => true, 'message' => "User $username deleted successfully"];
|
return ['success' => true, 'message' => "User $username deleted successfully", 'steps' => $steps];
|
||||||
}
|
}
|
||||||
|
|
||||||
function setUserPassword(array $params): array
|
function setUserPassword(array $params): array
|
||||||
@@ -2784,19 +2835,26 @@ function ensureJabaliNginxIncludeFiles(): void
|
|||||||
ensureWafUnicodeMapFile();
|
ensureWafUnicodeMapFile();
|
||||||
ensureWafMainConfig();
|
ensureWafMainConfig();
|
||||||
|
|
||||||
|
$modSecurityAvailable = isModSecurityModuleAvailable();
|
||||||
$baseConfig = findWafBaseConfig();
|
$baseConfig = findWafBaseConfig();
|
||||||
$shouldDisableWaf = $baseConfig === null;
|
$shouldDisableWaf = $baseConfig === null || !$modSecurityAvailable;
|
||||||
|
|
||||||
if (!file_exists(JABALI_WAF_INCLUDE)) {
|
if (!file_exists(JABALI_WAF_INCLUDE)) {
|
||||||
$content = "# Managed by Jabali\n";
|
$content = "# Managed by Jabali\n";
|
||||||
if ($shouldDisableWaf) {
|
if (!$modSecurityAvailable) {
|
||||||
|
$content .= "# ModSecurity module not available in nginx.\n";
|
||||||
|
} elseif ($shouldDisableWaf) {
|
||||||
$content .= "modsecurity off;\n";
|
$content .= "modsecurity off;\n";
|
||||||
}
|
}
|
||||||
file_put_contents(JABALI_WAF_INCLUDE, $content);
|
file_put_contents(JABALI_WAF_INCLUDE, $content);
|
||||||
} elseif ($shouldDisableWaf) {
|
} elseif ($shouldDisableWaf) {
|
||||||
$current = file_get_contents(JABALI_WAF_INCLUDE);
|
$current = file_get_contents(JABALI_WAF_INCLUDE);
|
||||||
if ($current === false || strpos($current, 'modsecurity_rules_file') !== false || strpos($current, 'modsecurity on;') !== false) {
|
if ($current === false || strpos($current, 'modsecurity_rules_file') !== false || strpos($current, 'modsecurity on;') !== false || strpos($current, 'modsecurity off;') !== false) {
|
||||||
file_put_contents(JABALI_WAF_INCLUDE, "# Managed by Jabali\nmodsecurity off;\n");
|
if (!$modSecurityAvailable) {
|
||||||
|
file_put_contents(JABALI_WAF_INCLUDE, "# Managed by Jabali\n# ModSecurity module not available in nginx.\n");
|
||||||
|
} else {
|
||||||
|
file_put_contents(JABALI_WAF_INCLUDE, "# Managed by Jabali\nmodsecurity off;\n");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2944,6 +3002,26 @@ function findWafBaseConfig(): ?string
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isModSecurityModuleAvailable(): bool
|
||||||
|
{
|
||||||
|
$output = [];
|
||||||
|
exec('nginx -V 2>&1', $output);
|
||||||
|
$info = implode("\n", $output);
|
||||||
|
|
||||||
|
if (stripos($info, 'modsecurity') !== false) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (glob('/etc/nginx/modules-enabled/*.conf') ?: [] as $file) {
|
||||||
|
$content = file_get_contents($file);
|
||||||
|
if ($content !== false && stripos($content, 'modsecurity') !== false) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function isWafBaseConfigUsable(string $path): bool
|
function isWafBaseConfigUsable(string $path): bool
|
||||||
{
|
{
|
||||||
if (!is_readable($path)) {
|
if (!is_readable($path)) {
|
||||||
@@ -3006,8 +3084,14 @@ function wafApplySettings(array $params): array
|
|||||||
|
|
||||||
$prevInclude = file_exists(JABALI_WAF_INCLUDE) ? file_get_contents(JABALI_WAF_INCLUDE) : null;
|
$prevInclude = file_exists(JABALI_WAF_INCLUDE) ? file_get_contents(JABALI_WAF_INCLUDE) : null;
|
||||||
$prevRules = file_exists(JABALI_WAF_RULES) ? file_get_contents(JABALI_WAF_RULES) : null;
|
$prevRules = file_exists(JABALI_WAF_RULES) ? file_get_contents(JABALI_WAF_RULES) : null;
|
||||||
|
$modSecurityAvailable = isModSecurityModuleAvailable();
|
||||||
|
|
||||||
if ($enabled) {
|
if ($enabled) {
|
||||||
|
if (!$modSecurityAvailable) {
|
||||||
|
file_put_contents(JABALI_WAF_INCLUDE, "# Managed by Jabali\n# ModSecurity module not available in nginx.\n");
|
||||||
|
return ['success' => false, 'error' => 'ModSecurity module not available in nginx'];
|
||||||
|
}
|
||||||
|
|
||||||
ensureWafUnicodeMapFile();
|
ensureWafUnicodeMapFile();
|
||||||
$baseConfig = findWafBaseConfig();
|
$baseConfig = findWafBaseConfig();
|
||||||
if (!$baseConfig) {
|
if (!$baseConfig) {
|
||||||
@@ -3035,7 +3119,11 @@ function wafApplySettings(array $params): array
|
|||||||
|
|
||||||
file_put_contents(JABALI_WAF_INCLUDE, implode("\n", $include) . "\n");
|
file_put_contents(JABALI_WAF_INCLUDE, implode("\n", $include) . "\n");
|
||||||
} else {
|
} else {
|
||||||
file_put_contents(JABALI_WAF_INCLUDE, "# Managed by Jabali\nmodsecurity off;\n");
|
if ($modSecurityAvailable) {
|
||||||
|
file_put_contents(JABALI_WAF_INCLUDE, "# Managed by Jabali\nmodsecurity off;\n");
|
||||||
|
} else {
|
||||||
|
file_put_contents(JABALI_WAF_INCLUDE, "# Managed by Jabali\n# ModSecurity module not available in nginx.\n");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureNginxServerIncludes([
|
ensureNginxServerIncludes([
|
||||||
@@ -10076,14 +10164,18 @@ function emailGetLogs(array $params): array
|
|||||||
|
|
||||||
$logs = [];
|
$logs = [];
|
||||||
$mailLogFile = '/var/log/mail.log';
|
$mailLogFile = '/var/log/mail.log';
|
||||||
|
|
||||||
if (!file_exists($mailLogFile)) {
|
|
||||||
return ['success' => true, 'logs' => []];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read last N lines of mail log
|
|
||||||
$output = [];
|
$output = [];
|
||||||
exec("tail -n 1000 " . escapeshellarg($mailLogFile), $output);
|
|
||||||
|
if (file_exists($mailLogFile)) {
|
||||||
|
// Read last N lines of mail log
|
||||||
|
exec("tail -n 1000 " . escapeshellarg($mailLogFile), $output);
|
||||||
|
} else {
|
||||||
|
// Fallback to journald when mail.log is not present (common on systemd systems)
|
||||||
|
exec("journalctl -u postfix --no-pager -n 1000 -o short-iso 2>/dev/null", $output, $journalCode);
|
||||||
|
if ($journalCode !== 0) {
|
||||||
|
return ['success' => true, 'logs' => []];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$currentMessage = null;
|
$currentMessage = null;
|
||||||
$messageIndex = [];
|
$messageIndex = [];
|
||||||
@@ -10099,54 +10191,68 @@ function emailGetLogs(array $params): array
|
|||||||
$message = null;
|
$message = null;
|
||||||
|
|
||||||
// Try ISO 8601 format first (modern systemd/journald)
|
// Try ISO 8601 format first (modern systemd/journald)
|
||||||
if (preg_match('/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2})?)\s+\S+\s+postfix\/(\w+)\[(\d+)\]:\s+([A-F0-9]+):\s+(.+)$/', $line, $matches)) {
|
if (preg_match('/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2})?)\s+\S+\s+postfix\/([\w\-\/]+)\[\d+\]:\s+(.+)$/', $line, $matches)) {
|
||||||
$timestamp = strtotime($matches[1]);
|
$timestamp = strtotime($matches[1]);
|
||||||
$component = $matches[2];
|
$component = $matches[2];
|
||||||
$queueId = $matches[4];
|
$message = $matches[3];
|
||||||
$message = $matches[5];
|
|
||||||
}
|
}
|
||||||
// Try traditional syslog format
|
// Try traditional syslog format
|
||||||
elseif (preg_match('/^(\w+\s+\d+\s+\d+:\d+:\d+)\s+\S+\s+postfix\/(\w+)\[(\d+)\]:\s+([A-F0-9]+):\s+(.+)$/', $line, $matches)) {
|
elseif (preg_match('/^(\w+\s+\d+\s+\d+:\d+:\d+)\s+\S+\s+postfix\/([\w\-\/]+)\[\d+\]:\s+(.+)$/', $line, $matches)) {
|
||||||
$timestamp = strtotime($matches[1] . ' ' . date('Y'));
|
$timestamp = strtotime($matches[1] . ' ' . date('Y'));
|
||||||
$component = $matches[2];
|
$component = $matches[2];
|
||||||
$queueId = $matches[4];
|
$message = $matches[3];
|
||||||
$message = $matches[5];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($timestamp && $queueId && $message) {
|
if ($timestamp && $component && $message) {
|
||||||
|
$queueId = null;
|
||||||
|
$payload = $message;
|
||||||
|
|
||||||
|
if (preg_match('/^([A-F0-9]{5,}):\s+(.+)$/', $message, $idMatch)) {
|
||||||
|
$queueId = $idMatch[1];
|
||||||
|
$payload = $idMatch[2];
|
||||||
|
} elseif (preg_match('/^NOQUEUE:\s+(.+)$/', $message, $noQueueMatch)) {
|
||||||
|
$queueId = 'NOQUEUE';
|
||||||
|
$payload = $noQueueMatch[1];
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize message entry
|
// Initialize message entry
|
||||||
if (!isset($messageIndex[$queueId])) {
|
$messageKey = $queueId . '-' . $timestamp;
|
||||||
$messageIndex[$queueId] = [
|
if (!isset($messageIndex[$messageKey])) {
|
||||||
|
$messageIndex[$messageKey] = [
|
||||||
'timestamp' => $timestamp,
|
'timestamp' => $timestamp,
|
||||||
'queue_id' => $queueId,
|
'queue_id' => $queueId,
|
||||||
|
'component' => $component,
|
||||||
'from' => null,
|
'from' => null,
|
||||||
'to' => null,
|
'to' => null,
|
||||||
'subject' => null,
|
'subject' => null,
|
||||||
'status' => 'unknown',
|
'status' => $queueId === 'NOQUEUE' ? 'reject' : 'unknown',
|
||||||
'message' => '',
|
'message' => '',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse from
|
// Parse from
|
||||||
if (preg_match('/from=<([^>]*)>/', $message, $fromMatch)) {
|
if (preg_match('/from=<([^>]*)>/', $payload, $fromMatch)) {
|
||||||
$messageIndex[$queueId]['from'] = $fromMatch[1];
|
$messageIndex[$messageKey]['from'] = $fromMatch[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse to
|
// Parse to
|
||||||
if (preg_match('/to=<([^>]*)>/', $message, $toMatch)) {
|
if (preg_match('/to=<([^>]*)>/', $payload, $toMatch)) {
|
||||||
$messageIndex[$queueId]['to'] = $toMatch[1];
|
$messageIndex[$messageKey]['to'] = $toMatch[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse status
|
// Parse status
|
||||||
if (preg_match('/status=(\w+)/', $message, $statusMatch)) {
|
if (preg_match('/status=(\w+)/', $payload, $statusMatch)) {
|
||||||
$messageIndex[$queueId]['status'] = $statusMatch[1];
|
$messageIndex[$messageKey]['status'] = $statusMatch[1];
|
||||||
$messageIndex[$queueId]['message'] = $message;
|
$messageIndex[$messageKey]['message'] = $payload;
|
||||||
|
} else {
|
||||||
|
$messageIndex[$messageKey]['message'] = $payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse delay and relay info
|
// Parse delay and relay info
|
||||||
if (preg_match('/relay=([^,]+)/', $message, $relayMatch)) {
|
if (preg_match('/relay=([^,]+)/', $payload, $relayMatch)) {
|
||||||
$messageIndex[$queueId]['relay'] = $relayMatch[1];
|
$messageIndex[$messageKey]['relay'] = $relayMatch[1];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ This blueprint describes a modern web hosting control panel (cPanel/DirectAdmin-
|
|||||||
### Control plane (panel)
|
### Control plane (panel)
|
||||||
|
|
||||||
- UI + API, RBAC, tenant/package/quota management
|
- UI + API, RBAC, tenant/package/quota management
|
||||||
|
- UI stack: Tailwind CSS + Filament components for panels, forms, tables, and widgets
|
||||||
- Job runner + queue workers
|
- Job runner + queue workers
|
||||||
- Audit log + job logs/artifacts
|
- Audit log + job logs/artifacts
|
||||||
- Central configuration + templates
|
- Central configuration + templates
|
||||||
|
|||||||
40
install.sh
40
install.sh
@@ -16,7 +16,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
if [[ -f "$SCRIPT_DIR/VERSION" ]]; then
|
if [[ -f "$SCRIPT_DIR/VERSION" ]]; then
|
||||||
JABALI_VERSION="$(sed -n 's/^VERSION=//p' "$SCRIPT_DIR/VERSION")"
|
JABALI_VERSION="$(sed -n 's/^VERSION=//p' "$SCRIPT_DIR/VERSION")"
|
||||||
fi
|
fi
|
||||||
JABALI_VERSION="${JABALI_VERSION:-0.9-rc26}"
|
JABALI_VERSION="${JABALI_VERSION:-0.9-rc34}"
|
||||||
|
|
||||||
# Colors
|
# Colors
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
@@ -487,6 +487,38 @@ install_packages() {
|
|||||||
# Add Security packages if enabled
|
# Add Security packages if enabled
|
||||||
if [[ "$INSTALL_SECURITY" == "true" ]]; then
|
if [[ "$INSTALL_SECURITY" == "true" ]]; then
|
||||||
info "Including Security packages..."
|
info "Including Security packages..."
|
||||||
|
if apt-cache show libnginx-mod-http-modsecurity &>/dev/null; then
|
||||||
|
base_packages+=(
|
||||||
|
libnginx-mod-http-modsecurity
|
||||||
|
)
|
||||||
|
elif apt-cache show libnginx-mod-http-modsecurity2 &>/dev/null; then
|
||||||
|
base_packages+=(
|
||||||
|
libnginx-mod-http-modsecurity2
|
||||||
|
)
|
||||||
|
elif apt-cache show nginx-extras &>/dev/null; then
|
||||||
|
base_packages+=(
|
||||||
|
nginx-extras
|
||||||
|
)
|
||||||
|
else
|
||||||
|
warn "ModSecurity nginx module not available in apt repositories"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if apt-cache show libmodsecurity3t64 &>/dev/null; then
|
||||||
|
base_packages+=(
|
||||||
|
libmodsecurity3t64
|
||||||
|
)
|
||||||
|
elif apt-cache show libmodsecurity3 &>/dev/null; then
|
||||||
|
base_packages+=(
|
||||||
|
libmodsecurity3
|
||||||
|
)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if apt-cache show modsecurity-crs &>/dev/null; then
|
||||||
|
base_packages+=(
|
||||||
|
modsecurity-crs
|
||||||
|
)
|
||||||
|
fi
|
||||||
|
|
||||||
base_packages+=(
|
base_packages+=(
|
||||||
clamav
|
clamav
|
||||||
clamav-daemon
|
clamav-daemon
|
||||||
@@ -3225,6 +3257,12 @@ uninstall() {
|
|||||||
|
|
||||||
# Security
|
# Security
|
||||||
fail2ban
|
fail2ban
|
||||||
|
libnginx-mod-http-modsecurity
|
||||||
|
libnginx-mod-http-modsecurity2
|
||||||
|
libmodsecurity3t64
|
||||||
|
libmodsecurity3
|
||||||
|
modsecurity-crs
|
||||||
|
nginx-extras
|
||||||
clamav
|
clamav
|
||||||
clamav-daemon
|
clamav-daemon
|
||||||
clamav-freshclam
|
clamav-freshclam
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<x-filament-panels::page>
|
||||||
|
{{ $this->table }}
|
||||||
|
|
||||||
|
<x-filament-actions::modals />
|
||||||
|
</x-filament-panels::page>
|
||||||
Reference in New Issue
Block a user