Initial commit

This commit is contained in:
codex
2026-02-02 03:11:45 +02:00
commit 27a9dfa84d
652 changed files with 144899 additions and 0 deletions

18
.editorconfig Normal file
View File

@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[compose.yaml]
indent_size = 4

62
.env.example Normal file
View File

@@ -0,0 +1,62 @@
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
# Database - Jabali uses SQLite by default
# Database file: database/database.sqlite
DB_CONNECTION=sqlite
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"

View File

@@ -0,0 +1 @@
admin@jabali.lan

3
.git-authorized-remotes Normal file
View File

@@ -0,0 +1,3 @@
ssh://git@192.168.100.100:2222/shukivaknin/jabali-panel.git
http://192.168.100.100:3001/shukivaknin/jabali-panel.git
git@github.com:shukiv/jabali-panel.git

11
.gitattributes vendored Normal file
View File

@@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore

58
.githooks/pre-commit Executable file
View File

@@ -0,0 +1,58 @@
#!/bin/bash
# Pre-commit hook for Jabali
# To enable: git config core.hooksPath .githooks
set -e
echo "Running pre-commit checks..."
# Get staged PHP files
STAGED_PHP=$(git diff --cached --name-only --diff-filter=ACMR | grep '\.php$' || true)
if [ -n "$STAGED_PHP" ]; then
echo "Checking PHP syntax..."
for FILE in $STAGED_PHP; do
if [ -f "$FILE" ]; then
php -l "$FILE" > /dev/null 2>&1 || {
echo "Syntax error in $FILE"
exit 1
}
fi
done
echo "PHP syntax OK"
# Run Pint on staged files only
if [ -f "./vendor/bin/pint" ]; then
echo "Running Laravel Pint..."
./vendor/bin/pint --test $STAGED_PHP || {
echo ""
echo "Code style issues found. Run 'make fix' to auto-fix."
exit 1
}
echo "Code style OK"
fi
fi
# Check for debug statements
DEBUG_PATTERNS="dd(|dump(|var_dump(|print_r(|ray(|Log::debug("
if git diff --cached --diff-filter=ACMR | grep -E "$DEBUG_PATTERNS" > /dev/null 2>&1; then
echo ""
echo "WARNING: Debug statements found in staged changes:"
git diff --cached --diff-filter=ACMR | grep -n -E "$DEBUG_PATTERNS" | head -10
echo ""
read -p "Continue anyway? (y/N) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
exit 1
fi
fi
# Check for hardcoded credentials patterns
CREDENTIAL_PATTERNS="password.*=.*['\"][^'\"]+['\"]|api_key.*=.*['\"][^'\"]+['\"]|secret.*=.*['\"][^'\"]+['\"]"
if git diff --cached --diff-filter=ACMR | grep -iE "$CREDENTIAL_PATTERNS" > /dev/null 2>&1; then
echo ""
echo "WARNING: Possible hardcoded credentials detected!"
echo "Please review the staged changes carefully."
fi
echo "Pre-commit checks passed!"

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
/node_modules
/public/build
/public/hot
/public/storage
/public/webmail
/storage/*.key
/vendor
.env
.env.backup
.env.production
.phpunit.result.cache
Homestead.json
Homestead.yaml
auth.json
npm-debug.log
yarn-error.log
/.fleet
/.idea
/.vscode
/.claude
CLAUDE.md
/jabali-panel_*.deb
/jabali-deps_*.deb
.git-credentials

36
.mcp.json Normal file
View File

@@ -0,0 +1,36 @@
{
"$schema": "https://raw.githubusercontent.com/anthropics/claude-code/main/schemas/mcp.schema.json",
"mcpServers": {
"filesystem": {
"command": "npx",
"args": [
"-y",
"@anthropic-ai/mcp-server-filesystem@latest",
"/var/www/jabali",
"/home",
"/etc/nginx",
"/var/log"
],
"description": "File system access for Jabali project and server configs"
},
"mysql": {
"command": "npx",
"args": [
"-y",
"@anthropic-ai/mcp-server-mysql@latest"
],
"env": {
"MYSQL_HOST": "localhost",
"MYSQL_USER": "root"
},
"description": "MySQL database access for development"
},
"laravel-boost": {
"command": "php",
"args": [
"artisan",
"boost:mcp"
]
}
}
}

2019
AGENT.md Normal file

File diff suppressed because it is too large Load Diff

25
AGENTS.md Normal file
View File

@@ -0,0 +1,25 @@
# AGENTS.md
Rules and behavior for automated agents working on Jabali.
## Baseline
- Read `AGENT.md` first; it is the authoritative project guide.
- Work from `/var/www/jabali`.
- Use ASCII in edits unless the file already uses Unicode.
## Editing
- Prefer `rg` for search; fall back to `grep` if needed.
- Use `apply_patch` for small manual edits.
- Avoid `apply_patch` for generated files (build artifacts, lockfiles, etc.).
- Avoid destructive git commands unless explicitly requested.
## Git
- Do not push unless the user explicitly asks.
- Bump `VERSION` before every push.
- Keep `install.sh` version fallback in sync with `VERSION`.
## Operational
- If you add dependencies, update both install and uninstall paths.
- For installer/upgrade changes, consider ownership/permissions for:
- `storage/`, `bootstrap/cache/`, `public/build/`, `node_modules/`.
- Run tests if available; if not, report what is missing.

35
CONTEXT.md Normal file
View File

@@ -0,0 +1,35 @@
# CONTEXT.md
Last updated: 2026-02-01
## Stack
- Laravel 12, Filament v5, Livewire v4
- PHP 8.4, Node 20, Vite
- Debian 12/13 target
## Panels
- Admin panel: `/jabali-admin`
- User panel: `/jabali-panel`
## Data
- Panel config DB: SQLite at `database/database.sqlite`
- Hosting services use MariaDB/Postfix/Dovecot/etc. as configured by the agent
## Services & Agents
- Privileged agent: `bin/jabali-agent` (root)
- Health monitor: `bin/jabali-health-monitor`
## Server Status Charts
- Data source: `app/Services/SysstatMetrics.php`
- Uses sysstat logs under `/var/log/sysstat`
## Installer / Upgrade
- Installer: `install.sh`
- Clones repo to `/var/www/jabali`
- Uses `www-data` as the runtime user
- Builds assets with Vite to `public/build`
- Sets npm caches under `storage/`
- Upgrade: `php artisan jabali:upgrade`
- Handles git safe.directory
- Runs composer/npm when needed
- Ensures writable permissions for `node_modules` and `public/build`

8
DECISIONS.md Normal file
View File

@@ -0,0 +1,8 @@
# DECISIONS.md
## 2026-02-01
- Server status charts read from sysstat logs via `SysstatMetrics` (no internal server_metrics table).
- Upgrade command manages npm caches in `storage/` and skips puppeteer downloads.
- Asset builds must be writable for `public/build` and `node_modules`; upgrade checks both.
- Installer builds assets as `www-data` to avoid permission issues.
- Default panel database is SQLite (`database/database.sqlite`).

75
LICENSE Normal file
View File

@@ -0,0 +1,75 @@
JABALI PROPRIETARY LICENSE
Version 1.0
Copyright (c) 2024-2026 Jabali. All Rights Reserved.
TERMS AND CONDITIONS
1. DEFINITIONS
"Software" refers to Jabali and all associated source code, documentation,
and related materials.
"Licensor" refers to the copyright holder of Jabali.
"User" refers to any individual or entity using the Software.
2. GRANT OF LICENSE
Subject to the terms of this license, the Licensor grants you a limited,
non-exclusive, non-transferable license to:
- Use the Software for your own hosting purposes
- Install the Software on servers you own or control
3. RESTRICTIONS
You are expressly prohibited from:
a) FORKING: Creating any derivative work, fork, or copy of the Software
for distribution or public availability.
b) REDISTRIBUTION: Distributing, selling, sublicensing, or transferring
the Software or any portion thereof to any third party.
c) MODIFICATION FOR DISTRIBUTION: Modifying the Software for the purpose
of creating a competing product or service.
d) REVERSE ENGINEERING: Decompiling, disassembling, or reverse engineering
the Software for the purpose of creating a similar product.
e) REMOVAL OF NOTICES: Removing, altering, or obscuring any copyright,
trademark, or other proprietary notices from the Software.
f) PUBLIC REPOSITORIES: Publishing the Software or any derivative work
to any public repository (GitHub, GitLab, Bitbucket, etc.).
4. INTELLECTUAL PROPERTY
The Software and all copies thereof are proprietary to the Licensor and
title thereto remains exclusively with the Licensor. All rights in the
Software not specifically granted in this license are reserved to the
Licensor.
5. NO WARRANTY
THE SOFTWARE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
6. LIMITATION OF LIABILITY
IN NO EVENT SHALL THE LICENSOR BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER
LIABILITY ARISING FROM THE USE OF THE SOFTWARE.
7. TERMINATION
This license is effective until terminated. It will terminate automatically
if you fail to comply with any term of this license. Upon termination, you
must destroy all copies of the Software in your possession.
8. ENFORCEMENT
Any unauthorized use, reproduction, or distribution of the Software may
result in civil and criminal penalties, and will be prosecuted to the
maximum extent possible under the law.
9. GOVERNING LAW
This license shall be governed by and construed in accordance with
applicable copyright and intellectual property laws.
For licensing inquiries, contact: license@jabali.io
---
BY USING THIS SOFTWARE, YOU ACKNOWLEDGE THAT YOU HAVE READ THIS LICENSE,
UNDERSTAND IT, AND AGREE TO BE BOUND BY ITS TERMS AND CONDITIONS.

122
Makefile Normal file
View File

@@ -0,0 +1,122 @@
# Jabali Web Hosting Panel - Development Makefile
.PHONY: dev test lint fix fresh migrate seed install build clean agent-restart agent-logs
# Development
dev:
composer dev
serve:
php artisan serve
queue:
php artisan queue:listen --tries=1
logs:
php artisan pail --timeout=0
# Testing
test:
php artisan test
test-filter:
@read -p "Filter: " filter && php artisan test --filter=$$filter
test-coverage:
php artisan test --coverage
# Code Quality
lint:
./vendor/bin/pint --test
fix:
./vendor/bin/pint
analyze:
./vendor/bin/phpstan analyse --memory-limit=512M 2>/dev/null || echo "PHPStan not installed"
# Database
migrate:
php artisan migrate
migrate-fresh:
php artisan migrate:fresh
seed:
php artisan db:seed
fresh: migrate-fresh seed
rollback:
php artisan migrate:rollback
# Build
build:
npm run build
build-dev:
npm run dev
install:
composer install
npm install
update:
composer update
npm update
# Cache
cache:
php artisan config:cache
php artisan route:cache
php artisan view:cache
clear:
php artisan config:clear
php artisan route:clear
php artisan view:clear
php artisan cache:clear
# Jabali Agent
agent-restart:
sudo systemctl restart jabali-agent
agent-status:
sudo systemctl status jabali-agent
agent-logs:
sudo tail -f /var/log/jabali/agent.log
agent-test:
@echo '{"action":"ping"}' | sudo socat - UNIX-CONNECT:/var/run/jabali/agent.sock
# Filament
filament-assets:
php artisan filament:assets
# Tinker
tinker:
php artisan tinker
# Cleanup
clean:
rm -rf node_modules
rm -rf vendor
rm -rf bootstrap/cache/*.php
rm -rf storage/framework/cache/data/*
rm -rf storage/framework/sessions/*
rm -rf storage/framework/views/*
# Help
help:
@echo "Available targets:"
@echo " dev - Start development servers (serve, queue, pail, vite)"
@echo " test - Run PHPUnit tests"
@echo " lint - Check code style with Pint"
@echo " fix - Fix code style with Pint"
@echo " migrate - Run database migrations"
@echo " fresh - Fresh migrate and seed"
@echo " build - Build frontend assets"
@echo " cache - Cache config, routes, views"
@echo " clear - Clear all caches"
@echo " agent-* - Jabali agent management"

160
README.md Normal file
View File

@@ -0,0 +1,160 @@
<p align="center">
<img src="public/images/jabali_logo.svg" alt="Jabali Panel" width="140">
</p>
<h1 align="center">Jabali Panel</h1>
A modern web hosting control panel for WordPress and general PHP hosting. Jabali focuses on clean multi-tenant isolation, safe automation, and a consistent admin/user experience. It ships with an agent for privileged tasks, built-in mail and DNS management, migrations from common panels, and a security center that keeps critical services in check. The UI is designed to be fast, predictable, and easy to operate on a single server. Administrators get clear visibility into services, SSL, DNS, backups, and security posture, while users get a streamlined workflow for domains, email, WordPress, files, databases, and PHP settings. The goal is simple: reduce the operational burden of hosting by making common tasks safe, repeatable, and easy to audit.
Version: 0.9-rc48 (release candidate)
This is a release candidate. Expect rapid iteration and breaking changes until 1.0.
## Highlights
- Per-user Linux accounts and PHP-FPM isolation
- Root agent for DNS, SSL, mail, backups, and migrations
- cPanel and WHM migrations with step-by-step logs
- Built-in mail stack with webmail SSO
- DNS templates with optional DNSSEC
- User and server backups with schedules and retention
- WordPress management (install, updates, scans, and SSO)
- Security center with firewall, Fail2ban, and ClamAV
## Installation
GitHub install:
```
curl -fsSL https://raw.githubusercontent.com/shukiv/jabali-panel/main/install.sh | sudo bash
```
Optional flags:
- `JABALI_MINIMAL=1` for core-only install
- `JABALI_FULL=1` to force all optional components
After install:
- Admin panel: `https://your-host/jabali-admin`
- User panel: `https://your-host/jabali-panel`
- Webmail: `https://your-host/webmail`
## Feature Map
### Admin Panel
- Dashboard with stats, health, and recent activity
- User management with suspension and quotas
- Service manager for systemd services
- PHP version and pool management
- DNS zones, templates, and DNSSEC
- SSL issuance and renewals
- IP address assignments
- Backups and restores (local + remote)
- Migrations (cPanel restore, WHM downloads)
- Security center (firewall, Fail2ban, ClamAV, scans)
- Audit logs and notifications
### User Panel
- Domains, redirects, and Nginx config
- DNS records editor
- Mail domains, mailboxes, and forwarders
- Webmail SSO (Roundcube)
- WordPress manager (install, scan, SSO)
- File manager plus SFTP/SSH keys
- Databases and permissions
- PHP settings per account
- SSL management
- Cron jobs
- Backups and restore
- Logs and statistics
- Protected directories
### Platform
- Root-level agent for privileged operations
- Queue-backed jobs for long-running tasks
- Health monitor with auto-restarts and alerts
- Redis ACL isolation for WordPress caching
- Multi-language UI
## Screenshots
Admin panel:
- Dashboard: ![Admin Dashboard](docs/screenshots/admin-dashboard.png)
- Server Status: ![Server Status](docs/screenshots/admin-server-status.png)
- Server Settings: ![Server Settings](docs/screenshots/admin-server-settings.png)
- Security Center: ![Security Center](docs/screenshots/admin-security.png)
- Users: ![User Management](docs/screenshots/admin-users.png)
- SSL Manager: ![SSL Manager](docs/screenshots/admin-ssl-manager.png)
- DNS Zones: ![DNS Zones](docs/screenshots/admin-dns-zones.png)
- Backups: ![Admin Backups](docs/screenshots/admin-backups.png)
- Services: ![Services](docs/screenshots/admin-services.png)
User panel:
- Dashboard: ![User Dashboard](docs/screenshots/user-dashboard.png)
- Domain Management: ![User Domains](docs/screenshots/user-domains.png)
- Backups: ![User Backups](docs/screenshots/user-backups.png)
- cPanel Migration: ![cPanel Migration](docs/screenshots/user-cpanel-migration.png)
## Architecture
- Control plane: Laravel app with Filament panels
- Data plane: root agent handling privileged operations
- Job queue: async tasks and migration steps
- Logging: panel and agent logs for troubleshooting
Service stack (single-node default):
- Nginx + PHP-FPM
- MariaDB (user databases)
- SQLite (panel metadata by default)
- Postfix, Dovecot, Rspamd
- BIND9 (DNS)
- Redis
- Fail2ban and ClamAV (optional)
## Requirements
- Fresh Debian 12 or 13 install (no pre-existing web or mail stack)
- A domain for panel and mail (with glue records if hosting DNS)
- PTR (reverse DNS) for mail hostname
- Open ports: 22, 80, 443, 25, 465, 587, 993, 995, 53
## Upgrades
```
cd /var/www/jabali
php artisan jabali:upgrade
```
Check for updates only:
```
php artisan jabali:upgrade --check
```
## CLI
```
jabali --help
jabali backup create <user>
jabali backup restore <path> --user=<user>
jabali cpanel analyze <file>
jabali cpanel restore <file> <user>
```
## Development
```
composer dev
php artisan test --compact
./vendor/bin/pint
```
## License
MIT

8
TODO.md Normal file
View File

@@ -0,0 +1,8 @@
# TODO.md
Keep this list current as work progresses.
- [ ] Verify server updates refresh/upgrade output appears in the accordion.
- [ ] Confirm WAF whitelist + blocked requests tables refresh correctly after changes.
- [ ] Validate sysstat collection interval (10s) and chart intervals align.
- [ ] Audit installer/uninstaller parity for newly added packages.

1
VERSION Normal file
View File

@@ -0,0 +1 @@
VERSION=0.9-rc50

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\CreatesNewUsers;
use Laravel\Jetstream\Jetstream;
class CreateNewUser implements CreatesNewUsers
{
use PasswordValidationRules;
/**
* Validate and create a newly registered user.
*
* @param array<string, string> $input
*/
public function create(array $input): User
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'password' => $this->passwordRules(),
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '',
])->validate();
return User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => Hash::make($input['password']),
]);
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Actions\Fortify;
use Illuminate\Validation\Rules\Password;
trait PasswordValidationRules
{
/**
* Get the validation rules used to validate passwords.
*
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
*/
protected function passwordRules(): array
{
return [
'required',
'string',
Password::min(8)
->mixedCase()
->numbers(),
'confirmed',
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\ResetsUserPasswords;
class ResetUserPassword implements ResetsUserPasswords
{
use PasswordValidationRules;
/**
* Validate and reset the user's forgotten password.
*
* @param array<string, string> $input
*/
public function reset(User $user, array $input): void
{
Validator::make($input, [
'password' => $this->passwordRules(),
])->validate();
$user->forceFill([
'password' => Hash::make($input['password']),
])->save();
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
class UpdateUserPassword implements UpdatesUserPasswords
{
use PasswordValidationRules;
/**
* Validate and update the user's password.
*
* @param array<string, string> $input
*/
public function update(User $user, array $input): void
{
Validator::make($input, [
'current_password' => ['required', 'string', 'current_password:web'],
'password' => $this->passwordRules(),
], [
'current_password.current_password' => __('The provided password does not match your current password.'),
])->validateWithBag('updatePassword');
$user->forceFill([
'password' => Hash::make($input['password']),
])->save();
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
{
/**
* Validate and update the given user's profile information.
*
* @param array<string, mixed> $input
*/
public function update(User $user, array $input): void
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($user->id)],
'photo' => ['nullable', 'mimes:jpg,jpeg,png', 'max:1024'],
])->validateWithBag('updateProfileInformation');
if (isset($input['photo'])) {
$user->updateProfilePhoto($input['photo']);
}
if ($input['email'] !== $user->email &&
$user instanceof MustVerifyEmail) {
$this->updateVerifiedUser($user, $input);
} else {
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
])->save();
}
}
/**
* Update the given verified user's profile information.
*
* @param array<string, string> $input
*/
protected function updateVerifiedUser(User $user, array $input): void
{
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
'email_verified_at' => null,
])->save();
$user->sendEmailVerificationNotification();
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Actions\Jetstream;
use App\Models\User;
use Laravel\Jetstream\Contracts\DeletesUsers;
class DeleteUser implements DeletesUsers
{
/**
* Delete the given user.
*/
public function delete(User $user): void
{
$user->deleteProfilePhoto();
$user->tokens->each->delete();
$user->delete();
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\DnsSetting;
use App\Models\User;
use App\Services\AdminNotificationService;
use Illuminate\Console\Command;
class CheckDiskQuotas extends Command
{
protected $signature = 'jabali:check-quotas {--threshold=90 : Percentage threshold for warning}';
protected $description = 'Check disk quotas and send notifications for users exceeding threshold';
public function handle(): int
{
if (!DnsSetting::get('quotas_enabled', false)) {
$this->info('Disk quotas are not enabled.');
return Command::SUCCESS;
}
$threshold = (int) $this->option('threshold');
$this->info("Checking disk quotas (threshold: {$threshold}%)...");
$users = User::where('is_admin', false)->get();
$warnings = 0;
foreach ($users as $user) {
$usage = $this->getUserQuotaUsage($user->username);
if ($usage === null) {
continue;
}
if ($usage['percent'] >= $threshold) {
$this->warn("User {$user->username} at {$usage['percent']}% quota usage");
AdminNotificationService::diskQuotaWarning($user->username, $usage['percent']);
$warnings++;
}
}
$this->info("Quota check complete. {$warnings} warning(s) sent.");
return Command::SUCCESS;
}
private function getUserQuotaUsage(string $username): ?array
{
$output = [];
$returnVar = 0;
exec("quota -u {$username} 2>/dev/null", $output, $returnVar);
if ($returnVar !== 0 || empty($output)) {
return null;
}
// Parse quota output
foreach ($output as $line) {
if (preg_match('/^\s*\/\S+\s+(\d+)\s+(\d+)\s+(\d+)/', $line, $matches)) {
$used = (int) $matches[1];
$soft = (int) $matches[2];
$hard = (int) $matches[3];
$limit = $soft > 0 ? $soft : $hard;
if ($limit > 0) {
return [
'used' => $used,
'limit' => $limit,
'percent' => (int) round(($used / $limit) * 100),
];
}
}
}
return null;
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\AdminNotificationService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
class CheckFail2banAlerts extends Command
{
protected $signature = 'jabali:check-fail2ban';
protected $description = 'Check fail2ban logs for recent bans and send notifications';
public function handle(): int
{
$this->info('Checking fail2ban for recent bans...');
$logFile = '/var/log/fail2ban.log';
if (!file_exists($logFile)) {
$this->info('Fail2ban log not found.');
return Command::SUCCESS;
}
// Get last check position
$lastPosition = (int) Cache::get('fail2ban_check_position', 0);
$currentSize = filesize($logFile);
// If log was rotated, start from beginning
if ($currentSize < $lastPosition) {
$lastPosition = 0;
}
$handle = fopen($logFile, 'r');
if (!$handle) {
$this->error('Cannot open fail2ban log.');
return Command::FAILURE;
}
fseek($handle, $lastPosition);
$banCount = 0;
$bans = [];
while (($line = fgets($handle)) !== false) {
// Match fail2ban ban entries
// Format: 2024-01-15 10:30:45,123 fail2ban.actions [12345]: NOTICE [sshd] Ban 192.168.1.100
if (preg_match('/\[([^\]]+)\]\s+Ban\s+(\S+)/', $line, $matches)) {
$service = $matches[1];
$ip = $matches[2];
$key = "{$service}:{$ip}";
if (!isset($bans[$key])) {
$bans[$key] = [
'service' => $service,
'ip' => $ip,
'count' => 0,
];
}
$bans[$key]['count']++;
$banCount++;
}
}
$newPosition = ftell($handle);
fclose($handle);
// Save new position
Cache::put('fail2ban_check_position', $newPosition, now()->addDays(7));
// Send notifications for each unique IP/service combination
foreach ($bans as $ban) {
$this->warn("Ban detected: {$ban['ip']} on {$ban['service']} ({$ban['count']} times)");
AdminNotificationService::loginFailure($ban['ip'], $ban['service'], $ban['count']);
}
$this->info("Fail2ban check complete. {$banCount} ban(s) found.");
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,251 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\DnsSetting;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
class CheckFileIntegrity extends Command
{
protected $signature = 'jabali:check-integrity
{--fix : Restore modified files from Git}
{--notify : Send email notification if changes detected}';
protected $description = 'Check for unauthorized modifications to Jabali core files using Git';
protected array $protectedPaths = [
'app/',
'config/',
'routes/',
'database/migrations/',
'resources/views/',
'public/index.php',
'artisan',
'composer.json',
'composer.lock',
];
protected array $ignoredPaths = [
'storage/',
'bootstrap/cache/',
'public/build/',
'public/vendor/',
'node_modules/',
'.env',
];
public function handle(): int
{
$basePath = base_path();
// Check if we're in a Git repository
if (!is_dir("$basePath/.git")) {
$this->error('Not a Git repository. File integrity checking requires Git.');
return 1;
}
$this->info('Checking file integrity...');
// Get modified files from Git
$modifiedFiles = $this->getModifiedFiles($basePath);
$untrackedFiles = $this->getUntrackedFiles($basePath);
// Filter to only protected paths
$modifiedFiles = $this->filterProtectedPaths($modifiedFiles);
$untrackedFiles = $this->filterProtectedPaths($untrackedFiles);
$hasChanges = !empty($modifiedFiles) || !empty($untrackedFiles);
if (!$hasChanges) {
$this->info('All core files are intact. No unauthorized modifications detected.');
DnsSetting::set('last_integrity_check', now()->toIso8601String());
DnsSetting::set('last_integrity_status', 'clean');
return 0;
}
// Report modified files
if (!empty($modifiedFiles)) {
$this->warn('Modified files detected:');
$this->table(['File', 'Status'], array_map(fn($f) => [$f['file'], $f['status']], $modifiedFiles));
}
// Report untracked files in protected directories
if (!empty($untrackedFiles)) {
$this->warn('Untracked files in protected directories:');
foreach ($untrackedFiles as $file) {
$this->line(" - $file");
}
}
// Store status
DnsSetting::set('last_integrity_check', now()->toIso8601String());
DnsSetting::set('last_integrity_status', 'modified');
DnsSetting::set('integrity_modified_files', json_encode($modifiedFiles));
DnsSetting::set('integrity_untracked_files', json_encode($untrackedFiles));
// Send notification if requested
if ($this->option('notify')) {
$this->sendNotification($modifiedFiles, $untrackedFiles);
}
// Restore files if requested
if ($this->option('fix')) {
return $this->restoreFiles($basePath, $modifiedFiles);
}
$this->newLine();
$this->warn('Run with --fix to restore modified files from Git.');
return 1;
}
protected function getModifiedFiles(string $basePath): array
{
$output = [];
exec("cd $basePath && git status --porcelain 2>/dev/null", $output);
$files = [];
foreach ($output as $line) {
if (strlen($line) < 3) continue;
$status = trim(substr($line, 0, 2));
$file = trim(substr($line, 3));
// Skip untracked files (handled separately)
if ($status === '??') continue;
// Map status codes
$statusMap = [
'M' => 'Modified',
'A' => 'Added',
'D' => 'Deleted',
'R' => 'Renamed',
'C' => 'Copied',
'MM' => 'Modified (staged + unstaged)',
'AM' => 'Added + Modified',
];
$files[] = [
'file' => $file,
'status' => $statusMap[$status] ?? $status,
'raw_status' => $status,
];
}
return $files;
}
protected function getUntrackedFiles(string $basePath): array
{
$output = [];
exec("cd $basePath && git status --porcelain 2>/dev/null | grep '^??' | cut -c4-", $output);
return $output;
}
protected function filterProtectedPaths(array $files): array
{
return array_filter($files, function ($item) {
$file = is_array($item) ? $item['file'] : $item;
// Check if in ignored paths
foreach ($this->ignoredPaths as $ignored) {
if (str_starts_with($file, $ignored)) {
return false;
}
}
// Check if in protected paths
foreach ($this->protectedPaths as $protected) {
if (str_starts_with($file, $protected)) {
return true;
}
}
return false;
});
}
protected function restoreFiles(string $basePath, array $modifiedFiles): int
{
if (empty($modifiedFiles)) {
$this->info('No files to restore.');
return 0;
}
$this->warn('Restoring modified files from Git...');
foreach ($modifiedFiles as $file) {
$filePath = $file['file'];
$status = $file['raw_status'];
// Skip deleted files - they need to be restored
if (str_contains($status, 'D')) {
exec("cd $basePath && git checkout HEAD -- " . escapeshellarg($filePath) . " 2>&1", $output, $code);
} else {
// Reset modifications
exec("cd $basePath && git checkout -- " . escapeshellarg($filePath) . " 2>&1", $output, $code);
}
if ($code === 0) {
$this->info(" Restored: $filePath");
} else {
$this->error(" Failed to restore: $filePath");
}
}
// Clear caches after restoration
$this->call('cache:clear');
$this->call('config:clear');
$this->call('view:clear');
DnsSetting::set('last_integrity_status', 'restored');
$this->info('File restoration complete.');
return 0;
}
protected function sendNotification(array $modifiedFiles, array $untrackedFiles): void
{
$recipients = DnsSetting::get('admin_email_recipients');
if (empty($recipients)) {
$this->warn('No admin email recipients configured. Skipping notification.');
return;
}
$hostname = gethostname();
$subject = "[Jabali Security] File integrity alert on $hostname";
$message = "File integrity check detected unauthorized modifications:\n\n";
if (!empty($modifiedFiles)) {
$message .= "MODIFIED FILES:\n";
foreach ($modifiedFiles as $file) {
$message .= " - {$file['file']} ({$file['status']})\n";
}
$message .= "\n";
}
if (!empty($untrackedFiles)) {
$message .= "UNTRACKED FILES IN PROTECTED DIRECTORIES:\n";
foreach ($untrackedFiles as $file) {
$message .= " - $file\n";
}
$message .= "\n";
}
$message .= "To restore files, run: php artisan jabali:check-integrity --fix\n";
$message .= "\nTime: " . now()->toDateTimeString();
foreach (explode(',', $recipients) as $email) {
$email = trim($email);
if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
mail($email, $subject, $message);
}
}
$this->info('Notification sent to admin.');
}
}

View File

@@ -0,0 +1,105 @@
<?php
namespace App\Console\Commands\Jabali;
use App\Models\Domain;
use App\Models\User;
use Illuminate\Console\Command;
class DomainCommand extends Command {
protected $signature = 'domain {action=list : list|create|show|delete} {--id= : Domain ID or name} {--user= : User ID or email} {--domain= : Domain name} {--force : Skip confirmation}';
protected $description = 'Manage domains: list, create, show, delete';
public function handle(): int {
return match($this->argument('action')) {
'list' => $this->listDomains(),
'create' => $this->createDomain(),
'show' => $this->showDomain(),
'delete' => $this->deleteDomain(),
default => $this->error("Unknown action. Use: list, create, show, delete") ?? 1,
};
}
private function listDomains(): int {
$domains = Domain::with('user')->get();
if ($domains->isEmpty()) { $this->warn('No domains found.'); return 0; }
$this->table(['ID', 'Domain', 'User', 'Document Root', 'SSL', 'Created'], $domains->map(fn($d) => [$d->id, $d->domain, $d->user->email ?? '-', $d->document_root ?? '/var/www/'.$d->domain, $d->ssl_enabled ? '✓' : '✗', $d->created_at->format('Y-m-d')])->toArray());
return 0;
}
private function createDomain(): int {
$domain = $this->option('domain') ?? $this->ask('Domain name');
// Clean and validate domain
$domain = $this->cleanDomain($domain);
// Validate FQDN format
if (!$this->isValidFqdn($domain)) {
$this->error("Invalid domain format: '$domain'");
$this->line("Domain must be a valid FQDN (e.g., example.com, sub.example.com)");
return 1;
}
$userId = $this->option('user') ?? $this->ask('User ID or email');
$user = is_numeric($userId) ? User::find($userId) : User::where('email', $userId)->first();
if (!$user) { $this->error("User not found: $userId"); return 1; }
if (Domain::where('domain', $domain)->exists()) { $this->error("Domain already exists!"); return 1; }
// Use proper document root structure
$documentRoot = "/home/{$user->username}/domains/{$domain}/public_html";
$d = Domain::create(['domain' => $domain, 'user_id' => $user->id, 'document_root' => $documentRoot]);
$this->info("✓ Created domain #{$d->id}: {$domain}");
$this->line(" Document root: {$documentRoot}");
return 0;
}
private function cleanDomain(string $domain): string {
// Remove protocol
$domain = preg_replace('#^https?://#i', '', $domain);
// Remove trailing slash and path
$domain = strtok($domain, '/');
// Remove port if present
$domain = strtok($domain, ':');
// Lowercase
return strtolower(trim($domain));
}
private function isValidFqdn(string $domain): bool {
// Check basic structure
if (empty($domain) || strlen($domain) > 253) return false;
if (strpos($domain, ' ') !== false) return false;
if (!preg_match('/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+$/', $domain)) return false;
// Must have at least one dot (e.g., example.com)
if (strpos($domain, '.') === false) return false;
// TLD must be at least 2 chars
$parts = explode('.', $domain);
$tld = end($parts);
if (strlen($tld) < 2) return false;
return true;
}
private function showDomain(): int {
$domain = $this->findDomain();
if (!$domain) return 1;
$this->table(['Field', 'Value'], [['ID', $domain->id], ['Domain', $domain->domain], ['User', $domain->user->email ?? '-'], ['Document Root', $domain->document_root], ['SSL', $domain->ssl_enabled ? 'Yes' : 'No'], ['Created', $domain->created_at]]);
return 0;
}
private function deleteDomain(): int {
$domain = $this->findDomain();
if (!$domain) return 1;
if (!$this->option('force') && !$this->confirm("Delete {$domain->domain}?")) return 0;
$domain->delete();
$this->info("✓ Deleted domain #{$domain->id}");
return 0;
}
private function findDomain(): ?Domain {
$id = $this->option('id') ?? $this->ask('Domain ID or name');
$domain = is_numeric($id) ? Domain::find($id) : Domain::where('domain', $id)->first();
if (!$domain) $this->error("Domain not found: $id");
return $domain;
}
}

View File

@@ -0,0 +1,389 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Jabali;
use App\Models\Domain;
use App\Models\ServerImport;
use App\Models\ServerImportAccount;
use App\Models\User;
use App\Services\Agent\AgentClient;
use Exception;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class ImportProcessCommand extends Command
{
protected $signature = 'import:process {import_id : The server import ID to process}';
protected $description = 'Process a server import job (cPanel/DirectAdmin migration)';
private ?AgentClient $agent = null;
public function handle(): int
{
$importId = (int) $this->argument('import_id');
$import = ServerImport::with('accounts')->find($importId);
if (!$import) {
$this->error("Import not found: $importId");
return 1;
}
$this->info("Processing import: {$import->name} (ID: {$import->id})");
$selectedAccountIds = $import->selected_accounts ?? [];
$options = $import->import_options ?? [];
if (empty($selectedAccountIds)) {
$import->update([
'status' => 'failed',
'current_task' => null,
]);
$import->addError('No accounts selected for import');
return 1;
}
$accounts = ServerImportAccount::whereIn('id', $selectedAccountIds)
->where('server_import_id', $import->id)
->get();
$totalAccounts = $accounts->count();
$completedAccounts = 0;
$import->addLog("Starting import of $totalAccounts account(s)");
foreach ($accounts as $account) {
try {
$this->processAccount($import, $account, $options);
$completedAccounts++;
$progress = (int) (($completedAccounts / $totalAccounts) * 100);
$import->update(['progress' => $progress]);
} catch (Exception $e) {
$account->update([
'status' => 'failed',
'error' => $e->getMessage(),
]);
$account->addLog("Import failed: " . $e->getMessage());
$import->addError("Account {$account->source_username}: " . $e->getMessage());
}
}
$failedCount = $accounts->where('status', 'failed')->count();
if ($failedCount === $totalAccounts) {
$import->update([
'status' => 'failed',
'current_task' => null,
'completed_at' => now(),
'progress' => 100,
]);
} elseif ($failedCount > 0) {
$import->update([
'status' => 'completed',
'current_task' => null,
'completed_at' => now(),
'progress' => 100,
]);
$import->addLog("Completed with $failedCount failed account(s)");
} else {
$import->update([
'status' => 'completed',
'current_task' => null,
'completed_at' => now(),
'progress' => 100,
]);
$import->addLog("All accounts imported successfully");
}
$this->info("Import completed. Success: " . ($totalAccounts - $failedCount) . ", Failed: $failedCount");
return 0;
}
private function getAgent(): AgentClient
{
if ($this->agent === null) {
$this->agent = new AgentClient();
}
return $this->agent;
}
private function processAccount(ServerImport $import, ServerImportAccount $account, array $options): void
{
$account->update([
'status' => 'importing',
'progress' => 0,
'current_task' => 'Starting import...',
]);
$account->addLog("Starting import for account: {$account->source_username}");
$import->update(['current_task' => "Importing account: {$account->source_username}"]);
// Step 1: Create user
$account->update(['current_task' => 'Creating user...', 'progress' => 10]);
$user = $this->createUser($account);
$account->addLog("Created user: {$user->email}");
// Step 2: Create domains
if ($account->main_domain) {
$account->update(['current_task' => 'Creating domains...', 'progress' => 20]);
$this->createDomains($account, $user);
$account->addLog("Created domains");
}
// Step 3: Import files
if ($options['files'] ?? true) {
$account->update(['current_task' => 'Importing files...', 'progress' => 40]);
$this->importFiles($import, $account, $user);
$account->addLog("Files imported");
}
// Step 4: Import databases
if (($options['databases'] ?? true) && !empty($account->databases)) {
$account->update(['current_task' => 'Importing databases...', 'progress' => 60]);
$this->importDatabases($import, $account, $user);
$account->addLog("Databases imported");
}
// Step 5: Import emails
if (($options['emails'] ?? true) && !empty($account->email_accounts)) {
$account->update(['current_task' => 'Importing email accounts...', 'progress' => 80]);
$this->importEmails($import, $account, $user);
$account->addLog("Email accounts imported");
}
$account->update([
'status' => 'completed',
'progress' => 100,
'current_task' => null,
]);
$account->addLog("Import completed successfully");
}
private function createUser(ServerImportAccount $account): User
{
// Check if user already exists with this username
$existingUser = User::where('username', $account->target_username)->first();
if ($existingUser) {
$account->addLog("User already exists: {$account->target_username}");
return $existingUser;
}
// Generate a temporary password
$password = Str::random(16);
// Create user via agent
$result = $this->getAgent()->createUser($account->target_username, $password);
if (!($result['success'] ?? false)) {
throw new Exception("Failed to create system user: " . ($result['error'] ?? 'Unknown error'));
}
// Create user in database
$user = User::create([
'name' => $account->target_username,
'email' => $account->email ?: "{$account->target_username}@localhost",
'username' => $account->target_username,
'password' => Hash::make($password),
]);
$account->addLog("Created user with temporary password. User should reset password.");
return $user;
}
private function createDomains(ServerImportAccount $account, User $user): void
{
// Create main domain
if ($account->main_domain) {
$existingDomain = Domain::where('domain', $account->main_domain)->first();
if (!$existingDomain) {
$result = $this->getAgent()->domainCreate($user->username, $account->main_domain);
if ($result['success'] ?? false) {
Domain::create([
'domain' => $account->main_domain,
'user_id' => $user->id,
'document_root' => "/home/{$user->username}/domains/{$account->main_domain}/public",
'is_active' => true,
]);
$account->addLog("Created main domain: {$account->main_domain}");
} else {
$account->addLog("Warning: Failed to create main domain: " . ($result['error'] ?? 'Unknown'));
}
} else {
$account->addLog("Main domain already exists: {$account->main_domain}");
}
}
// Create addon domains
foreach ($account->addon_domains ?? [] as $domain) {
$existingDomain = Domain::where('domain', $domain)->first();
if (!$existingDomain) {
$result = $this->getAgent()->domainCreate($user->username, $domain);
if ($result['success'] ?? false) {
Domain::create([
'domain' => $domain,
'user_id' => $user->id,
'document_root' => "/home/{$user->username}/domains/{$domain}/public",
'is_active' => true,
]);
$account->addLog("Created addon domain: {$domain}");
} else {
$account->addLog("Warning: Failed to create addon domain: {$domain}");
}
}
}
}
private function importFiles(ServerImport $import, ServerImportAccount $account, User $user): void
{
if ($import->import_method !== 'backup_file' || !$import->backup_path) {
$account->addLog("File import skipped - not a backup file import");
return;
}
$backupPath = Storage::disk('local')->path($import->backup_path);
if (!file_exists($backupPath)) {
$account->addLog("Warning: Backup file not found");
return;
}
$extractDir = "/tmp/import_{$import->id}_{$account->id}_" . time();
if (!mkdir($extractDir, 0755, true)) {
$account->addLog("Warning: Failed to create extraction directory");
return;
}
try {
$username = $account->source_username;
if ($import->source_type === 'cpanel') {
// Extract home directory from cPanel backup
$cmd = "tar -xzf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) .
" --wildcards '*/{$username}/homedir/*' '*/homedir/*' 2>/dev/null";
exec($cmd, $output, $code);
// Find extracted files
$homeDirs = glob("$extractDir/**/homedir", GLOB_ONLYDIR) ?:
glob("$extractDir/*/homedir", GLOB_ONLYDIR) ?:
glob("$extractDir/homedir", GLOB_ONLYDIR) ?: [];
foreach ($homeDirs as $homeDir) {
// Copy public_html to the domain
$publicHtml = "$homeDir/public_html";
if (is_dir($publicHtml) && $account->main_domain) {
$destDir = "/home/{$user->username}/domains/{$account->main_domain}/public";
if (is_dir($destDir)) {
exec("cp -r " . escapeshellarg($publicHtml) . "/* " . escapeshellarg($destDir) . "/ 2>&1");
exec("chown -R " . escapeshellarg($user->username) . ":" . escapeshellarg($user->username) . " " . escapeshellarg($destDir) . " 2>&1");
$account->addLog("Copied public_html to {$account->main_domain}");
}
}
}
} else {
// Extract from DirectAdmin backup
$cmd = "tar -xzf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) .
" --wildcards 'domains/*' 'backup/domains/*' 2>/dev/null";
exec($cmd, $output, $code);
// Find domain directories
$domainDirs = glob("$extractDir/**/domains/*", GLOB_ONLYDIR) ?:
glob("$extractDir/domains/*", GLOB_ONLYDIR) ?: [];
foreach ($domainDirs as $domainDir) {
$domain = basename($domainDir);
$publicHtml = "$domainDir/public_html";
if (is_dir($publicHtml)) {
$destDir = "/home/{$user->username}/domains/{$domain}/public";
if (is_dir($destDir)) {
exec("cp -r " . escapeshellarg($publicHtml) . "/* " . escapeshellarg($destDir) . "/ 2>&1");
exec("chown -R " . escapeshellarg($user->username) . ":" . escapeshellarg($user->username) . " " . escapeshellarg($destDir) . " 2>&1");
$account->addLog("Copied files for domain: {$domain}");
}
}
}
}
} finally {
// Cleanup
exec("rm -rf " . escapeshellarg($extractDir));
}
}
private function importDatabases(ServerImport $import, ServerImportAccount $account, User $user): void
{
if ($import->import_method !== 'backup_file' || !$import->backup_path) {
$account->addLog("Database import skipped - not a backup file import");
return;
}
$backupPath = Storage::disk('local')->path($import->backup_path);
if (!file_exists($backupPath)) {
return;
}
$extractDir = "/tmp/import_db_{$import->id}_{$account->id}_" . time();
if (!mkdir($extractDir, 0755, true)) {
return;
}
try {
// Extract MySQL dumps
if ($import->source_type === 'cpanel') {
$cmd = "tar -xzf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) .
" --wildcards '*/mysql/*.sql' 'mysql/*.sql' 2>/dev/null";
} else {
$cmd = "tar -xzf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) .
" --wildcards 'backup/databases/*.sql' 'databases/*.sql' 2>/dev/null";
}
exec($cmd, $output, $code);
// Find SQL files
$sqlFiles = [];
exec("find " . escapeshellarg($extractDir) . " -name '*.sql' -type f 2>/dev/null", $sqlFiles);
foreach ($sqlFiles as $sqlFile) {
$dbName = basename($sqlFile, '.sql');
// Create database name with user prefix
$newDbName = substr($user->username . '_' . preg_replace('/^[^_]+_/', '', $dbName), 0, 64);
// Create database via agent
$result = $this->getAgent()->mysqlCreateDatabase($user->username, $newDbName);
if ($result['success'] ?? false) {
// Import data
$cmd = "mysql " . escapeshellarg($newDbName) . " < " . escapeshellarg($sqlFile) . " 2>&1";
exec($cmd, $importOutput, $importCode);
if ($importCode === 0) {
$account->addLog("Imported database: {$newDbName}");
} else {
$account->addLog("Warning: Database created but import failed: {$newDbName}");
}
} else {
$account->addLog("Warning: Failed to create database: {$newDbName}");
}
}
} finally {
exec("rm -rf " . escapeshellarg($extractDir));
}
}
private function importEmails(ServerImport $import, ServerImportAccount $account, User $user): void
{
// Email import is complex and requires the email system to be configured
// For now, just log the email accounts that would be created
foreach ($account->email_accounts ?? [] as $emailAccount) {
$account->addLog("Email account found (not imported): {$emailAccount}@{$account->main_domain}");
}
$account->addLog("Note: Email accounts must be recreated manually");
}
}

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Jabali;
use App\Models\User;
use App\Services\Agent\AgentClient;
use Illuminate\Console\Command;
class MigrateRedisUsersCommand extends Command
{
protected $signature = 'jabali:migrate-redis-users
{--dry-run : Show what would be done without making changes}
{--force : Recreate credentials even if they already exist}';
protected $description = 'Create Redis ACL users for existing Jabali users';
private AgentClient $agent;
private int $created = 0;
private int $skipped = 0;
private int $failed = 0;
public function __construct()
{
parent::__construct();
$this->agent = new AgentClient();
}
public function handle(): int
{
$dryRun = $this->option('dry-run');
$force = $this->option('force');
$this->info('Migrating existing users to Redis ACL...');
if ($dryRun) {
$this->warn('DRY RUN - no changes will be made');
}
$this->newLine();
$users = User::where('role', 'user')->get();
if ($users->isEmpty()) {
$this->info('No users found to migrate.');
return 0;
}
$this->info("Found {$users->count()} users to process...");
$this->newLine();
foreach ($users as $user) {
$this->processUser($user, $dryRun, $force);
}
$this->newLine();
$this->info('Migration Complete');
$this->table(
['Metric', 'Count'],
[
['Created', $this->created],
['Skipped', $this->skipped],
['Failed', $this->failed],
]
);
return $this->failed > 0 ? 1 : 0;
}
private function processUser(User $user, bool $dryRun, bool $force): void
{
$homeDir = "/home/{$user->username}";
$credFile = "{$homeDir}/.redis_credentials";
// Check if credentials file already exists
if (file_exists($credFile) && !$force) {
$this->line(" [skip] {$user->username} - credentials file already exists");
$this->skipped++;
return;
}
if ($dryRun) {
$this->line(" [would create] {$user->username}");
$this->created++;
return;
}
$this->line(" Processing: {$user->username}...");
// Generate password before sending to agent so we can save it
$redisPassword = bin2hex(random_bytes(16)); // 32 char password
$redisUser = 'jabali_' . $user->username;
try {
$result = $this->agent->send('redis.create_user', [
'username' => $user->username,
'password' => $redisPassword,
]);
if ($result['success'] ?? false) {
// Save credentials file
$credContent = "REDIS_USER={$redisUser}\n" .
"REDIS_PASS={$redisPassword}\n" .
"REDIS_PREFIX={$user->username}:\n";
file_put_contents($credFile, $credContent);
chmod($credFile, 0600);
chown($credFile, $user->username);
chgrp($credFile, $user->username);
$this->info(" ✓ Created Redis user for {$user->username}");
$this->created++;
} else {
$error = $result['error'] ?? 'Unknown error';
$this->error(" ✗ Failed for {$user->username}: {$error}");
$this->failed++;
}
} catch (\Exception $e) {
$this->error(" ✗ Exception for {$user->username}: {$e->getMessage()}");
$this->failed++;
}
}
}

View File

@@ -0,0 +1,463 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Jabali;
use App\Models\Domain;
use App\Models\SslCertificate;
use App\Services\AdminNotificationService;
use App\Services\Agent\AgentClient;
use Carbon\Carbon;
use Exception;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
class SslCheckCommand extends Command
{
protected $signature = 'jabali:ssl-check
{--domain= : Check a specific domain only}
{--issue-only : Only issue certificates for domains without SSL}
{--renew-only : Only renew expiring certificates}';
protected $description = 'Check SSL certificates and automatically issue/renew them';
private AgentClient $agent;
private int $issued = 0;
private int $renewed = 0;
private int $failed = 0;
private int $skipped = 0;
private array $logEntries = [];
private string $logFile = '';
public function __construct()
{
parent::__construct();
$this->agent = new AgentClient();
}
public function handle(): int
{
$this->initializeLogging();
$this->log('Starting SSL certificate check...');
$this->info('Starting SSL certificate check...');
$this->newLine();
$domain = $this->option('domain');
$issueOnly = $this->option('issue-only');
$renewOnly = $this->option('renew-only');
if ($domain) {
$this->processSingleDomain($domain);
} else {
if (!$renewOnly) {
$this->issueMissingCertificates();
}
if (!$issueOnly) {
$this->renewExpiringCertificates();
}
// Check for certificates expiring very soon (7 days) and notify
$this->notifyExpiringSoon();
}
$this->newLine();
$this->info('SSL Check Complete');
$this->table(
['Metric', 'Count'],
[
['Issued', $this->issued],
['Renewed', $this->renewed],
['Failed', $this->failed],
['Skipped', $this->skipped],
]
);
// Log summary
$this->log('');
$this->log('=== SSL Check Complete ===');
$this->log("Issued: {$this->issued}");
$this->log("Renewed: {$this->renewed}");
$this->log("Failed: {$this->failed}");
$this->log("Skipped: {$this->skipped}");
// Save log file
$this->saveLog();
// Clean old logs (older than 3 months)
$this->cleanOldLogs();
return $this->failed > 0 ? 1 : 0;
}
private function initializeLogging(): void
{
$logDir = storage_path('logs/ssl');
try {
if (!is_dir($logDir)) {
mkdir($logDir, 0775, true);
}
// Ensure directory is writable
if (!is_writable($logDir)) {
chmod($logDir, 0775);
}
$this->logFile = $logDir . '/ssl-check-' . date('Y-m-d_H-i-s') . '.log';
} catch (\Exception $e) {
// Fall back to temp directory if storage is not writable
$this->logFile = sys_get_temp_dir() . '/ssl-check-' . date('Y-m-d_H-i-s') . '.log';
}
$this->logEntries = [];
}
private function log(string $message, string $level = 'INFO'): void
{
$timestamp = date('Y-m-d H:i:s');
$this->logEntries[] = "[{$timestamp}] [{$level}] {$message}";
}
private function saveLog(): void
{
if (empty($this->logFile) || empty($this->logEntries)) {
return;
}
try {
$content = implode("\n", $this->logEntries) . "\n";
// Ensure parent directory exists
$logDir = dirname($this->logFile);
if (!is_dir($logDir)) {
@mkdir($logDir, 0775, true);
}
if (@file_put_contents($this->logFile, $content) !== false) {
// Also create/update a symlink to latest log
$latestLink = storage_path('logs/ssl/latest.log');
@unlink($latestLink);
@symlink($this->logFile, $latestLink);
$this->line("Log saved to: {$this->logFile}");
} else {
$this->warn("Could not save log to: {$this->logFile}");
}
} catch (\Exception $e) {
$this->warn("Log save failed: {$e->getMessage()}");
}
}
private function cleanOldLogs(): void
{
$logDir = storage_path('logs/ssl');
$cutoffDate = now()->subMonths(3);
$deletedCount = 0;
foreach (glob("{$logDir}/ssl-check-*.log") as $file) {
$fileTime = filemtime($file);
if ($fileTime < $cutoffDate->timestamp) {
unlink($file);
$deletedCount++;
}
}
if ($deletedCount > 0) {
$this->log("Cleaned up {$deletedCount} old log files (older than 3 months)");
}
}
private function processSingleDomain(string $domainName): void
{
$domain = Domain::where('domain', $domainName)->with(['user', 'sslCertificate'])->first();
if (!$domain) {
$this->log("Domain not found: {$domainName}", 'ERROR');
$this->error("Domain not found: {$domainName}");
$this->failed++;
return;
}
$this->log("Processing domain: {$domainName}");
$this->line("Processing domain: {$domainName}");
$ssl = $domain->sslCertificate;
if (!$ssl || $ssl->status === 'failed') {
$this->issueCertificate($domain);
} elseif ($ssl->needsRenewal()) {
$this->renewCertificate($domain);
} else {
$this->log("Certificate is valid for {$domainName}, expires: {$ssl->expires_at->format('Y-m-d')}");
$this->line(" - Certificate is valid, expires: {$ssl->expires_at->format('Y-m-d')}");
$this->skipped++;
}
}
private function issueMissingCertificates(): void
{
$this->log('Checking domains without SSL certificates...');
$this->info('Checking domains without SSL certificates...');
$domains = Domain::whereDoesntHave('sslCertificate')
->orWhereHas('sslCertificate', function ($q) {
$q->where('status', 'failed')
->where('renewal_attempts', '<', 3)
->where(function ($q2) {
$q2->whereNull('last_check_at')
->orWhere('last_check_at', '<', now()->subHours(6));
});
})
->with(['user', 'sslCertificate'])
->get();
$this->log("Found {$domains->count()} domains without valid SSL");
$this->line("Found {$domains->count()} domains without valid SSL");
foreach ($domains as $domain) {
$this->issueCertificate($domain);
}
}
private function renewExpiringCertificates(): void
{
$this->log('Checking certificates that need renewal...');
$this->info('Checking certificates that need renewal...');
$certificates = SslCertificate::where('auto_renew', true)
->where('type', 'lets_encrypt')
->where('status', 'active')
->where('expires_at', '<=', now()->addDays(30))
->where('renewal_attempts', '<', 5)
->with(['domain.user'])
->get();
$this->log("Found {$certificates->count()} certificates needing renewal");
$this->line("Found {$certificates->count()} certificates needing renewal");
foreach ($certificates as $ssl) {
if ($ssl->domain) {
$this->renewCertificate($ssl->domain);
}
}
}
private function notifyExpiringSoon(): void
{
$certificates = SslCertificate::where('status', 'active')
->where('expires_at', '<=', now()->addDays(7))
->where('expires_at', '>', now())
->with(['domain'])
->get();
foreach ($certificates as $ssl) {
if ($ssl->domain) {
$daysLeft = (int) now()->diffInDays($ssl->expires_at);
$this->log("Certificate expiring soon: {$ssl->domain->domain} ({$daysLeft} days left)", 'WARN');
AdminNotificationService::sslExpiring($ssl->domain->domain, $daysLeft);
}
}
}
private function issueCertificate(Domain $domain): void
{
if (!$domain->user) {
$this->log("Skipping {$domain->domain}: No user associated", 'WARN');
$this->warn(" Skipping {$domain->domain}: No user associated");
$this->skipped++;
return;
}
// Check if domain DNS points to this server
if (!$this->domainPointsToServer($domain->domain)) {
$this->log("Skipping {$domain->domain}: DNS does not point to this server", 'WARN');
$this->warn(" Skipping {$domain->domain}: DNS does not point to this server");
$this->skipped++;
return;
}
$this->log("Issuing SSL for: {$domain->domain} (user: {$domain->user->username})");
$this->line(" Issuing SSL for: {$domain->domain}");
try {
$result = $this->agent->sslIssue(
$domain->domain,
$domain->user->username,
$domain->user->email,
true
);
if ($result['success'] ?? false) {
$expiresAt = isset($result['valid_to']) ? Carbon::parse($result['valid_to']) : now()->addMonths(3);
SslCertificate::updateOrCreate(
['domain_id' => $domain->id],
[
'type' => 'lets_encrypt',
'status' => 'active',
'issuer' => "Let's Encrypt",
'certificate' => $result['certificate'] ?? null,
'issued_at' => now(),
'expires_at' => $expiresAt,
'last_check_at' => now(),
'last_error' => null,
'renewal_attempts' => 0,
'auto_renew' => true,
]
);
$domain->update(['ssl_enabled' => true]);
$this->log("SUCCESS: Certificate issued for {$domain->domain}, expires: {$expiresAt->format('Y-m-d')}", 'SUCCESS');
$this->info(" ✓ Certificate issued successfully");
$this->issued++;
} else {
$error = $result['error'] ?? 'Unknown error';
$ssl = SslCertificate::firstOrNew(['domain_id' => $domain->id]);
$ssl->type = 'lets_encrypt';
$ssl->status = 'failed';
$ssl->last_check_at = now();
$ssl->last_error = $error;
$ssl->increment('renewal_attempts');
$ssl->save();
$this->log("FAILED: Certificate issue for {$domain->domain}: {$error}", 'ERROR');
$this->error(" ✗ Failed: {$error}");
$this->failed++;
// Send admin notification
AdminNotificationService::sslError($domain->domain, $error);
}
} catch (Exception $e) {
$this->log("EXCEPTION: Certificate issue for {$domain->domain}: {$e->getMessage()}", 'ERROR');
$this->error(" ✗ Exception: {$e->getMessage()}");
$this->failed++;
// Send admin notification
AdminNotificationService::sslError($domain->domain, $e->getMessage());
}
}
private function renewCertificate(Domain $domain): void
{
if (!$domain->user) {
$this->log("Skipping renewal for {$domain->domain}: No user associated", 'WARN');
$this->warn(" Skipping {$domain->domain}: No user associated");
$this->skipped++;
return;
}
// Check if domain DNS still points to this server
if (!$this->domainPointsToServer($domain->domain)) {
$this->log("Skipping renewal for {$domain->domain}: DNS does not point to this server", 'WARN');
$this->warn(" Skipping {$domain->domain}: DNS does not point to this server");
$this->skipped++;
return;
}
$this->log("Renewing SSL for: {$domain->domain} (user: {$domain->user->username})");
$this->line(" Renewing SSL for: {$domain->domain}");
try {
$result = $this->agent->sslRenew($domain->domain, $domain->user->username);
if ($result['success'] ?? false) {
$ssl = $domain->sslCertificate;
$expiresAt = isset($result['valid_to']) ? Carbon::parse($result['valid_to']) : now()->addMonths(3);
if ($ssl) {
$ssl->update([
'status' => 'active',
'issued_at' => now(),
'expires_at' => $expiresAt,
'last_check_at' => now(),
'last_error' => null,
'renewal_attempts' => 0,
]);
}
$this->log("SUCCESS: Certificate renewed for {$domain->domain}, expires: {$expiresAt->format('Y-m-d')}", 'SUCCESS');
$this->info(" ✓ Certificate renewed successfully");
$this->renewed++;
} else {
$error = $result['error'] ?? 'Unknown error';
$ssl = $domain->sslCertificate;
if ($ssl) {
$ssl->incrementRenewalAttempts();
$ssl->update(['last_error' => $error]);
}
$this->log("FAILED: Certificate renewal for {$domain->domain}: {$error}", 'ERROR');
$this->error(" ✗ Failed: {$error}");
$this->failed++;
// Send admin notification
AdminNotificationService::sslError($domain->domain, "Renewal failed: {$error}");
}
} catch (Exception $e) {
$this->log("EXCEPTION: Certificate renewal for {$domain->domain}: {$e->getMessage()}", 'ERROR');
$this->error(" ✗ Exception: {$e->getMessage()}");
$this->failed++;
// Send admin notification
AdminNotificationService::sslError($domain->domain, "Renewal exception: {$e->getMessage()}");
}
}
private function domainPointsToServer(string $domain): bool
{
// Get server's public IP
$serverIp = $this->getServerPublicIp();
if (!$serverIp) {
// If we can't determine server IP, assume it's okay to try
return true;
}
// Get domain's DNS resolution
$domainIp = gethostbyname($domain);
// gethostbyname returns the original string if resolution fails
if ($domainIp === $domain) {
return false;
}
return $domainIp === $serverIp;
}
private function getServerPublicIp(): ?string
{
static $cachedIp = null;
if ($cachedIp !== null) {
return $cachedIp ?: null;
}
// Try multiple services to get public IP
$services = [
'https://api.ipify.org',
'https://ipv4.icanhazip.com',
'https://checkip.amazonaws.com',
];
foreach ($services as $service) {
$ip = @file_get_contents($service, false, stream_context_create([
'http' => ['timeout' => 5],
]));
if ($ip) {
$ip = trim($ip);
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$cachedIp = $ip;
return $ip;
}
}
}
$cachedIp = '';
return null;
}
}

View File

@@ -0,0 +1,808 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Jabali;
use Exception;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
use Symfony\Component\Process\Process;
class UpgradeCommand extends Command
{
protected $signature = 'jabali:upgrade
{--check : Only check for updates without upgrading}
{--force : Force upgrade even if already up to date}';
protected $description = 'Upgrade Jabali Panel to the latest version';
private string $basePath;
private string $versionFile;
public function __construct()
{
parent::__construct();
$this->basePath = base_path();
$this->versionFile = $this->basePath.'/VERSION';
}
public function handle(): int
{
if ($this->option('check')) {
return $this->checkForUpdates();
}
return $this->performUpgrade();
}
private function checkForUpdates(): int
{
$this->info('Checking for updates...');
$currentVersion = $this->getCurrentVersion();
$this->line("Current version: <info>{$currentVersion}</info>");
try {
$this->configureGitSafeDirectory();
$this->ensureGitRepository();
$updateSource = $this->fetchUpdates();
// Check if there are updates
$behindCount = trim($this->executeCommandOrFail("git rev-list HEAD..{$updateSource['remoteRef']} --count"));
if ($behindCount === '0') {
$this->info('Jabali Panel is up to date!');
return 0;
}
$this->warn("Updates available: {$behindCount} commit(s) behind");
// Show recent commits
$this->line("\nRecent changes:");
$commits = $this->executeCommandOrFail("git log HEAD..{$updateSource['remoteRef']} --oneline -10");
if ($commits !== '') {
$this->line($commits);
}
return 0;
} catch (Exception $e) {
$this->error('Failed to check for updates: '.$e->getMessage());
return 1;
}
}
private function performUpgrade(): int
{
$this->info('Starting Jabali Panel upgrade...');
$this->newLine();
$currentVersion = $this->getCurrentVersion();
$this->line("Current version: <info>{$currentVersion}</info>");
// Step 1: Check git status
$this->info('[1/9] Checking repository status...');
try {
$this->configureGitSafeDirectory();
$this->ensureGitRepository();
$this->ensureWritableStorage();
$statusResult = $this->executeCommand('git status --porcelain');
if ($statusResult['exitCode'] !== 0) {
throw new Exception($statusResult['output'] ?: 'Unable to read git status.');
}
$status = $statusResult['output'];
if (! empty(trim($status)) && ! $this->option('force')) {
$this->warn('Working directory has uncommitted changes:');
$this->line($status);
if (! $this->confirm('Continue anyway? Local changes may be overwritten.')) {
$this->info('Upgrade cancelled.');
return 0;
}
}
} catch (Exception $e) {
$this->error('Git check failed: '.$e->getMessage());
return 1;
}
// Step 2: Fetch updates
$this->info('[2/9] Fetching updates from repository...');
try {
$updateSource = $this->fetchUpdates();
} catch (Exception $e) {
$this->error('Failed to fetch updates: '.$e->getMessage());
return 1;
}
// Step 3: Check if updates available
$behindCount = trim($this->executeCommandOrFail("git rev-list HEAD..{$updateSource['remoteRef']} --count"));
if ($behindCount === '0' && ! $this->option('force')) {
$this->info('Already up to date!');
return 0;
}
// Step 4: Pull changes
$oldHead = trim($this->executeCommandOrFail('git rev-parse HEAD'));
$this->info('[3/9] Pulling latest changes...');
try {
$pullResult = $this->executeCommand("git pull --ff-only {$updateSource['pullRemote']} main");
if ($pullResult['exitCode'] !== 0) {
throw new Exception($pullResult['output'] ?: 'Git pull failed.');
}
if ($pullResult['output'] !== '') {
$this->line($pullResult['output']);
}
} catch (Exception $e) {
$this->error('Failed to pull changes: '.$e->getMessage());
$this->warn('You may need to resolve conflicts manually.');
return 1;
}
$newHead = trim($this->executeCommandOrFail('git rev-parse HEAD'));
$changedFiles = $this->getChangedFiles($oldHead, $newHead);
$hasVendor = File::exists($this->basePath.'/vendor/autoload.php');
$hasManifest = File::exists($this->basePath.'/public/build/manifest.json');
$hasPackageJson = File::exists($this->basePath.'/package.json');
$shouldRunComposer = $this->shouldRunComposerInstall($changedFiles, $this->option('force'), $hasVendor);
$shouldRunNpm = $this->shouldRunNpmBuild($changedFiles, $this->option('force'), $hasManifest, $hasPackageJson);
$shouldRunMigrations = $this->shouldRunMigrations($changedFiles, $this->option('force'));
// Step 5: Install composer dependencies
$this->info('[4/9] Installing PHP dependencies...');
if ($shouldRunComposer) {
try {
$this->ensureCommandAvailable('composer');
$composerResult = $this->executeCommand('composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader', 1200);
if ($composerResult['exitCode'] !== 0) {
throw new Exception($composerResult['output'] ?: 'Composer install failed.');
}
if ($composerResult['output'] !== '') {
$this->line($composerResult['output']);
}
} catch (Exception $e) {
$this->error('Failed to install dependencies: '.$e->getMessage());
return 1;
}
} else {
$this->line('No composer changes detected, skipping.');
}
// Step 5b: Install npm dependencies and build assets
$this->info('[5/9] Building frontend assets...');
if ($shouldRunNpm) {
try {
$this->ensureCommandAvailable('npm');
$this->ensureNpmCacheDirectory();
$this->ensureNodeModulesPermissions();
$this->ensurePublicBuildPermissions();
$nodeModulesWritable = $this->isNodeModulesWritable();
$publicBuildWritable = $this->isPublicBuildWritable();
if (! $nodeModulesWritable || ! $publicBuildWritable) {
$this->warn('Skipping frontend build because asset paths are not writable by the current user.');
if (! $nodeModulesWritable) {
$this->warn('node_modules is not writable.');
$this->warn('Run: sudo chown -R www-data:www-data '.$this->getNodeModulesPath());
}
if (! $publicBuildWritable) {
$this->warn('public/build is not writable.');
$this->warn('Run: sudo chown -R www-data:www-data '.$this->getPublicBuildPath());
}
} else {
$npmInstall = File::exists($this->basePath.'/package-lock.json') ? 'npm ci' : 'npm install';
$installResult = $this->executeCommand($npmInstall, 1200);
if ($installResult['exitCode'] !== 0) {
throw new Exception($installResult['output'] ?: 'npm install failed.');
}
if ($installResult['output'] !== '') {
$this->line($installResult['output']);
}
$buildResult = $this->executeCommand('npm run build', 1200);
if ($buildResult['exitCode'] !== 0) {
throw new Exception($buildResult['output'] ?: 'npm build failed.');
}
if ($buildResult['output'] !== '') {
$this->line($buildResult['output']);
}
$this->ensureNodeModulesPermissions();
$this->ensurePublicBuildPermissions();
}
} catch (Exception $e) {
$this->error('Asset build failed: '.$e->getMessage());
return 1;
}
} else {
$this->line('No frontend changes detected, skipping.');
}
// Step 6: Run migrations
$this->info('[6/9] Running database migrations...');
if ($shouldRunMigrations) {
try {
Artisan::call('migrate', ['--force' => true]);
$this->line(Artisan::output());
} catch (Exception $e) {
$this->error('Migration failed: '.$e->getMessage());
return 1;
}
} else {
$this->line('No migration changes detected, skipping.');
}
// Step 7: Clear caches
$this->info('[7/9] Clearing caches...');
try {
Artisan::call('optimize:clear');
$this->line(Artisan::output());
} catch (Exception $e) {
$this->warn('Cache clear warning: '.$e->getMessage());
}
// Step 8: Setup Redis ACL if not configured
$this->info('[8/9] Checking Redis ACL configuration...');
$this->setupRedisAcl();
// Step 9: Restart services
$this->info('[9/9] Restarting services...');
$this->restartServices();
$newVersion = $this->getCurrentVersion();
$this->newLine();
$this->info("Upgrade complete! Version: {$newVersion}");
return 0;
}
/**
* @return array{pullRemote: string, remoteRef: string}
*/
private function fetchUpdates(): array
{
$originUrl = trim($this->executeCommandOrFail('git remote get-url origin'));
$fetchAttempts = [
['remote' => 'origin', 'ref' => 'origin/main', 'type' => 'remote'],
];
if ($this->hasRemote('gitea')) {
$fetchAttempts[] = ['remote' => 'gitea', 'ref' => 'gitea/main', 'type' => 'remote'];
}
if ($this->isGithubSshUrl($originUrl)) {
$fetchAttempts[] = [
'remote' => $this->githubHttpsUrlFromSsh($originUrl),
'ref' => 'jabali-upgrade/main',
'type' => 'url',
];
}
$lastError = null;
foreach ($fetchAttempts as $attempt) {
$result = $attempt['type'] === 'url'
? $this->executeCommand("git fetch {$attempt['remote']} main:refs/remotes/{$attempt['ref']}")
: $this->executeCommand("git fetch {$attempt['remote']} main");
if (($result['exitCode'] ?? 1) === 0) {
return [
'pullRemote' => $attempt['remote'],
'remoteRef' => $attempt['ref'],
];
}
$lastError = $result['output'] ?? 'Unknown error';
}
throw new Exception($lastError ?: 'Unable to fetch updates from any configured remote.');
}
private function hasRemote(string $remote): bool
{
$result = $this->executeCommand("git remote get-url {$remote}");
return ($result['exitCode'] ?? 1) === 0;
}
private function isGithubSshUrl(string $url): bool
{
return str_starts_with($url, 'git@github.com:') || str_starts_with($url, 'ssh://git@github.com/');
}
private function githubHttpsUrlFromSsh(string $url): string
{
if (str_starts_with($url, 'git@github.com:')) {
$path = substr($url, strlen('git@github.com:'));
return 'https://github.com/'.$path;
}
if (str_starts_with($url, 'ssh://git@github.com/')) {
$path = substr($url, strlen('ssh://git@github.com/'));
return 'https://github.com/'.$path;
}
return $url;
}
private function getCurrentVersion(): string
{
if (! File::exists($this->versionFile)) {
return 'unknown';
}
$content = File::get($this->versionFile);
if (preg_match('/VERSION=(.+)/', $content, $matches)) {
return trim($matches[1]);
}
return 'unknown';
}
protected function executeCommand(string $command, int $timeout = 600): array
{
$process = Process::fromShellCommandline($command, $this->basePath, $this->getCommandEnvironment());
$process->setTimeout($timeout);
$process->run();
$output = trim($process->getOutput().$process->getErrorOutput());
return [
'exitCode' => $process->getExitCode() ?? 1,
'output' => $output,
];
}
protected function executeCommandOrFail(string $command, int $timeout = 600): string
{
$result = $this->executeCommand($command, $timeout);
if ($result['exitCode'] !== 0) {
throw new Exception($result['output'] ?: "Command failed: {$command}");
}
return $result['output'];
}
protected function getCommandEnvironment(): array
{
$path = getenv('PATH') ?: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin';
return [
'PATH' => $path,
'COMPOSER_ALLOW_SUPERUSER' => '1',
'NPM_CONFIG_CACHE' => $this->getNpmCacheDir(),
'PUPPETEER_SKIP_DOWNLOAD' => '1',
'PUPPETEER_CACHE_DIR' => $this->getPuppeteerCacheDir(),
'XDG_CACHE_HOME' => $this->getXdgCacheDir(),
];
}
protected function ensureCommandAvailable(string $command): void
{
$result = $this->executeCommand("command -v {$command}");
if ($result['exitCode'] !== 0 || $result['output'] === '') {
throw new Exception("Required command not found: {$command}");
}
}
protected function ensureGitRepository(): void
{
if (! File::isDirectory($this->basePath.'/.git')) {
throw new Exception('Not a git repository.');
}
}
protected function configureGitSafeDirectory(): void
{
foreach ($this->getSafeDirectoryCommands() as $command) {
$this->executeCommand($command);
}
}
protected function ensureWritableStorage(): void
{
foreach ($this->getWritableStorageCommands() as $command) {
$this->executeCommand($command);
}
}
protected function getSafeDirectoryCommands(): array
{
$directory = $this->basePath;
$commands = [
"git config --global --add safe.directory {$directory}",
];
if (! $this->isRunningAsRoot()) {
return $commands;
}
$commands[] = "git config --system --add safe.directory {$directory}";
if ($this->commandExists('sudo') && $this->userExists('www-data')) {
$commands[] = "sudo -u www-data git config --global --add safe.directory {$directory}";
}
return $commands;
}
protected function getWritableStorageCommands(): array
{
if (! $this->isRunningAsRoot() || ! $this->userExists('www-data')) {
return [];
}
$paths = [
$this->basePath.'/database',
$this->basePath.'/storage',
$this->getNodeModulesPath(),
$this->getPublicBuildPath(),
$this->getNpmCacheDir(),
$this->getPuppeteerCacheDir(),
$this->getXdgCacheDir(),
$this->basePath.'/bootstrap/cache',
];
$escapedPaths = array_map('escapeshellarg', $paths);
$pathList = implode(' ', $escapedPaths);
return [
"chgrp -R www-data {$pathList}",
"chmod -R g+rwX {$pathList}",
"find {$pathList} -type d -exec chmod g+s {} +",
];
}
protected function ensureNpmCacheDirectory(): void
{
$cacheDirs = [
$this->getNpmCacheDir(),
$this->getPuppeteerCacheDir(),
$this->getXdgCacheDir(),
];
foreach ($cacheDirs as $cacheDir) {
if (! File::exists($cacheDir)) {
File::ensureDirectoryExists($cacheDir);
}
if ($this->isRunningAsRoot() && $this->userExists('www-data')) {
$this->executeCommand('chgrp -R www-data '.escapeshellarg($cacheDir));
$this->executeCommand('chmod -R g+rwX '.escapeshellarg($cacheDir));
}
}
}
protected function ensureNodeModulesPermissions(): void
{
$nodeModules = $this->getNodeModulesPath();
if (! File::isDirectory($nodeModules)) {
return;
}
if ($this->isRunningAsRoot() && $this->userExists('www-data')) {
$escaped = escapeshellarg($nodeModules);
$this->executeCommand("chgrp -R www-data {$escaped}");
$this->executeCommand("chmod -R g+rwX {$escaped}");
$this->executeCommand("find {$escaped} -type d -exec chmod g+s {} +");
return;
}
$this->executeCommand('chmod -R u+rwX '.escapeshellarg($nodeModules));
}
protected function ensurePublicBuildPermissions(): void
{
$buildPath = $this->getPublicBuildPath();
if (! File::exists($buildPath)) {
File::ensureDirectoryExists($buildPath);
}
if ($this->isRunningAsRoot() && $this->userExists('www-data')) {
$escaped = escapeshellarg($buildPath);
$this->executeCommand("chgrp -R www-data {$escaped}");
$this->executeCommand("chmod -R g+rwX {$escaped}");
$this->executeCommand("find {$escaped} -type d -exec chmod g+s {} +");
return;
}
$this->executeCommand('chmod -R u+rwX '.escapeshellarg($buildPath));
}
protected function isNodeModulesWritable(): bool
{
$nodeModules = $this->getNodeModulesPath();
if (! File::isDirectory($nodeModules)) {
return true;
}
$binPath = $nodeModules.'/.bin';
if (! is_writable($nodeModules)) {
return false;
}
if (File::isDirectory($binPath) && ! is_writable($binPath)) {
return false;
}
return true;
}
protected function isPublicBuildWritable(): bool
{
$buildPath = $this->getPublicBuildPath();
if (! File::isDirectory($buildPath)) {
return true;
}
if (! is_writable($buildPath)) {
return false;
}
$assetsPath = $buildPath.'/assets';
if (File::isDirectory($assetsPath) && ! is_writable($assetsPath)) {
return false;
}
return true;
}
protected function getNpmCacheDir(): string
{
return $this->basePath.'/storage/npm-cache';
}
protected function getNodeModulesPath(): string
{
return $this->basePath.'/node_modules';
}
protected function getPublicBuildPath(): string
{
return $this->basePath.'/public/build';
}
protected function getPuppeteerCacheDir(): string
{
return $this->basePath.'/storage/puppeteer-cache';
}
protected function getXdgCacheDir(): string
{
return $this->basePath.'/storage/.cache';
}
protected function commandExists(string $command): bool
{
$result = $this->executeCommand("command -v {$command}");
return $result['exitCode'] === 0 && $result['output'] !== '';
}
protected function userExists(string $user): bool
{
if (function_exists('posix_getpwnam')) {
return posix_getpwnam($user) !== false;
}
return $this->executeCommand("id -u {$user}")['exitCode'] === 0;
}
private function getChangedFiles(string $from, string $to): array
{
if ($from === $to) {
return [];
}
$output = $this->executeCommandOrFail("git diff --name-only {$from}..{$to}");
if ($output === '') {
return [];
}
return array_values(array_filter(array_map('trim', explode("\n", $output))));
}
protected function shouldRunComposerInstall(array $changedFiles, bool $force, bool $hasVendor): bool
{
if ($force || ! $hasVendor) {
return true;
}
return $this->hasChangedFile($changedFiles, ['composer.json', 'composer.lock']);
}
protected function shouldRunNpmBuild(array $changedFiles, bool $force, bool $hasManifest, bool $hasPackageJson): bool
{
if (! $hasPackageJson) {
return false;
}
if ($force || ! $hasManifest) {
return true;
}
if ($this->hasChangedFile($changedFiles, [
'package.json',
'package-lock.json',
'vite.config.js',
'postcss.config.js',
'tailwind.config.js',
])) {
return true;
}
return $this->hasChangedPathPrefix($changedFiles, 'resources/');
}
protected function shouldRunMigrations(array $changedFiles, bool $force): bool
{
if ($force) {
return true;
}
return $this->hasChangedPathPrefix($changedFiles, 'database/migrations/');
}
protected function hasChangedFile(array $changedFiles, array $targets): bool
{
foreach ($targets as $target) {
if (in_array($target, $changedFiles, true)) {
return true;
}
}
return false;
}
protected function hasChangedPathPrefix(array $changedFiles, string $prefix): bool
{
foreach ($changedFiles as $file) {
if (str_starts_with($file, $prefix)) {
return true;
}
}
return false;
}
protected function restartServices(): void
{
try {
Artisan::call('queue:restart');
$this->line(' - queue restarted');
} catch (Exception $e) {
$this->warn('Queue restart warning: '.$e->getMessage());
}
if (! $this->isRunningAsRoot()) {
$this->warn('Skipping system service reloads (requires root).');
return;
}
$agentResult = $this->executeCommand('systemctl restart jabali-agent');
if ($agentResult['exitCode'] === 0) {
$this->line(' - jabali-agent restarted');
} else {
$this->warn(' - jabali-agent restart failed');
}
$fpmResult = $this->executeCommand('systemctl reload php*-fpm');
if ($fpmResult['exitCode'] === 0) {
$this->line(' - PHP-FPM reloaded (all versions)');
} else {
$this->warn(' - PHP-FPM reload failed');
}
}
protected function isRunningAsRoot(): bool
{
if (function_exists('posix_geteuid')) {
return posix_geteuid() === 0;
}
return getmyuid() === 0;
}
private function setupRedisAcl(): void
{
$credFile = '/root/.jabali_redis_credentials';
$aclFile = '/etc/redis/users.acl';
// Check if Redis ACL is already configured
if (File::exists($credFile) && File::exists($aclFile)) {
$this->line(' - Redis ACL already configured');
return;
}
// Check if we have permission to write to /root/
if (! is_writable('/root') && ! File::exists($credFile)) {
$this->line(' - Skipping Redis ACL setup (requires root privileges)');
return;
}
$this->line(' - Setting up Redis ACL...');
// Generate admin password
$password = bin2hex(random_bytes(16));
// Save credentials
File::put($credFile, "REDIS_ADMIN_PASSWORD={$password}\n");
chmod($credFile, 0600);
// Create ACL file
$aclContent = "user default off\nuser jabali_admin on >{$password} ~* &* +@all\n";
File::put($aclFile, $aclContent);
chmod($aclFile, 0640);
chown($aclFile, 'redis');
chgrp($aclFile, 'redis');
// Check if redis.conf has aclfile directive
$redisConf = '/etc/redis/redis.conf';
if (File::exists($redisConf)) {
$conf = File::get($redisConf);
if (strpos($conf, 'aclfile') === false) {
// Add aclfile directive
$conf .= "\n# ACL configuration\naclfile /etc/redis/users.acl\n";
File::put($redisConf, $conf);
}
}
// Update Laravel .env with Redis credentials
$envFile = base_path('.env');
if (File::exists($envFile)) {
$env = File::get($envFile);
// Update or add Redis credentials
if (strpos($env, 'REDIS_USERNAME=') === false) {
$env = preg_replace(
'/REDIS_HOST=.*/m',
"REDIS_HOST=127.0.0.1\nREDIS_USERNAME=jabali_admin\nREDIS_PASSWORD={$password}",
$env
);
} else {
$env = preg_replace('/REDIS_PASSWORD=.*/m', "REDIS_PASSWORD={$password}", $env);
}
File::put($envFile, $env);
}
// Restart Redis
exec('systemctl restart redis-server 2>&1', $output, $code);
if ($code === 0) {
$this->line(' - Redis ACL configured and restarted');
// Migrate existing users
$this->line(' - Migrating existing users to Redis ACL...');
try {
Artisan::call('jabali:migrate-redis-users');
$this->line(Artisan::output());
} catch (Exception $e) {
$this->warn(' - Redis user migration warning: '.$e->getMessage());
}
} else {
$this->warn(' - Redis restart failed, ACL may not be active');
}
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace App\Console\Commands\Jabali;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class UserCommand extends Command {
protected $signature = 'user {action=list : list|create|show|update|delete|password} {--id= : User ID or email} {--name= : Name} {--email= : Email} {--password= : Password} {--role= : Role} {--force : Skip confirmation}';
protected $description = 'Manage users: list, create, show, update, delete, password';
public function handle(): int {
return match($this->argument('action')) {
'list' => $this->listUsers(),
'create' => $this->createUser(),
'show' => $this->showUser(),
'update' => $this->updateUser(),
'delete' => $this->deleteUser(),
'password' => $this->changePassword(),
default => $this->error("Unknown action. Use: list, create, show, update, delete, password") ?? 1,
};
}
private function generateSecurePassword(int $length = 16): string {
$lower = 'abcdefghijklmnopqrstuvwxyz';
$upper = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
$numbers = '0123456789';
$special = '!@#$%^&*';
$password = $lower[random_int(0, 25)] . $upper[random_int(0, 25)] . $numbers[random_int(0, 9)] . $special[random_int(0, 7)];
$all = $lower . $upper . $numbers . $special;
for ($i = 4; $i < $length; $i++) $password .= $all[random_int(0, strlen($all) - 1)];
return str_shuffle($password);
}
private function validatePassword(string $password): ?string {
if (strlen($password) < 8) return 'Password must be at least 8 characters';
if (!preg_match('/[a-z]/', $password)) return 'Password must contain a lowercase letter';
if (!preg_match('/[A-Z]/', $password)) return 'Password must contain an uppercase letter';
if (!preg_match('/[0-9]/', $password)) return 'Password must contain a number';
return null;
}
private function listUsers(): int {
$users = User::all();
$this->table(['ID', 'Name', 'Email', 'Role', 'Created'], $users->map(fn($u) => [$u->id, $u->name, $u->email, $u->role ?? 'user', $u->created_at->format('Y-m-d')])->toArray());
return 0;
}
private function createUser(): int {
$name = $this->option('name') ?? $this->ask('Name');
$email = $this->option('email') ?? $this->ask('Email');
$gen = !$this->option('password');
$password = $this->option('password') ?? $this->generateSecurePassword();
if ($error = $this->validatePassword($password)) { $this->error($error); return 1; }
if (User::where('email', $email)->exists()) { $this->error("Email exists!"); return 1; }
$user = User::create(['name' => $name, 'email' => $email, 'password' => Hash::make($password), 'role' => $this->option('role') ?? 'user', 'email_verified_at' => now()]);
$this->info("✓ Created user #{$user->id}: {$email}");
if ($gen) $this->warn("🔑 Password: $password");
return 0;
}
private function showUser(): int {
$user = $this->findUser();
if (!$user) return 1;
$this->table(['Field', 'Value'], [['ID', $user->id], ['Name', $user->name], ['Email', $user->email], ['Role', $user->role ?? 'user'], ['Created', $user->created_at]]);
return 0;
}
private function updateUser(): int {
$user = $this->findUser();
if (!$user) return 1;
$updates = array_filter(['name' => $this->option('name'), 'email' => $this->option('email'), 'role' => $this->option('role')]);
if (empty($updates)) { $this->warn('Specify --name, --email, or --role'); return 0; }
$user->update($updates);
$this->info("✓ Updated user #{$user->id}");
return 0;
}
private function deleteUser(): int {
$user = $this->findUser();
if (!$user) return 1;
if (!$this->option('force') && !$this->confirm("Delete {$user->email}?")) return 0;
$user->delete();
$this->info("✓ Deleted user #{$user->id}");
return 0;
}
private function changePassword(): int {
$user = $this->findUser();
if (!$user) return 1;
$gen = !$this->option('password');
$password = $this->option('password') ?? $this->generateSecurePassword();
if ($error = $this->validatePassword($password)) { $this->error($error); return 1; }
$user->update(['password' => Hash::make($password)]);
$this->info("✓ Password updated for {$user->email}");
if ($gen) $this->warn("🔑 Password: $password");
return 0;
}
private function findUser(): ?User {
$id = $this->option('id') ?? $this->ask('User ID or email');
$user = is_numeric($id) ? User::find($id) : User::where('email', $id)->first();
if (!$user) $this->error("User not found: $id");
return $user;
}
}

View File

@@ -0,0 +1,233 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
class ManageGitProtection extends Command
{
protected $signature = 'jabali:git-protection
{action : enable|disable|status|add-committer|remove-committer|list-committers}
{--email= : Email address for add/remove committer}';
protected $description = 'Manage Git commit protection for Jabali';
protected string $authFile;
protected string $hookFile;
protected string $deployKeyFile;
public function __construct()
{
parent::__construct();
$this->authFile = base_path('.git-authorized-committers');
$this->hookFile = base_path('.git/hooks/pre-commit');
$this->deployKeyFile = base_path('.deploy-key');
}
public function handle(): int
{
$action = $this->argument('action');
return match ($action) {
'enable' => $this->enableProtection(),
'disable' => $this->disableProtection(),
'status' => $this->showStatus(),
'add-committer' => $this->addCommitter(),
'remove-committer' => $this->removeCommitter(),
'list-committers' => $this->listCommitters(),
default => $this->showHelp(),
};
}
protected function enableProtection(): int
{
// Ensure hook exists and is executable
if (!file_exists($this->hookFile)) {
$this->error('Pre-commit hook not found. Please reinstall Jabali.');
return 1;
}
chmod($this->hookFile, 0755);
// Create authorized committers file if not exists
if (!file_exists($this->authFile)) {
// Add current git user as first authorized committer
$email = trim(shell_exec('git config user.email') ?? '');
if ($email) {
file_put_contents($this->authFile, $email . "\n");
} else {
touch($this->authFile);
}
chmod($this->authFile, 0600);
}
// Generate deploy key for automated deployments
if (!file_exists($this->deployKeyFile)) {
$key = Str::random(64);
file_put_contents($this->deployKeyFile, $key);
chmod($this->deployKeyFile, 0600);
$this->info("Deploy key generated. Use JABALI_DEPLOY_KEY=$key for automated deployments.");
}
$this->info('Git commit protection ENABLED.');
$this->line('Only authorized committers can now make commits.');
$this->line('');
$this->line('Authorized committers file: ' . $this->authFile);
return 0;
}
protected function disableProtection(): int
{
if (file_exists($this->authFile)) {
unlink($this->authFile);
}
$this->info('Git commit protection DISABLED.');
$this->line('Anyone can now make commits to this repository.');
return 0;
}
protected function showStatus(): int
{
$isEnabled = file_exists($this->authFile);
$this->line('Git Protection Status');
$this->line('=====================');
$this->line('');
if ($isEnabled) {
$this->info('Status: ENABLED');
$this->line('');
$committers = $this->getCommitters();
if (count($committers) > 0) {
$this->line('Authorized committers:');
foreach ($committers as $email) {
$this->line(" - $email");
}
} else {
$this->warn('No authorized committers configured!');
$this->line('No one will be able to commit.');
}
if (file_exists($this->deployKeyFile)) {
$this->line('');
$this->line('Deploy key: Configured');
}
} else {
$this->warn('Status: DISABLED');
$this->line('Anyone can make commits.');
}
// Show last integrity check status
$lastCheck = \App\Models\DnsSetting::get('last_integrity_check');
$lastStatus = \App\Models\DnsSetting::get('last_integrity_status');
if ($lastCheck) {
$this->line('');
$this->line('Last integrity check: ' . $lastCheck);
$this->line('Status: ' . ($lastStatus === 'clean' ? 'Clean' : 'Modified files detected'));
}
return 0;
}
protected function addCommitter(): int
{
$email = $this->option('email');
if (!$email) {
$email = $this->ask('Enter committer email address');
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$this->error('Invalid email address.');
return 1;
}
$committers = $this->getCommitters();
if (in_array($email, $committers)) {
$this->warn("$email is already an authorized committer.");
return 0;
}
$committers[] = $email;
file_put_contents($this->authFile, implode("\n", $committers) . "\n");
chmod($this->authFile, 0600);
$this->info("Added $email to authorized committers.");
return 0;
}
protected function removeCommitter(): int
{
$email = $this->option('email');
if (!$email) {
$email = $this->ask('Enter committer email to remove');
}
$committers = $this->getCommitters();
$committers = array_filter($committers, fn($e) => $e !== $email);
file_put_contents($this->authFile, implode("\n", $committers) . "\n");
$this->info("Removed $email from authorized committers.");
return 0;
}
protected function listCommitters(): int
{
$committers = $this->getCommitters();
if (empty($committers)) {
$this->warn('No authorized committers configured.');
return 0;
}
$this->info('Authorized committers:');
foreach ($committers as $email) {
$this->line(" - $email");
}
return 0;
}
protected function getCommitters(): array
{
if (!file_exists($this->authFile)) {
return [];
}
$content = file_get_contents($this->authFile);
$lines = array_filter(array_map('trim', explode("\n", $content)));
return $lines;
}
protected function showHelp(): int
{
$this->line('Usage: php artisan jabali:git-protection <action>');
$this->line('');
$this->line('Actions:');
$this->line(' enable Enable commit protection');
$this->line(' disable Disable commit protection');
$this->line(' status Show protection status');
$this->line(' add-committer Add an authorized committer');
$this->line(' remove-committer Remove an authorized committer');
$this->line(' list-committers List all authorized committers');
$this->line('');
$this->line('Options:');
$this->line(' --email=<email> Email address for add/remove actions');
return 0;
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\AdminNotificationService;
use Illuminate\Console\Command;
class NotifyHighLoad extends Command
{
protected $signature = 'notify:high-load
{event : Event type (high|recovered)}
{load : Current load average}
{minutes : Minutes the load has been high}';
protected $description = 'Send notification for high server load events';
public function handle(): int
{
$event = $this->argument('event');
$load = (float) $this->argument('load');
$minutes = (int) $this->argument('minutes');
$result = match ($event) {
'high' => $this->notifyHighLoad($load, $minutes),
'recovered' => $this->notifyRecovered($load),
default => false,
};
return $result ? Command::SUCCESS : Command::FAILURE;
}
protected function notifyHighLoad(float $load, int $minutes): bool
{
$hostname = gethostname() ?: 'server';
$cpuCount = (int) shell_exec('nproc 2>/dev/null') ?: 1;
return AdminNotificationService::send(
'high_load',
"High Server Load: {$hostname}",
"The server load has been critically high for {$minutes} minutes. Current load: {$load} (CPU cores: {$cpuCount}). Please investigate immediately.",
[
'Load Average' => number_format($load, 2),
'Duration' => "{$minutes} minutes",
'CPU Cores' => $cpuCount,
'Load per Core' => number_format($load / $cpuCount, 2),
]
);
}
protected function notifyRecovered(float $load): bool
{
$hostname = gethostname() ?: 'server';
return AdminNotificationService::send(
'high_load',
"Server Load Recovered: {$hostname}",
"The server load has returned to normal levels. Current load: {$load}.",
[
'Load Average' => number_format($load, 2),
'Status' => 'Recovered',
]
);
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\AdminNotificationService;
use Illuminate\Console\Command;
class NotifyServiceHealth extends Command
{
protected $signature = 'notify:service-health
{event : Event type (down|restarted|recovered|failed)}
{service : Service name}
{--description= : Service description}';
protected $description = 'Send notification for service health events';
public function handle(): int
{
$event = $this->argument('event');
$service = $this->argument('service');
$description = $this->option('description') ?? $service;
$result = match ($event) {
'down' => $this->notifyDown($service, $description),
'restarted' => $this->notifyRestarted($service, $description),
'recovered' => $this->notifyRecovered($service, $description),
'failed' => $this->notifyFailed($service, $description),
default => false,
};
return $result ? Command::SUCCESS : Command::FAILURE;
}
protected function notifyDown(string $service, string $description): bool
{
return AdminNotificationService::send(
'service_health',
"Service Down: {$description}",
"The {$description} service ({$service}) has stopped and will be automatically restarted.",
['Service' => $service, 'Status' => 'Down', 'Action' => 'Auto-restart pending']
);
}
protected function notifyRestarted(string $service, string $description): bool
{
return AdminNotificationService::send(
'service_health',
"Service Auto-Restarted: {$description}",
"The {$description} service ({$service}) was down and has been automatically restarted by the health monitor.",
['Service' => $service, 'Status' => 'Restarted', 'Action' => 'Automatic recovery']
);
}
protected function notifyRecovered(string $service, string $description): bool
{
return AdminNotificationService::send(
'service_health',
"Service Recovered: {$description}",
"The {$description} service ({$service}) has recovered and is now running normally.",
['Service' => $service, 'Status' => 'Running', 'Action' => 'None required']
);
}
protected function notifyFailed(string $service, string $description): bool
{
return AdminNotificationService::send(
'service_health',
"CRITICAL: Service Failed: {$description}",
"The {$description} service ({$service}) could not be automatically restarted after multiple attempts. Manual intervention is required immediately.",
['Service' => $service, 'Status' => 'Failed', 'Action' => 'Manual intervention required']
);
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\AdminNotificationService;
use Illuminate\Console\Command;
class NotifySshLogin extends Command
{
protected $signature = 'notify:ssh-login {username} {ip} {--method=password : Authentication method}';
protected $description = 'Send notification for successful SSH login';
public function handle(): int
{
$username = $this->argument('username');
$ip = $this->argument('ip');
$method = $this->option('method');
AdminNotificationService::sshLogin($username, $ip, $method);
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,282 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Backup;
use App\Models\BackupSchedule;
use App\Services\AdminNotificationService;
use App\Services\Agent\AgentClient;
use Illuminate\Console\Command;
use Exception;
class RunBackupSchedules extends Command
{
protected $signature = 'backups:run-schedules';
protected $description = 'Run due backup schedules';
protected AgentClient $agent;
public function __construct()
{
parent::__construct();
$this->agent = new AgentClient();
}
public function handle(): int
{
$this->info('Checking for due backup schedules...');
$dueSchedules = BackupSchedule::due()->with('destination')->get();
if ($dueSchedules->isEmpty()) {
$this->info('No backup schedules due.');
return Command::SUCCESS;
}
$this->info("Found {$dueSchedules->count()} schedule(s) to run.");
foreach ($dueSchedules as $schedule) {
$this->runSchedule($schedule);
}
return Command::SUCCESS;
}
protected function runSchedule(BackupSchedule $schedule): void
{
$this->info("Running schedule: {$schedule->name}");
$backupType = $schedule->metadata['backup_type'] ?? 'full';
$timestamp = now()->format('Y-m-d_His');
if ($schedule->is_server_backup) {
// Server backup: folder with individual user tar.gz files
$filename = $timestamp;
$outputPath = "/var/backups/jabali/{$timestamp}";
} else {
$user = $schedule->user;
if (!$user) {
$this->error("Schedule {$schedule->id} has no user.");
return;
}
$filename = "backup_scheduled_{$timestamp}.tar.gz";
$outputPath = "/home/{$user->username}/backups/{$filename}";
}
$backup = Backup::create([
'user_id' => $schedule->user_id,
'destination_id' => $schedule->destination_id,
'schedule_id' => $schedule->id,
'name' => "{$schedule->name} - " . now()->format('M j, Y H:i'),
'filename' => $filename,
'type' => $schedule->is_server_backup ? 'server' : 'partial',
'include_files' => $schedule->include_files,
'include_databases' => $schedule->include_databases,
'include_mailboxes' => $schedule->include_mailboxes,
'include_dns' => $schedule->include_dns,
'users' => $schedule->users,
'status' => 'pending',
'local_path' => $outputPath,
'metadata' => ['backup_type' => $backupType],
]);
try {
$backup->update(['status' => 'running', 'started_at' => now()]);
// Check if this is an incremental backup with remote destination (dirvish-style)
$isIncrementalRemote = $backupType === 'incremental' && $schedule->destination;
if ($schedule->is_server_backup) {
if ($isIncrementalRemote) {
// Dirvish-style: rsync directly to remote with --link-dest
$config = array_merge(
$schedule->destination->config ?? [],
['type' => $schedule->destination->type]
);
$result = $this->agent->backupIncrementalDirect($config, [
'users' => $schedule->users,
'include_files' => $schedule->include_files,
'include_databases' => $schedule->include_databases,
'include_mailboxes' => $schedule->include_mailboxes,
'include_dns' => $schedule->include_dns,
]);
// Update backup record with remote path
if ($result['success']) {
$backup->update([
'local_path' => null, // No local file for incremental remote
'remote_path' => $result['remote_path'] ?? null,
]);
}
} else {
$result = $this->agent->backupCreateServer($outputPath, [
'backup_type' => $backupType,
'users' => $schedule->users,
'include_files' => $schedule->include_files,
'include_databases' => $schedule->include_databases,
'include_mailboxes' => $schedule->include_mailboxes,
'include_dns' => $schedule->include_dns,
]);
}
} else {
$result = $this->agent->backupCreate($schedule->user->username, $outputPath, [
'include_files' => $schedule->include_files,
'include_databases' => $schedule->include_databases,
'include_mailboxes' => $schedule->include_mailboxes,
'include_dns' => $schedule->include_dns,
'include_ssl' => $schedule->include_ssl ?? true,
]);
}
if ($result['success']) {
$backup->update([
'status' => 'completed',
'completed_at' => now(),
'size_bytes' => $result['size'] ?? 0,
'checksum' => $result['checksum'] ?? null,
'domains' => $result['domains'] ?? null,
'databases' => $result['databases'] ?? null,
'mailboxes' => $result['mailboxes'] ?? null,
'users' => $result['users'] ?? null,
]);
// Upload to remote if destination configured
if ($schedule->destination) {
$backup->load('destination'); // Load the relationship
$uploadSuccess = $this->uploadToRemote($backup);
// Delete local file after successful remote upload (unless keep_local is set)
$keepLocal = $schedule->metadata['keep_local_copy'] ?? false;
if (!$keepLocal && $uploadSuccess && $backup->local_path) {
$this->agent->backupDeleteServer($backup->local_path);
$backup->update(['local_path' => null]);
$this->info("Local backup deleted after remote upload");
}
}
$schedule->update([
'last_run_at' => now(),
'last_status' => 'success',
'last_error' => null,
]);
$this->info("Backup completed: {$backup->name}");
// Apply retention policy
$this->applyRetention($schedule);
} else {
throw new Exception($result['error'] ?? 'Backup failed');
}
} catch (Exception $e) {
$backup->update([
'status' => 'failed',
'completed_at' => now(),
'error_message' => $e->getMessage(),
]);
$schedule->update([
'last_run_at' => now(),
'last_status' => 'failed',
'last_error' => $e->getMessage(),
]);
$this->error("Backup failed: {$e->getMessage()}");
// Send admin notification
AdminNotificationService::backupFailure($schedule->name, $e->getMessage());
}
// Calculate next run
$schedule->calculateNextRun();
$schedule->save();
}
protected function uploadToRemote(Backup $backup): bool
{
if (!$backup->destination || !$backup->local_path) {
return false;
}
try {
$backup->update(['status' => 'uploading']);
$config = array_merge(
$backup->destination->config ?? [],
['type' => $backup->destination->type]
);
$backupType = $backup->metadata['backup_type'] ?? 'full';
$result = $this->agent->backupUploadRemote($backup->local_path, $config, $backupType);
if ($result['success']) {
$backup->update([
'status' => 'completed',
'remote_path' => $result['remote_path'] ?? null,
]);
$this->info("Uploaded to remote: {$backup->destination->name}");
return true;
} else {
throw new Exception($result['error'] ?? 'Upload failed');
}
} catch (Exception $e) {
$backup->update([
'status' => 'completed', // Keep as completed since local exists
'error_message' => 'Remote upload failed: ' . $e->getMessage(),
]);
$this->warn("Remote upload failed: {$e->getMessage()}");
return false;
}
}
protected function applyRetention(BackupSchedule $schedule): void
{
$retentionCount = $schedule->retention_count ?? 7;
// Get backups from this schedule, ordered by date
// schedule_id is a top-level field on the backups table
$backups = Backup::where('schedule_id', $schedule->id)
->where('status', 'completed')
->orderByDesc('created_at')
->get();
if ($backups->count() <= $retentionCount) {
return;
}
// Get backups to delete
$toDelete = $backups->slice($retentionCount);
foreach ($toDelete as $backup) {
$this->info("Deleting old backup: {$backup->name}");
// Delete local file
if ($backup->local_path && file_exists($backup->local_path)) {
if (is_file($backup->local_path)) {
unlink($backup->local_path);
} else {
exec("rm -rf " . escapeshellarg($backup->local_path));
}
}
// Delete from remote if exists
if ($backup->remote_path && $backup->destination) {
try {
$config = array_merge(
$backup->destination->config ?? [],
['type' => $backup->destination->type]
);
$this->agent->backupDeleteRemote($backup->remote_path, $config);
} catch (Exception $e) {
// Silent fail for remote deletion
}
}
$backup->delete();
}
$deletedCount = $toDelete->count();
$this->info("Deleted {$deletedCount} old backup(s) per retention policy.");
}
}

View File

@@ -0,0 +1,121 @@
<?php
namespace App\Console\Commands;
use App\Models\CronJob;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class RunUserCronJobs extends Command
{
protected $signature = 'jabali:run-cron-jobs';
protected $description = 'Run due user cron jobs and track their execution';
public function handle(): int
{
$jobs = CronJob::where('is_active', true)->get();
foreach ($jobs as $job) {
if ($this->isDue($job->schedule)) {
$this->runJob($job);
}
}
return Command::SUCCESS;
}
protected function isDue(string $schedule): bool
{
$parts = explode(' ', $schedule);
if (count($parts) !== 5) {
return false;
}
[$minute, $hour, $dayOfMonth, $month, $dayOfWeek] = $parts;
$now = now();
return $this->matchesPart($minute, $now->minute)
&& $this->matchesPart($hour, $now->hour)
&& $this->matchesPart($dayOfMonth, $now->day)
&& $this->matchesPart($month, $now->month)
&& $this->matchesPart($dayOfWeek, $now->dayOfWeek);
}
protected function matchesPart(string $pattern, int $value): bool
{
// Handle *
if ($pattern === '*') {
return true;
}
// Handle */n (step values)
if (str_starts_with($pattern, '*/')) {
$step = (int) substr($pattern, 2);
return $step > 0 && $value % $step === 0;
}
// Handle ranges (e.g., 1-5)
if (str_contains($pattern, '-')) {
[$start, $end] = explode('-', $pattern);
return $value >= (int) $start && $value <= (int) $end;
}
// Handle lists (e.g., 1,3,5)
if (str_contains($pattern, ',')) {
$values = array_map('intval', explode(',', $pattern));
return in_array($value, $values);
}
// Handle exact value
return (int) $pattern === $value;
}
protected function runJob(CronJob $job): void
{
$username = $job->user->username ?? null;
if (!$username) {
Log::warning("Cron job {$job->id} has no valid user");
return;
}
$this->info("Running cron job: {$job->name} (ID: {$job->id})");
$startTime = microtime(true);
// Strip output redirection from command so we can capture it
$command = $job->command;
$command = preg_replace('/\s*>\s*\/dev\/null.*$/', '', $command);
$command = preg_replace('/\s*2>&1\s*$/', '', $command);
// Run the command as the user
$cmd = sprintf(
'sudo -u %s bash -c %s 2>&1',
escapeshellarg($username),
escapeshellarg($command)
);
exec($cmd, $output, $exitCode);
$duration = round(microtime(true) - $startTime, 2);
$outputStr = implode("\n", $output);
// Update the job record
$job->update([
'last_run_at' => now(),
'last_run_status' => $exitCode === 0 ? 'success' : 'failed',
'last_run_output' => substr($outputStr, 0, 10000), // Limit output size
]);
if ($exitCode === 0) {
$this->info(" Completed successfully in {$duration}s");
} else {
$this->error(" Failed with exit code {$exitCode} in {$duration}s");
Log::warning("Cron job {$job->id} ({$job->name}) failed", [
'exit_code' => $exitCode,
'output' => $outputStr,
]);
}
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Mailbox;
use App\Services\Agent\AgentClient;
use Illuminate\Console\Command;
class SyncMailboxQuotas extends Command
{
protected $signature = 'jabali:sync-mailbox-quotas';
protected $description = 'Sync mailbox quota usage from disk to database';
public function handle(): int
{
$mailboxes = Mailbox::with('emailDomain')->get();
if ($mailboxes->isEmpty()) {
$this->info('No mailboxes to sync.');
return 0;
}
$this->info("Syncing quota usage for {$mailboxes->count()} mailboxes...");
$agent = new AgentClient();
$synced = 0;
$errors = 0;
foreach ($mailboxes as $mailbox) {
try {
$response = $agent->send('email.mailbox_quota_usage', [
'email' => $mailbox->email,
'maildir_path' => $mailbox->getRawOriginal('maildir_path'),
]);
$mailbox->quota_used_bytes = $response['quota_used_bytes'] ?? 0;
$mailbox->save();
$synced++;
} catch (\Exception $e) {
if (str_contains($e->getMessage(), 'not found')) {
// Mailbox directory doesn't exist yet (no mail received)
$mailbox->quota_used_bytes = 0;
$mailbox->save();
$synced++;
} else {
$this->error("Failed to sync {$mailbox->email}: {$e->getMessage()}");
$errors++;
}
}
}
$this->info("Synced {$synced} mailboxes" . ($errors ? ", {$errors} errors" : ''));
return $errors > 0 ? 1 : 0;
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Pages\Auth;
use App\Models\User;
use Filament\Auth\Http\Responses\Contracts\LoginResponse;
use Filament\Auth\Pages\Login as BaseLogin;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Hash;
class Login extends BaseLogin
{
public function authenticate(): ?LoginResponse
{
$data = $this->form->getState();
// Check credentials without logging in
$user = User::where('email', $data['email'])->first();
if ($user && Hash::check($data['password'], $user->password)) {
if (! $user->is_admin) {
$this->redirect(route('filament.jabali.pages.dashboard'));
return null;
}
// Check if 2FA is enabled
if ($user->two_factor_secret && $user->two_factor_confirmed_at) {
// Store user ID in session for 2FA challenge
session(['login.id' => $user->id]);
session(['login.remember' => $data['remember'] ?? false]);
// Redirect to 2FA challenge
$this->redirect(route('filament.admin.auth.two-factor-challenge'));
return null;
}
}
$response = parent::authenticate();
// If authentication successful, check if user is NOT admin
$user = Filament::auth()->user();
if ($user && ! $user->is_admin) {
// Log out from admin guard - regular users can't access admin panel
Filament::auth()->logout();
// Redirect to user panel using Livewire's redirect
$this->redirect(route('filament.jabali.pages.dashboard'));
return null;
}
return $response;
}
}

View File

@@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Pages\Auth;
use Filament\Facades\Filament;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\SimplePage;
use Filament\Schemas\Schema;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Facades\Auth;
use Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider;
use Laravel\Fortify\Events\RecoveryCodeReplaced;
class TwoFactorChallenge extends SimplePage implements HasForms
{
use InteractsWithForms;
protected string $view = 'filament.admin.pages.auth.two-factor-challenge';
public ?array $data = [];
public bool $useRecoveryCode = false;
public function mount(): void
{
$user = $this->getChallengedUser();
// If not in 2FA challenge state or not an admin, redirect to login
if (! $user) {
$this->clearChallengeSession();
$this->redirect(Filament::getPanel('admin')->getLoginUrl());
return;
}
$this->form->fill();
}
public function getTitle(): string|Htmlable
{
return __('Two-Factor Authentication');
}
public function getHeading(): string|Htmlable
{
return __('Two-Factor Authentication');
}
public function getSubheading(): string|Htmlable|null
{
return $this->useRecoveryCode
? __('Please enter one of your emergency recovery codes.')
: __('Please enter the authentication code from your app.');
}
public function form(Schema $schema): Schema
{
return $schema
->schema([
TextInput::make('code')
->label($this->useRecoveryCode ? __('Recovery Code') : __('Authentication Code'))
->placeholder($this->useRecoveryCode ? __('Enter recovery code') : '000000')
->required()
->autocomplete('one-time-code')
->autofocus()
->extraInputAttributes([
'inputmode' => $this->useRecoveryCode ? 'text' : 'numeric',
'pattern' => $this->useRecoveryCode ? null : '[0-9]*',
'maxlength' => $this->useRecoveryCode ? 21 : 6,
]),
])
->statePath('data');
}
public function authenticate(): void
{
$data = $this->form->getState();
$code = $data['code'];
$user = $this->getChallengedUser();
if (! $user) {
$this->clearChallengeSession();
$this->redirect(Filament::getPanel('admin')->getLoginUrl());
return;
}
$valid = $this->useRecoveryCode
? $this->validateRecoveryCode($user, $code)
: $this->validateAuthenticationCode($user, $code);
if (! $valid) {
Notification::make()
->title(__('Invalid Code'))
->body($this->useRecoveryCode
? __('The recovery code is invalid.')
: __('The authentication code is invalid.'))
->danger()
->send();
$this->form->fill();
return;
}
$remember = (bool) session('login.remember', false);
$this->clearChallengeSession();
// Login the user with admin guard
Auth::guard('admin')->login($user, $remember);
session()->regenerate();
$this->redirect(Filament::getPanel('admin')->getUrl());
}
protected function getChallengedUser()
{
$userId = session('login.id');
if (! $userId) {
return null;
}
$user = \App\Models\User::find($userId);
if (! $user || ! $user->is_admin) {
return null;
}
return $user;
}
protected function clearChallengeSession(): void
{
session()->forget('login.id');
session()->forget('login.remember');
}
protected function validateAuthenticationCode($user, string $code): bool
{
return app(TwoFactorAuthenticationProvider::class)->verify(
decrypt($user->two_factor_secret),
$code
);
}
protected function validateRecoveryCode($user, string $code): bool
{
$codes = json_decode(decrypt($user->two_factor_recovery_codes), true);
$code = str_replace('-', '', trim($code));
foreach ($codes as $index => $storedCode) {
$storedCode = str_replace('-', '', $storedCode);
if (hash_equals($storedCode, $code)) {
// Remove the used code
unset($codes[$index]);
$user->forceFill([
'two_factor_recovery_codes' => encrypt(json_encode(array_values($codes))),
])->save();
event(new RecoveryCodeReplaced($user, $code));
return true;
}
}
return false;
}
public function toggleRecoveryCode(): void
{
$this->useRecoveryCode = ! $this->useRecoveryCode;
$this->form->fill();
}
protected function hasFullWidthFormActions(): bool
{
return true;
}
}

View File

@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Pages;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\TextInput;
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\Support\Facades\Auth;
class AutomationApi extends Page implements HasActions, HasTable
{
use InteractsWithActions;
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedKey;
protected static ?int $navigationSort = 17;
protected static ?string $slug = 'automation-api';
protected string $view = 'filament.admin.pages.automation-api';
public array $tokens = [];
public ?string $plainToken = null;
public function getTitle(): string|Htmlable
{
return __('API for Automation');
}
public static function getNavigationLabel(): string
{
return __('API Access');
}
public function mount(): void
{
$this->loadTokens();
}
protected function loadTokens(): void
{
$user = Auth::user();
if (! $user) {
$this->tokens = [];
return;
}
$this->tokens = $user->tokens()
->orderByDesc('created_at')
->get()
->map(function ($token) {
return [
'id' => $token->id,
'name' => $token->name,
'abilities' => implode(', ', $token->abilities ?? []),
'last_used_at' => $token->last_used_at?->format('Y-m-d H:i') ?? __('Never'),
'created_at' => $token->created_at?->format('Y-m-d H:i') ?? '',
];
})
->toArray();
$this->resetTable();
}
public function table(Table $table): Table
{
return $table
->records(fn () => $this->tokens)
->columns([
TextColumn::make('name')
->label(__('Token')),
TextColumn::make('abilities')
->label(__('Abilities'))
->wrap(),
TextColumn::make('last_used_at')
->label(__('Last Used')),
TextColumn::make('created_at')
->label(__('Created')),
])
->recordActions([
Action::make('revoke')
->label(__('Revoke'))
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->action(function (array $record): void {
$user = Auth::user();
if (! $user) {
return;
}
$user->tokens()->where('id', $record['id'])->delete();
Notification::make()->title(__('Token revoked'))->success()->send();
$this->loadTokens();
}),
])
->headerActions([
Action::make('createToken')
->label(__('Create Token'))
->icon('heroicon-o-plus-circle')
->color('primary')
->modalHeading(__('Create API Token'))
->form([
TextInput::make('name')
->label(__('Token Name'))
->required(),
CheckboxList::make('abilities')
->label(__('Abilities'))
->options([
'automation' => __('Automation API'),
'read' => __('Read Only'),
'write' => __('Write'),
])
->columns(2)
->default(['automation']),
])
->action(function (array $data): void {
$user = Auth::user();
if (! $user) {
return;
}
$abilities = $data['abilities'] ?? ['automation'];
if (empty($abilities)) {
$abilities = ['automation'];
}
$token = $user->createToken($data['name'], $abilities);
$this->plainToken = $token->plainTextToken;
Notification::make()->title(__('Token created'))->success()->send();
$this->loadTokens();
}),
])
->emptyStateHeading(__('No API tokens yet'))
->emptyStateDescription(__('Create a token to access the automation API.'));
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Pages;
use App\Filament\Admin\Widgets\Dashboard\RecentActivityTable;
use App\Filament\Admin\Widgets\DashboardStatsWidget;
use App\Models\DnsSetting;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Pages\Page;
use Filament\Schemas\Components\EmbeddedTable;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Illuminate\Contracts\Support\Htmlable;
class Dashboard extends Page implements HasActions, HasForms
{
use InteractsWithActions;
use InteractsWithForms;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-home';
protected static ?int $navigationSort = 0;
protected static ?string $slug = 'dashboard';
protected string $view = 'filament.admin.pages.dashboard';
public static function getNavigationLabel(): string
{
return __('Dashboard');
}
public function getTitle(): string|Htmlable
{
return __('Dashboard');
}
protected function getHeaderWidgets(): array
{
return [
DashboardStatsWidget::class,
];
}
protected function getForms(): array
{
return [
'dashboardForm',
];
}
public function dashboardForm(Schema $schema): Schema
{
return $schema->components([
// Recent Activity
Section::make(__('Recent Activity'))
->icon('heroicon-o-clock')
->schema([
EmbeddedTable::make(RecentActivityTable::class),
]),
]);
}
protected function getHeaderActions(): array
{
return [
Action::make('refresh')
->label(__('Refresh'))
->icon('heroicon-o-arrow-path')
->color('gray')
->action(fn () => $this->redirect(request()->url())),
Action::make('onboarding')
->label(__('Setup Wizard'))
->icon('heroicon-o-sparkles')
->visible(fn () => ! DnsSetting::get('onboarding_completed', false))
->modalHeading(__('Welcome to Jabali!'))
->modalDescription(__('Let\'s get your server control panel set up.'))
->modalWidth('md')
->form([
TextInput::make('admin_email')
->label(__('Your Email Address'))
->helperText(__('Enter your email to receive important server notifications.'))
->email()
->placeholder('admin@example.com'),
])
->modalSubmitActionLabel(__('Get Started'))
->action(function (array $data): void {
if (! empty($data['admin_email'])) {
DnsSetting::set('admin_email_recipients', $data['admin_email']);
}
DnsSetting::set('onboarding_completed', '1');
DnsSetting::clearCache();
}),
];
}
}

View File

@@ -0,0 +1,154 @@
<?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\Forms\Components\TextInput;
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;
class DatabaseTuning extends Page implements HasActions, HasTable
{
use InteractsWithActions;
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedCircleStack;
protected static ?int $navigationSort = 19;
protected static ?string $slug = 'database-tuning';
protected static bool $shouldRegisterNavigation = false;
protected string $view = 'filament.admin.pages.database-tuning';
public array $variables = [];
public function getTitle(): string|Htmlable
{
return __('Database Tuning');
}
public static function getNavigationLabel(): string
{
return __('Database Tuning');
}
public function mount(): void
{
$this->loadVariables();
}
protected function getAgent(): AgentClient
{
return app(AgentClient::class);
}
public function loadVariables(): void
{
$names = [
'innodb_buffer_pool_size',
'max_connections',
'tmp_table_size',
'max_heap_table_size',
'innodb_log_file_size',
'innodb_flush_log_at_trx_commit',
];
try {
$result = $this->getAgent()->databaseGetVariables($names);
if (! ($result['success'] ?? false)) {
throw new \RuntimeException($result['error'] ?? __('Unable to load variables'));
}
$this->variables = collect($result['variables'] ?? [])->map(function (array $row) {
return [
'name' => $row['name'] ?? '',
'value' => $row['value'] ?? '',
];
})->toArray();
} catch (\Exception $e) {
$this->variables = [];
Notification::make()
->title(__('Unable to load database variables'))
->body($e->getMessage())
->warning()
->send();
}
$this->resetTable();
}
public function table(Table $table): Table
{
return $table
->records(fn () => $this->variables)
->columns([
TextColumn::make('name')
->label(__('Variable'))
->fontFamily('mono'),
TextColumn::make('value')
->label(__('Current Value'))
->fontFamily('mono'),
])
->recordActions([
Action::make('update')
->label(__('Update'))
->icon('heroicon-o-pencil-square')
->form([
TextInput::make('value')
->label(__('New Value'))
->required(),
])
->action(function (array $record, array $data): void {
try {
try {
$agent = $this->getAgent();
$setResult = $agent->databaseSetGlobal($record['name'], (string) $data['value']);
if (! ($setResult['success'] ?? false)) {
throw new \RuntimeException($setResult['error'] ?? __('Update failed'));
}
$persistResult = $agent->databasePersistTuning($record['name'], (string) $data['value']);
if (! ($persistResult['success'] ?? false)) {
Notification::make()
->title(__('Variable updated, but not persisted'))
->body($persistResult['error'] ?? __('Unable to persist value'))
->warning()
->send();
} else {
Notification::make()
->title(__('Variable updated'))
->success()
->send();
}
} catch (\Exception $e) {
Notification::make()
->title(__('Update failed'))
->body($e->getMessage())
->danger()
->send();
}
} catch (\Exception $e) {
Notification::make()->title(__('Update failed'))->body($e->getMessage())->danger()->send();
}
$this->loadVariables();
}),
])
->emptyStateHeading(__('No variables found'))
->emptyStateDescription(__('Database variables could not be loaded.'));
}
}

View File

@@ -0,0 +1,975 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Pages;
use App\Filament\Admin\Widgets\DnsPendingAddsTable;
use App\Models\DnsRecord;
use App\Models\DnsSetting;
use App\Models\Domain;
use App\Services\Agent\AgentClient;
use BackedEnum;
use Exception;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\EmbeddedSchema;
use Filament\Schemas\Components\EmbeddedTable;
use Filament\Schemas\Components\EmptyState;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Text;
use Filament\Schemas\Schema;
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\Database\Eloquent\Builder;
use Illuminate\Support\Str;
use Livewire\Attributes\On;
class DnsZones extends Page implements HasActions, HasForms, HasTable
{
use InteractsWithActions;
use InteractsWithForms;
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-server-stack';
protected static ?int $navigationSort = 7;
public ?int $selectedDomainId = null;
protected ?AgentClient $agent = null;
// Pending changes tracking
public array $pendingEdits = []; // [record_id => [field => value]]
public array $pendingDeletes = []; // [record_id, ...]
public array $pendingAdds = []; // [[field => value], ...]
public static function getNavigationLabel(): string
{
return __('DNS Zones');
}
public function getTitle(): string|Htmlable
{
return __('DNS Zone Manager');
}
public function getAgent(): AgentClient
{
return $this->agent ??= new AgentClient;
}
public function mount(): void
{
// Start with no domain selected
$this->selectedDomainId = null;
}
public function form(Schema $form): Schema
{
return $form
->schema([
Select::make('selectedDomainId')
->label(__('Select Domain'))
->options(fn () => Domain::orderBy('domain')->pluck('domain', 'id')->toArray())
->searchable()
->preload()
->live()
->afterStateUpdated(fn () => $this->onDomainChange())
->placeholder(__('Select a domain to manage DNS records')),
]);
}
public function content(Schema $schema): Schema
{
return $schema->schema([
Section::make(__('Select Domain'))
->description(__('Choose a domain to manage DNS records.'))
->schema([
EmbeddedSchema::make('form'),
]),
Section::make(__('Zone Status'))
->description(fn () => $this->getSelectedDomain()?->domain)
->icon('heroicon-o-signal')
->headerActions([
Action::make('rebuildZone')
->label(__('Rebuild Zone'))
->icon('heroicon-o-arrow-path')
->color('gray')
->action(fn () => $this->rebuildCurrentZone()),
Action::make('deleteZone')
->label(__('Delete Zone'))
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->modalHeading(__('Delete DNS Zone'))
->modalDescription(__('Delete DNS zone for this domain? All records will be removed.'))
->action(fn () => $this->deleteCurrentZone()),
])
->schema([
Grid::make(['default' => 1, 'sm' => 3])->schema([
Text::make(fn () => (($this->getZoneStatus() ?? [])['zone_file_exists'] ?? false) ? __('Active') : __('Missing'))
->badge()
->color(fn () => (($this->getZoneStatus() ?? [])['zone_file_exists'] ?? false) ? 'success' : 'danger'),
Text::make(fn () => __(':count records', ['count' => ($this->getZoneStatus() ?? [])['records_count'] ?? 0]))
->badge()
->color('gray'),
Text::make(fn () => __('Owner: :owner', ['owner' => ($this->getZoneStatus() ?? [])['user'] ?? 'N/A']))
->color('gray'),
]),
])
->visible(fn () => $this->selectedDomainId !== null),
Section::make(__('New Records to Add'))
->description(__('These records will be created when you save changes.'))
->icon('heroicon-o-plus-circle')
->iconColor('success')
->collapsible()
->schema([
EmbeddedTable::make(DnsPendingAddsTable::class, fn () => [
'records' => $this->pendingAdds,
]),
])
->headerActions([
Action::make('clearPending')
->label(__('Clear All'))
->icon('heroicon-o-trash')
->color('danger')
->size('sm')
->requiresConfirmation()
->action(fn () => $this->clearPendingAdds()),
])
->visible(fn () => $this->selectedDomainId !== null && count($this->pendingAdds) > 0),
EmbeddedTable::make()
->visible(fn () => $this->selectedDomainId !== null),
EmptyState::make(__('No Domain Selected'))
->description(__('Select a domain from the dropdown above to manage DNS records.'))
->icon('heroicon-o-globe-alt')
->iconColor('gray')
->visible(fn () => $this->selectedDomainId === null),
]);
}
public function onDomainChange(): void
{
// Discard pending changes when switching domains
$this->pendingEdits = [];
$this->pendingDeletes = [];
$this->pendingAdds = [];
$this->resetTable();
}
public function updatedSelectedDomainId(): void
{
$this->onDomainChange();
}
// Pending changes helpers
public function hasPendingChanges(): bool
{
return count($this->pendingEdits) > 0 || count($this->pendingDeletes) > 0 || count($this->pendingAdds) > 0;
}
public function getPendingChangesCount(): int
{
return count($this->pendingEdits) + count($this->pendingDeletes) + count($this->pendingAdds);
}
public function isRecordPendingDelete(int $recordId): bool
{
return in_array($recordId, $this->pendingDeletes);
}
public function isRecordPendingEdit(int $recordId): bool
{
return isset($this->pendingEdits[$recordId]);
}
public function clearPendingAdds(): void
{
$this->pendingAdds = [];
Notification::make()->title(__('Pending records cleared'))->success()->send();
}
public function table(Table $table): Table
{
return $table
->query(
DnsRecord::query()
->when($this->selectedDomainId, fn (Builder $query) => $query->where('domain_id', $this->selectedDomainId))
->orderByRaw("CASE type
WHEN 'NS' THEN 1
WHEN 'A' THEN 2
WHEN 'AAAA' THEN 3
WHEN 'CNAME' THEN 4
WHEN 'MX' THEN 5
WHEN 'TXT' THEN 6
WHEN 'SRV' THEN 7
WHEN 'CAA' THEN 8
ELSE 9 END")
->orderBy('name')
)
->columns([
TextColumn::make('type')
->label(__('Type'))
->badge()
->color(fn (DnsRecord $record) => $this->isRecordPendingDelete($record->id) ? 'danger' : match ($record->type) {
'A', 'AAAA' => 'info',
'CNAME' => 'primary',
'MX' => 'warning',
'TXT' => 'success',
'NS' => 'danger',
'SRV' => 'primary',
'CAA' => 'warning',
default => 'gray',
})
->sortable(),
TextColumn::make('name')
->label(__('Name'))
->fontFamily('mono')
->color(fn (DnsRecord $record) => $this->isRecordPendingDelete($record->id) ? 'danger' : null)
->searchable(),
TextColumn::make('content')
->label(__('Content'))
->fontFamily('mono')
->limit(50)
->tooltip(fn ($record) => $record->content)
->color(fn (DnsRecord $record) => $this->isRecordPendingDelete($record->id) ? 'danger' : ($this->isRecordPendingEdit($record->id) ? 'warning' : null))
->searchable(),
TextColumn::make('ttl')
->label(__('TTL'))
->color(fn (DnsRecord $record) => $this->isRecordPendingDelete($record->id) ? 'danger' : null)
->sortable(),
TextColumn::make('priority')
->label(__('Priority'))
->placeholder('-')
->color(fn (DnsRecord $record) => $this->isRecordPendingDelete($record->id) ? 'danger' : null)
->sortable(),
TextColumn::make('domain.user.username')
->label(__('Owner'))
->placeholder('N/A')
->sortable(),
])
->filters([])
->headerActions([
Action::make('resetToDefaults')
->label(__('Reset to Defaults'))
->icon('heroicon-o-arrow-path')
->color('gray')
->requiresConfirmation()
->modalHeading(__('Reset DNS Records'))
->modalDescription(__('This will delete all existing DNS records and create default records. This action cannot be undone.'))
->modalIcon('heroicon-o-exclamation-triangle')
->modalIconColor('warning')
->action(fn () => $this->resetToDefaults()),
Action::make('saveChanges')
->label(__('Save'))
->icon('heroicon-o-check')
->color('primary')
->action(fn () => $this->saveChanges()),
])
->recordActions([
Action::make('edit')
->label(__('Edit'))
->icon('heroicon-o-pencil')
->color(fn (DnsRecord $record) => $this->isRecordPendingEdit($record->id) ? 'warning' : 'gray')
->hidden(fn (DnsRecord $record) => $this->isRecordPendingDelete($record->id))
->modalHeading(__('Edit DNS Record'))
->modalDescription(__('Changes will be queued until you click "Save Changes".'))
->modalIcon('heroicon-o-pencil-square')
->modalIconColor('primary')
->modalSubmitActionLabel(__('Queue Changes'))
->fillForm(fn (DnsRecord $record) => [
'type' => $this->pendingEdits[$record->id]['type'] ?? $record->type,
'name' => $this->pendingEdits[$record->id]['name'] ?? $record->name,
'content' => $this->pendingEdits[$record->id]['content'] ?? $record->content,
'ttl' => $this->pendingEdits[$record->id]['ttl'] ?? $record->ttl,
'priority' => $this->pendingEdits[$record->id]['priority'] ?? $record->priority,
])
->form([
Select::make('type')
->label(__('Record Type'))
->options([
'A' => __('A - IPv4 Address'),
'AAAA' => __('AAAA - IPv6 Address'),
'CNAME' => __('CNAME - Canonical Name'),
'MX' => __('MX - Mail Exchange'),
'TXT' => __('TXT - Text Record'),
'NS' => __('NS - Nameserver'),
'SRV' => __('SRV - Service'),
'CAA' => __('CAA - Certificate Authority'),
])
->required()
->reactive(),
TextInput::make('name')
->label(__('Name'))
->placeholder(__('@ for root, or subdomain'))
->required()
->maxLength(255),
TextInput::make('content')
->label(__('Content'))
->required()
->maxLength(1024),
TextInput::make('ttl')
->label(__('TTL (seconds)'))
->numeric()
->minValue(60)
->maxValue(86400),
TextInput::make('priority')
->label(__('Priority'))
->numeric()
->visible(fn ($get) => in_array($get('type'), ['MX', 'SRV'])),
])
->action(function (DnsRecord $record, array $data): void {
// Queue the edit
$this->pendingEdits[$record->id] = [
'type' => $data['type'],
'name' => $data['name'],
'content' => $data['content'],
'ttl' => $data['ttl'] ?? 3600,
'priority' => $data['priority'] ?? null,
];
Notification::make()
->title(__('Edit queued'))
->body(__('Click "Save Changes" to apply.'))
->info()
->send();
}),
Action::make('delete')
->label(__('Delete'))
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->modalHeading(__('Delete Record'))
->modalDescription(fn (DnsRecord $record) => __('Delete the :type record for :name?', ['type' => $record->type, 'name' => $record->name]))
->modalIcon('heroicon-o-trash')
->modalIconColor('danger')
->modalSubmitActionLabel(__('Delete'))
->action(function (DnsRecord $record): void {
if (! in_array($record->id, $this->pendingDeletes)) {
$this->pendingDeletes[] = $record->id;
}
unset($this->pendingEdits[$record->id]);
}),
])
->emptyStateHeading(__('No DNS records'))
->emptyStateDescription(__('Add DNS records to manage this domain\'s DNS configuration.'))
->emptyStateIcon('heroicon-o-server-stack')
->striped();
}
public function getSelectedDomain(): ?Domain
{
return $this->selectedDomainId ? Domain::find($this->selectedDomainId) : null;
}
public function getZoneStatus(): ?array
{
if (! $this->selectedDomainId) {
return null;
}
$domain = Domain::find($this->selectedDomainId);
if (! $domain) {
return null;
}
$zoneFile = "/etc/bind/zones/db.{$domain->domain}";
$recordsCount = DnsRecord::where('domain_id', $this->selectedDomainId)->count();
return [
'domain' => $domain->domain,
'user' => $domain->user->username ?? 'N/A',
'records_count' => $recordsCount,
'zone_file_exists' => file_exists($zoneFile),
];
}
#[On('dns-pending-add-remove')]
public function removePendingAddFromTable(string $key): void
{
$this->removePendingAdd($key);
}
public function removePendingAdd(int|string $identifier): void
{
if (is_int($identifier)) {
unset($this->pendingAdds[$identifier]);
$this->pendingAdds = array_values($this->pendingAdds);
Notification::make()->title(__('Pending record removed'))->success()->send();
return;
}
$this->pendingAdds = array_values(array_filter(
$this->pendingAdds,
fn (array $record): bool => ($record['key'] ?? null) !== $identifier
));
Notification::make()->title(__('Pending record removed'))->success()->send();
}
protected function queuePendingAdd(array $record): void
{
$record['key'] ??= (string) Str::uuid();
$this->pendingAdds[] = $record;
}
protected function sanitizePendingAdd(array $record): array
{
unset($record['key']);
return $record;
}
public function saveChanges(bool $notify = true): void
{
if (! $this->hasPendingChanges()) {
if ($notify) {
Notification::make()->title(__('No changes to save'))->warning()->send();
}
return;
}
$domain = Domain::find($this->selectedDomainId);
if (! $domain) {
Notification::make()->title(__('Domain not found'))->danger()->send();
return;
}
try {
// Apply deletes
foreach ($this->pendingDeletes as $recordId) {
DnsRecord::where('id', $recordId)->delete();
}
// Apply edits
foreach ($this->pendingEdits as $recordId => $data) {
$record = DnsRecord::find($recordId);
if ($record) {
$record->update($data);
}
}
// Apply adds
foreach ($this->pendingAdds as $data) {
DnsRecord::create(array_merge(['domain_id' => $this->selectedDomainId], $this->sanitizePendingAdd($data)));
}
// Sync zone file
$this->syncZoneFile($domain->domain);
// Clear pending changes
$this->pendingEdits = [];
$this->pendingDeletes = [];
$this->pendingAdds = [];
// Reset table to refresh data
$this->resetTable();
if ($notify) {
Notification::make()
->title(__('Changes saved'))
->body(__('DNS records updated. Changes may take up to 48 hours to propagate.'))
->success()
->send();
}
} catch (Exception $e) {
Notification::make()
->title(__('Failed to save changes'))
->body($e->getMessage())
->danger()
->send();
}
}
public function discardChanges(): void
{
$this->pendingEdits = [];
$this->pendingDeletes = [];
$this->pendingAdds = [];
Notification::make()->title(__('Changes discarded'))->success()->send();
}
public function resetToDefaults(): void
{
$domain = Domain::find($this->selectedDomainId);
if (! $domain) {
Notification::make()->title(__('Domain not found'))->danger()->send();
return;
}
try {
// Delete all existing records
DnsRecord::where('domain_id', $this->selectedDomainId)->delete();
// Create default records
$settings = DnsSetting::getAll();
$serverIp = $domain->ip_address ?: ($settings['default_ip'] ?? trim(shell_exec("hostname -I | awk '{print $1}'") ?? '') ?: '127.0.0.1');
$serverIpv6 = $domain->ipv6_address ?: ($settings['default_ipv6'] ?? null);
$ns1 = $settings['ns1'] ?? 'ns1.'.$domain->domain;
$ns2 = $settings['ns2'] ?? 'ns2.'.$domain->domain;
$defaultRecords = [
['name' => '@', 'type' => 'NS', 'content' => $ns1, 'ttl' => 3600, 'priority' => null],
['name' => '@', 'type' => 'NS', 'content' => $ns2, 'ttl' => 3600, 'priority' => null],
['name' => '@', 'type' => 'A', 'content' => $serverIp, 'ttl' => 3600, 'priority' => null],
['name' => 'www', 'type' => 'A', 'content' => $serverIp, 'ttl' => 3600, 'priority' => null],
['name' => 'mail', 'type' => 'A', 'content' => $serverIp, 'ttl' => 3600, 'priority' => null],
['name' => '@', 'type' => 'MX', 'content' => 'mail.'.$domain->domain, 'ttl' => 3600, 'priority' => 10],
['name' => '@', 'type' => 'TXT', 'content' => 'v=spf1 mx a ~all', 'ttl' => 3600, 'priority' => null],
];
if (! empty($serverIpv6)) {
$defaultRecords[] = ['name' => '@', 'type' => 'AAAA', 'content' => $serverIpv6, 'ttl' => 3600, 'priority' => null];
$defaultRecords[] = ['name' => 'www', 'type' => 'AAAA', 'content' => $serverIpv6, 'ttl' => 3600, 'priority' => null];
$defaultRecords[] = ['name' => 'mail', 'type' => 'AAAA', 'content' => $serverIpv6, 'ttl' => 3600, 'priority' => null];
}
$defaultRecords = $this->appendNameserverRecords(
$defaultRecords,
$domain->domain,
$ns1,
$ns2,
$serverIp,
$serverIpv6,
3600
);
foreach ($defaultRecords as $record) {
DnsRecord::create(array_merge(['domain_id' => $this->selectedDomainId], $record));
}
// Sync zone file
$this->syncZoneFile($domain->domain);
// Clear any pending changes
$this->pendingEdits = [];
$this->pendingDeletes = [];
$this->pendingAdds = [];
$this->resetTable();
Notification::make()
->title(__('DNS records reset'))
->body(__('Default records have been created for :domain', ['domain' => $domain->domain]))
->success()
->send();
} catch (Exception $e) {
Notification::make()
->title(__('Failed to reset records'))
->body($e->getMessage())
->danger()
->send();
}
}
protected function getHeaderActions(): array
{
return [
Action::make('syncAllZones')
->label(__('Sync All Zones'))
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
->modalDescription(__('This will regenerate all zone files from the database.'))
->action(function () {
$count = 0;
$domains = Domain::all();
$settings = DnsSetting::getAll();
foreach ($domains as $domain) {
try {
$records = DnsRecord::where('domain_id', $domain->id)->get()->toArray();
$this->getAgent()->send('dns.sync_zone', [
'domain' => $domain->domain,
'records' => $records,
'ns1' => $settings['ns1'] ?? 'ns1.example.com',
'ns2' => $settings['ns2'] ?? 'ns2.example.com',
'admin_email' => $settings['admin_email'] ?? 'admin.example.com',
'default_ttl' => $settings['default_ttl'] ?? 3600,
]);
$count++;
} catch (Exception $e) {
// Continue with other zones
}
}
Notification::make()->title(__(':count zones synced', ['count' => $count]))->success()->send();
}),
$this->applyTemplateAction()
->visible(fn () => $this->selectedDomainId !== null),
$this->addRecordAction()
->visible(fn () => $this->selectedDomainId !== null),
];
}
public function addRecordAction(): Action
{
return Action::make('addRecord')
->label(__('Add Record'))
->icon('heroicon-o-plus')
->color('primary')
->modalHeading(__('Add DNS Record'))
->modalDescription(__('The record will be queued until you click "Save Changes".'))
->modalIcon('heroicon-o-plus-circle')
->modalIconColor('primary')
->modalSubmitActionLabel(__('Queue Record'))
->modalWidth('lg')
->form([
Select::make('type')
->label(__('Record Type'))
->options([
'A' => __('A - IPv4 Address'),
'AAAA' => __('AAAA - IPv6 Address'),
'CNAME' => __('CNAME - Canonical Name'),
'MX' => __('MX - Mail Exchange'),
'TXT' => __('TXT - Text Record'),
'NS' => __('NS - Nameserver'),
'SRV' => __('SRV - Service'),
'CAA' => __('CAA - Certificate Authority'),
])
->required()
->reactive(),
TextInput::make('name')
->label(__('Name'))
->placeholder(__('@ for root, or subdomain'))
->required(),
TextInput::make('content')
->label(__('Content'))
->required(),
TextInput::make('ttl')
->label(__('TTL'))
->numeric()
->default(3600),
TextInput::make('priority')
->label(__('Priority'))
->numeric()
->visible(fn ($get) => in_array($get('type'), ['MX', 'SRV'])),
])
->action(function (array $data) {
// Queue the add
$this->queuePendingAdd([
'type' => $data['type'],
'name' => $data['name'],
'content' => $data['content'],
'ttl' => $data['ttl'] ?? 3600,
'priority' => $data['priority'] ?? null,
]);
Notification::make()
->title(__('Record queued'))
->body(__('Click "Save Changes" to apply.'))
->info()
->send();
});
}
public function applyTemplateAction(): Action
{
return Action::make('applyTemplate')
->label(__('Apply Template'))
->icon('heroicon-o-document-duplicate')
->color('gray')
->modalHeading(__('Apply Email Template'))
->modalDescription(__('This will apply the selected email DNS records immediately.'))
->modalIcon('heroicon-o-envelope')
->modalIconColor('warning')
->modalSubmitActionLabel(__('Apply Template'))
->modalWidth('lg')
->form([
Select::make('template')
->label(__('Email Provider'))
->options([
'google' => __('Google Workspace (Gmail)'),
'microsoft' => __('Microsoft 365 (Outlook)'),
'zoho' => __('Zoho Mail'),
'protonmail' => __('ProtonMail'),
'fastmail' => __('Fastmail'),
'local' => __('Local Mail Server (This Server)'),
'none' => __('Remove All Email Records'),
])
->required()
->reactive(),
TextInput::make('verification_code')
->label(__('Domain Verification Code (optional)'))
->placeholder(__('e.g., google-site-verification=xxx'))
->visible(fn ($get) => $get('template') && $get('template') !== 'none'),
])
->action(function (array $data) {
$domain = Domain::find($this->selectedDomainId);
if (! $domain) {
Notification::make()->title(__('Domain not found'))->danger()->send();
return;
}
$domainName = $domain->domain;
$template = $data['template'];
$verificationCode = $data['verification_code'] ?? null;
// Queue deletion of existing MX and email-related records
$recordsToDelete = DnsRecord::where('domain_id', $this->selectedDomainId)
->where(function ($query) {
$query->where('type', 'MX')
->orWhere(function ($q) {
$q->where('type', 'A')->where('name', 'mail');
})
->orWhere(function ($q) {
$q->where('type', 'CNAME')->where('name', 'autodiscover');
})
->orWhere(function ($q) {
$q->where('type', 'TXT')
->where(function ($inner) {
$inner->where('content', 'like', '%spf%')
->orWhere('content', 'like', '%v=spf1%')
->orWhere('content', 'like', '%google-site-verification%')
->orWhere('content', 'like', '%MS=%')
->orWhere('content', 'like', '%zoho-verification%')
->orWhere('content', 'like', '%protonmail-verification%')
->orWhere('name', 'like', '%_domainkey%');
});
});
})
->pluck('id')
->toArray();
foreach ($recordsToDelete as $id) {
if (! in_array($id, $this->pendingDeletes)) {
$this->pendingDeletes[] = $id;
}
unset($this->pendingEdits[$id]);
}
// Queue new records
if ($template !== 'none') {
$records = $this->getTemplateRecords($template, $domainName, $verificationCode);
foreach ($records as $record) {
$this->queuePendingAdd($record);
}
}
if (! $this->hasPendingChanges()) {
Notification::make()
->title(__('No changes to apply'))
->warning()
->send();
return;
}
$this->saveChanges(false);
$message = $template === 'none'
? __('Email records removed.')
: __('Email records for :provider have been applied.', ['provider' => ucfirst($template)]);
Notification::make()
->title(__('Template applied'))
->body($message.' '.__('Changes may take up to 48 hours to propagate.'))
->success()
->send();
});
}
protected function getTemplateRecords(string $template, string $domain, ?string $verificationCode): array
{
$settings = DnsSetting::getAll();
$serverIp = $settings['default_ip'] ?? trim(shell_exec("hostname -I | awk '{print $1}'") ?? '') ?: '127.0.0.1';
$records = match ($template) {
'google' => [
['name' => '@', 'type' => 'MX', 'content' => 'aspmx.l.google.com', 'ttl' => 3600, 'priority' => 1],
['name' => '@', 'type' => 'MX', 'content' => 'alt1.aspmx.l.google.com', 'ttl' => 3600, 'priority' => 5],
['name' => '@', 'type' => 'MX', 'content' => 'alt2.aspmx.l.google.com', 'ttl' => 3600, 'priority' => 5],
['name' => '@', 'type' => 'MX', 'content' => 'alt3.aspmx.l.google.com', 'ttl' => 3600, 'priority' => 10],
['name' => '@', 'type' => 'MX', 'content' => 'alt4.aspmx.l.google.com', 'ttl' => 3600, 'priority' => 10],
['name' => '@', 'type' => 'TXT', 'content' => 'v=spf1 include:_spf.google.com ~all', 'ttl' => 3600, 'priority' => null],
],
'microsoft' => [
['name' => '@', 'type' => 'MX', 'content' => str_replace('.', '-', $domain).'.mail.protection.outlook.com', 'ttl' => 3600, 'priority' => 0],
['name' => '@', 'type' => 'TXT', 'content' => 'v=spf1 include:spf.protection.outlook.com ~all', 'ttl' => 3600, 'priority' => null],
['name' => 'autodiscover', 'type' => 'CNAME', 'content' => 'autodiscover.outlook.com', 'ttl' => 3600, 'priority' => null],
],
'zoho' => [
['name' => '@', 'type' => 'MX', 'content' => 'mx.zoho.com', 'ttl' => 3600, 'priority' => 10],
['name' => '@', 'type' => 'MX', 'content' => 'mx2.zoho.com', 'ttl' => 3600, 'priority' => 20],
['name' => '@', 'type' => 'MX', 'content' => 'mx3.zoho.com', 'ttl' => 3600, 'priority' => 50],
['name' => '@', 'type' => 'TXT', 'content' => 'v=spf1 include:zoho.com ~all', 'ttl' => 3600, 'priority' => null],
],
'protonmail' => [
['name' => '@', 'type' => 'MX', 'content' => 'mail.protonmail.ch', 'ttl' => 3600, 'priority' => 10],
['name' => '@', 'type' => 'MX', 'content' => 'mailsec.protonmail.ch', 'ttl' => 3600, 'priority' => 20],
['name' => '@', 'type' => 'TXT', 'content' => 'v=spf1 include:_spf.protonmail.ch mx ~all', 'ttl' => 3600, 'priority' => null],
],
'fastmail' => [
['name' => '@', 'type' => 'MX', 'content' => 'in1-smtp.messagingengine.com', 'ttl' => 3600, 'priority' => 10],
['name' => '@', 'type' => 'MX', 'content' => 'in2-smtp.messagingengine.com', 'ttl' => 3600, 'priority' => 20],
['name' => '@', 'type' => 'TXT', 'content' => 'v=spf1 include:spf.messagingengine.com ~all', 'ttl' => 3600, 'priority' => null],
],
'local' => [
['name' => '@', 'type' => 'MX', 'content' => 'mail.'.$domain, 'ttl' => 3600, 'priority' => 10],
['name' => 'mail', 'type' => 'A', 'content' => $serverIp, 'ttl' => 3600, 'priority' => null],
['name' => '@', 'type' => 'TXT', 'content' => 'v=spf1 mx a ~all', 'ttl' => 3600, 'priority' => null],
],
default => [],
};
if ($verificationCode) {
$records[] = ['name' => '@', 'type' => 'TXT', 'content' => $verificationCode, 'ttl' => 3600, 'priority' => null];
}
return $records;
}
public function rebuildCurrentZone(): void
{
if (! $this->selectedDomainId) {
return;
}
$domain = Domain::find($this->selectedDomainId);
if (! $domain) {
return;
}
try {
$this->syncZoneFile($domain->domain);
Notification::make()->title(__('Zone rebuilt for :domain', ['domain' => $domain->domain]))->success()->send();
} catch (Exception $e) {
Notification::make()->title(__('Failed'))->body($e->getMessage())->danger()->send();
}
}
public function deleteCurrentZone(): void
{
if (! $this->selectedDomainId) {
return;
}
$domain = Domain::find($this->selectedDomainId);
if (! $domain) {
return;
}
try {
$this->getAgent()->send('dns.delete_zone', ['domain' => $domain->domain]);
DnsRecord::where('domain_id', $this->selectedDomainId)->delete();
Notification::make()->title(__('Zone deleted for :domain', ['domain' => $domain->domain]))->success()->send();
$this->selectedDomainId = null;
$this->pendingEdits = [];
$this->pendingDeletes = [];
$this->pendingAdds = [];
} catch (Exception $e) {
Notification::make()->title(__('Failed'))->body($e->getMessage())->danger()->send();
}
}
protected function syncZoneFile(string $domain): void
{
$records = DnsRecord::whereHas('domain', fn ($q) => $q->where('domain', $domain))->get()->toArray();
$settings = DnsSetting::getAll();
$defaultIp = $settings['default_ip'] ?? trim(shell_exec("hostname -I | awk '{print $1}'") ?? '') ?: '127.0.0.1';
$this->getAgent()->send('dns.sync_zone', [
'domain' => $domain,
'records' => $records,
'ns1' => $settings['ns1'] ?? 'ns1.example.com',
'ns2' => $settings['ns2'] ?? 'ns2.example.com',
'admin_email' => $settings['admin_email'] ?? 'admin.example.com',
'default_ip' => $defaultIp,
'default_ipv6' => $settings['default_ipv6'] ?? null,
'default_ttl' => $settings['default_ttl'] ?? 3600,
]);
}
/**
* @param array<int, array<string, mixed>> $records
* @return array<int, array<string, mixed>>
*/
protected function appendNameserverRecords(
array $records,
string $domain,
string $ns1,
string $ns2,
string $ipv4,
?string $ipv6,
int $ttl
): array {
$labels = $this->getNameserverLabels($domain, [$ns1, $ns2]);
if ($labels === []) {
return $records;
}
$existingA = array_map(
fn (array $record): string => $record['name'] ?? '',
array_filter($records, fn (array $record): bool => ($record['type'] ?? '') === 'A')
);
$existingAAAA = array_map(
fn (array $record): string => $record['name'] ?? '',
array_filter($records, fn (array $record): bool => ($record['type'] ?? '') === 'AAAA')
);
foreach ($labels as $label) {
if (! in_array($label, $existingA, true)) {
$records[] = ['name' => $label, 'type' => 'A', 'content' => $ipv4, 'ttl' => $ttl, 'priority' => null];
}
if ($ipv6 && ! in_array($label, $existingAAAA, true)) {
$records[] = ['name' => $label, 'type' => 'AAAA', 'content' => $ipv6, 'ttl' => $ttl, 'priority' => null];
}
}
return $records;
}
/**
* @param array<int, string> $nameservers
* @return array<int, string>
*/
protected function getNameserverLabels(string $domain, array $nameservers): array
{
$domain = rtrim($domain, '.');
$labels = [];
foreach ($nameservers as $nameserver) {
$nameserver = rtrim($nameserver ?? '', '.');
if ($nameserver === '') {
continue;
}
if ($nameserver === $domain) {
$label = '@';
} elseif (str_ends_with($nameserver, '.'.$domain)) {
$label = substr($nameserver, 0, -strlen('.'.$domain));
} else {
continue;
}
if ($label !== '@') {
$labels[] = $label;
}
}
return array_values(array_unique($labels));
}
}

View 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(),
],
);
}
}

View File

@@ -0,0 +1,176 @@
<?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;
class EmailQueue extends Page implements HasActions, HasTable
{
use InteractsWithActions;
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedQueueList;
protected static ?int $navigationSort = null;
protected static ?string $slug = 'email-queue';
protected static bool $shouldRegisterNavigation = false;
protected string $view = 'filament.admin.pages.email-queue';
public array $queueItems = [];
protected ?AgentClient $agent = null;
protected bool $queueLoaded = false;
public function getTitle(): string|Htmlable
{
return __('Email Queue Manager');
}
public static function getNavigationLabel(): string
{
return __('Email Queue');
}
public function mount(): void
{
$this->redirect(EmailLogs::getUrl());
}
protected function getAgent(): AgentClient
{
return $this->agent ??= new AgentClient;
}
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 table(Table $table): Table
{
return $table
->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([
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 (array $record): string {
$recipients = $record['recipients'] ?? [];
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 (array $record): string => $record['size'] ?? ''),
TextColumn::make('status')
->label(__('Status'))
->wrap(),
])
->recordActions([
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();
}
}),
])
->emptyStateHeading(__('Mail queue is empty'))
->emptyStateDescription(__('No deferred messages found.'))
->headerActions([
Action::make('refresh')
->label(__('Refresh'))
->icon('heroicon-o-arrow-path')
->action(fn () => $this->loadQueue()),
]);
}
}

View File

@@ -0,0 +1,330 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Pages;
use App\Models\DnsSetting;
use App\Services\Agent\AgentClient;
use BackedEnum;
use Exception;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable;
class IpAddresses extends Page implements HasActions, HasTable
{
use InteractsWithActions;
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-signal';
protected static ?int $navigationSort = 9;
protected string $view = 'filament.admin.pages.ip-addresses';
public array $addresses = [];
public array $interfaces = [];
public ?string $defaultIp = null;
public ?string $defaultIpv6 = null;
protected ?AgentClient $agent = null;
public static function getNavigationLabel(): string
{
return __('IP Addresses');
}
public function getTitle(): string|Htmlable
{
return __('IP Address Manager');
}
public function mount(): void
{
$this->loadAddresses();
}
protected function getAgent(): AgentClient
{
return $this->agent ??= new AgentClient;
}
protected function loadAddresses(): void
{
try {
$result = $this->getAgent()->ipList();
$this->addresses = $result['addresses'] ?? [];
$this->interfaces = $result['interfaces'] ?? [];
} catch (Exception $e) {
$this->addresses = [];
$this->interfaces = [];
}
$settings = DnsSetting::getAll();
$this->defaultIp = $settings['default_ip'] ?? null;
$this->defaultIpv6 = $settings['default_ipv6'] ?? null;
$this->flushCachedTableRecords();
}
protected function getHeaderActions(): array
{
return [
Action::make('refresh')
->label(__('Refresh'))
->icon('heroicon-o-arrow-path')
->color('gray')
->action(fn () => $this->loadAddresses()),
Action::make('addIp')
->label(__('Add IP'))
->icon('heroicon-o-plus-circle')
->color('primary')
->modalHeading(__('Add IP Address'))
->modalDescription(__('Assign a new IPv4 or IPv6 address to a network interface.'))
->modalSubmitActionLabel(__('Add IP'))
->form([
TextInput::make('ip')
->label(__('IP Address'))
->placeholder('203.0.113.10')
->live()
->afterStateUpdated(function (?string $state, callable $set): void {
if (! $state) {
return;
}
if (str_contains($state, ':')) {
$set('cidr', 64);
return;
}
$set('cidr', 24);
})
->rule('ip')
->required(),
TextInput::make('cidr')
->label(__('CIDR'))
->numeric()
->minValue(1)
->maxValue(128)
->default(24)
->required(),
Select::make('interface')
->label(__('Interface'))
->options(fn () => $this->getInterfaceOptions())
->searchable()
->required(),
])
->action(function (array $data): void {
try {
$result = $this->getAgent()->ipAdd($data['ip'], (int) $data['cidr'], $data['interface']);
if ($result['success'] ?? false) {
$message = $result['message'] ?? __('IP added successfully');
if (! ($result['persistent'] ?? true)) {
$message .= ' '.__('(Persistence not configured for this system)');
}
Notification::make()
->title(__('IP address added'))
->body($message)
->success()
->send();
$this->dispatch('notificationsSent');
$this->loadAddresses();
$this->dispatch('ip-defaults-updated');
} else {
throw new Exception($result['error'] ?? __('Failed to add IP address'));
}
} catch (Exception $e) {
Notification::make()
->title(__('Failed to add IP'))
->body($e->getMessage())
->danger()
->send();
$this->dispatch('notificationsSent');
}
}),
];
}
public function table(Table $table): Table
{
return $table
->records(fn () => $this->addresses)
->columns([
TextColumn::make('ip')
->label(__('IP Address'))
->fontFamily('mono')
->copyable()
->searchable(),
TextColumn::make('version')
->label(__('Version'))
->badge()
->formatStateUsing(fn ($state): string => (int) $state === 6 ? 'IPv6' : 'IPv4')
->color(fn ($state): string => (int) $state === 6 ? 'primary' : 'info'),
TextColumn::make('interface')
->label(__('Interface'))
->badge()
->color('gray'),
TextColumn::make('cidr')
->label(__('CIDR'))
->alignCenter(),
TextColumn::make('scope')
->label(__('Scope'))
->badge()
->color(fn (?string $state): string => $state === 'global' ? 'success' : 'gray')
->formatStateUsing(fn (?string $state): string => $state ? ucfirst($state) : '-'),
IconColumn::make('is_primary')
->label(__('Primary'))
->boolean(),
TextColumn::make('default')
->label(__('Default'))
->getStateUsing(fn (array $record): ?string => $this->getDefaultLabel($record))
->badge()
->color('success')
->placeholder('-'),
])
->recordActions([
Action::make('setDefault')
->label(fn (array $record): string => ($record['version'] ?? 4) === 6 ? __('Set Default IPv6') : __('Set Default IPv4'))
->icon('heroicon-o-star')
->color('primary')
->visible(fn (array $record): bool => ! $this->isDefaultIp($record))
->action(fn (array $record) => $this->setDefaultIp($record)),
Action::make('remove')
->label(__('Remove'))
->icon('heroicon-o-trash')
->color('danger')
->visible(fn (array $record): bool => ! $this->isDefaultIp($record))
->requiresConfirmation()
->modalHeading(__('Remove IP Address'))
->modalDescription(fn (array $record): string => __('Remove :ip from :iface?', ['ip' => $record['ip'] ?? '-', 'iface' => $record['interface'] ?? '-']))
->modalSubmitActionLabel(__('Remove IP'))
->action(fn (array $record) => $this->removeIp($record)),
])
->striped()
->paginated(false)
->emptyStateHeading(__('No IP addresses found'))
->emptyStateDescription(__('Add an IP address to begin managing assignments.'))
->emptyStateIcon('heroicon-o-signal');
}
protected function getInterfaceOptions(): array
{
if (empty($this->interfaces)) {
$this->loadAddresses();
}
$options = [];
foreach ($this->interfaces as $interface) {
$options[$interface] = $interface;
}
return $options;
}
protected function getDefaultLabel(array $record): ?string
{
$ip = $record['ip'] ?? null;
if (! $ip) {
return null;
}
if ($this->defaultIp === $ip) {
return __('Default IPv4');
}
if ($this->defaultIpv6 === $ip) {
return __('Default IPv6');
}
return null;
}
protected function isDefaultIp(array $record): bool
{
$ip = $record['ip'] ?? null;
if (! $ip) {
return false;
}
return $ip === $this->defaultIp || $ip === $this->defaultIpv6;
}
protected function setDefaultIp(array $record): void
{
$ip = $record['ip'] ?? null;
$version = (int) ($record['version'] ?? 4);
if (! $ip) {
return;
}
if ($version === 6) {
DnsSetting::set('default_ipv6', $ip);
$this->defaultIpv6 = $ip;
} else {
DnsSetting::set('default_ip', $ip);
$this->defaultIp = $ip;
}
DnsSetting::clearCache();
Notification::make()
->title(__('Default IP updated'))
->success()
->send();
$this->dispatch('ip-defaults-updated');
}
protected function removeIp(array $record): void
{
$ip = $record['ip'] ?? null;
$cidr = $record['cidr'] ?? null;
$interface = $record['interface'] ?? null;
if (! $ip || ! $cidr || ! $interface) {
return;
}
try {
$result = $this->getAgent()->ipRemove($ip, (int) $cidr, $interface);
if (! ($result['success'] ?? false)) {
throw new Exception($result['error'] ?? __('Failed to remove IP address'));
}
Notification::make()
->title(__('IP address removed'))
->success()
->send();
$this->dispatch('notificationsSent');
$this->loadAddresses();
$this->dispatch('ip-defaults-updated');
} catch (Exception $e) {
Notification::make()
->title(__('Failed to remove IP'))
->body($e->getMessage())
->danger()
->send();
$this->dispatch('notificationsSent');
}
}
}

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Pages;
use BackedEnum;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Pages\Page;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\View;
use Filament\Schemas\Schema;
use Illuminate\Contracts\Support\Htmlable;
use Livewire\Attributes\Url;
class Migration extends Page implements HasForms
{
use InteractsWithForms;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrow-down-tray';
protected static ?string $navigationLabel = null;
protected static ?int $navigationSort = 12;
protected string $view = 'filament.admin.pages.migration';
#[Url(as: 'migration')]
public string $activeTab = 'cpanel';
public static function getNavigationLabel(): string
{
return __('Migration');
}
public function getTitle(): string|Htmlable
{
return __('Migration');
}
public function getSubheading(): ?string
{
return __('Migrate cPanel accounts directly or via WHM');
}
public function mount(): void
{
if (! in_array($this->activeTab, ['cpanel', 'whm'], true)) {
$this->activeTab = 'cpanel';
}
}
public function updatedActiveTab(string $activeTab): void
{
if (! in_array($activeTab, ['cpanel', 'whm'], true)) {
$this->activeTab = 'cpanel';
}
}
protected function getForms(): array
{
return ['migrationForm'];
}
public function migrationForm(Schema $schema): Schema
{
return $schema->schema([
Tabs::make(__('Migration Type'))
->livewireProperty('activeTab')
->tabs([
'cpanel' => Tabs\Tab::make(__('cPanel Migration'))
->icon('heroicon-o-arrow-down-tray')
->schema([
View::make('filament.admin.pages.migration-cpanel-tab'),
]),
'whm' => Tabs\Tab::make(__('WHM Migration'))
->icon('heroicon-o-server-stack')
->schema([
View::make('filament.admin.pages.migration-whm-tab'),
]),
]),
]);
}
}

View File

@@ -0,0 +1,291 @@
<?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\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable;
class PhpManager extends Page implements HasActions, HasForms, HasTable
{
use InteractsWithActions;
use InteractsWithForms;
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-code-bracket';
protected static ?int $navigationSort = 6;
protected static ?string $slug = 'php-manager';
protected string $view = 'filament.admin.pages.php-manager';
public array $installedVersions = [];
public array $availableVersions = [];
public ?string $defaultVersion = null;
public static function getNavigationLabel(): string
{
return __('PHP Manager');
}
public function getTitle(): string|Htmlable
{
return __('PHP Manager');
}
protected function getAgent(): AgentClient
{
return new AgentClient;
}
public function mount(): void
{
$this->loadPhpVersions();
}
public function loadPhpVersions(): void
{
$result = $this->getAgent()->send('php.list_versions', []);
if ($result['success'] ?? false) {
$this->installedVersions = $result['versions'] ?? [];
$this->defaultVersion = $result['default'] ?? null;
} else {
$this->installedVersions = [];
$this->defaultVersion = null;
}
$allVersions = ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4'];
$installed = array_column($this->installedVersions, 'version');
$this->availableVersions = array_diff($allVersions, $installed);
}
protected function getForms(): array
{
return ['statsForm'];
}
public function statsForm(Schema $schema): Schema
{
return $schema->schema([
Section::make(__('Warning: Modifying PHP versions can cause server downtime'))
->description(__('Uninstalling PHP versions may break websites that depend on them. Ensure you understand the impact before making changes.'))
->icon('heroicon-o-exclamation-triangle')
->iconColor('warning')
->collapsed(false)
->compact(),
Grid::make(['default' => 1, 'sm' => 2])
->schema([
Section::make('PHP '.($this->defaultVersion ?? __('N/A')))
->description(__('Default CLI Version'))
->icon('heroicon-o-command-line')
->iconColor('primary'),
Section::make((string) count($this->installedVersions))
->description(__('Installed Versions'))
->icon('heroicon-o-squares-2x2')
->iconColor('success'),
]),
]);
}
public function getAllVersionsData(): array
{
$allVersions = ['8.4', '8.3', '8.2', '8.1', '8.0', '7.4'];
$installedMap = [];
foreach ($this->installedVersions as $php) {
$installedMap[$php['version']] = $php;
}
$result = [];
foreach ($allVersions as $version) {
$installed = isset($installedMap[$version]);
$result[] = [
'version' => $version,
'installed' => $installed,
'fpm_status' => $installed ? ($installedMap[$version]['fpm_status'] ?? 'inactive') : null,
'is_default' => $version === $this->defaultVersion,
];
}
return $result;
}
public function table(Table $table): Table
{
return $table
->records(fn () => $this->getAllVersionsData())
->columns([
TextColumn::make('version')
->label(__('PHP Version'))
->formatStateUsing(fn (string $state): string => 'PHP '.$state)
->icon('heroicon-o-code-bracket')
->weight('bold')
->sortable(),
TextColumn::make('installed')
->label(__('Status'))
->badge()
->formatStateUsing(fn (bool $state): string => $state ? __('Installed') : __('Not Installed'))
->color(fn (bool $state): string => $state ? 'success' : 'gray'),
IconColumn::make('is_default')
->label(__('Default'))
->boolean()
->trueIcon('heroicon-o-check-badge')
->falseIcon('heroicon-o-minus')
->trueColor('info')
->falseColor('gray'),
TextColumn::make('fpm_status')
->label(__('FPM'))
->badge()
->formatStateUsing(fn (?string $state): string => $state === 'active' ? __('Running') : ($state ? __('Stopped') : '-'))
->color(fn (?string $state): string => $state === 'active' ? 'success' : ($state ? 'danger' : 'gray')),
])
->recordActions([
Action::make('install')
->label(__('Install'))
->icon('heroicon-o-arrow-down-tray')
->color('primary')
->size('sm')
->visible(fn (array $record): bool => ! $record['installed'])
->action(fn (array $record) => $this->installPhp($record['version'])),
Action::make('reload')
->label(__('Reload'))
->icon('heroicon-o-arrow-path')
->color('warning')
->size('sm')
->visible(fn (array $record): bool => $record['installed'])
->requiresConfirmation()
->modalHeading(__('Reload PHP-FPM'))
->modalDescription(fn (array $record): string => __('Are you sure you want to reload PHP :version FPM?', ['version' => $record['version']]))
->action(fn (array $record) => $this->reloadFpm($record['version'])),
Action::make('uninstall')
->label(__('Uninstall'))
->icon('heroicon-o-trash')
->color('danger')
->size('sm')
->visible(fn (array $record): bool => $record['installed'] && ! $record['is_default'])
->action(fn (array $record) => $this->uninstallPhp($record['version'])),
])
->heading(__('PHP Versions'))
->description(__('Install, manage and configure PHP versions'))
->striped()
->poll('30s');
}
public function installPhp(string $version): void
{
$result = $this->getAgent()->send('php.install', ['version' => $version]);
if ($result['success'] ?? false) {
$this->loadPhpVersions();
$this->resetTable();
Notification::make()
->title(__('PHP :version installed successfully!', ['version' => $version]))
->success()
->send();
} else {
Notification::make()
->title(__('Failed to install PHP :version', ['version' => $version]))
->body($result['error'] ?? __('Unknown error'))
->danger()
->send();
}
}
public function uninstallPhp(string $version): void
{
if ($version === $this->defaultVersion) {
Notification::make()
->title(__('Cannot uninstall default PHP version'))
->body(__('Please set a different PHP version as default first'))
->danger()
->send();
return;
}
$result = $this->getAgent()->send('php.uninstall', ['version' => $version]);
if ($result['success'] ?? false) {
$this->loadPhpVersions();
$this->resetTable();
Notification::make()
->title(__('PHP :version uninstalled successfully!', ['version' => $version]))
->success()
->send();
} else {
Notification::make()
->title(__('Failed to uninstall PHP :version', ['version' => $version]))
->body($result['error'] ?? __('Unknown error'))
->danger()
->send();
}
}
public function setDefaultPhp(string $version): void
{
$result = $this->getAgent()->send('php.set_default', ['version' => $version]);
if ($result['success'] ?? false) {
$this->defaultVersion = $version;
$this->loadPhpVersions();
$this->resetTable();
Notification::make()
->title(__('PHP :version set as default', ['version' => $version]))
->success()
->send();
} else {
Notification::make()
->title(__('Failed to set default PHP version'))
->body($result['error'] ?? __('Unknown error'))
->danger()
->send();
}
}
public function reloadFpm(string $version): void
{
$result = $this->getAgent()->send('php.reload_fpm', ['version' => $version]);
if ($result['success'] ?? false) {
$this->loadPhpVersions();
$this->resetTable();
Notification::make()
->title(__('PHP :version FPM reloaded', ['version' => $version]))
->success()
->send();
} else {
Notification::make()
->title(__('Failed to reload PHP-FPM'))
->body($result['error'] ?? __('Unknown error'))
->danger()
->send();
}
}
protected function getHeaderActions(): array
{
return [
];
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,363 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Pages;
use App\Filament\Admin\Widgets\ServerChartsWidget;
use App\Filament\Admin\Widgets\ServerInfoWidget;
use App\Models\ServerProcess;
use App\Services\Agent\AgentClient;
use BackedEnum;
use Exception;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Actions\BulkAction;
use Filament\Forms\Components\Radio;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Support\Enums\FontFamily;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Database\Eloquent\Collection;
class ServerStatus extends Page implements HasTable
{
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-chart-bar';
protected static ?int $navigationSort = 4;
protected string $view = 'filament.admin.pages.server-status';
public array $overview = [];
public array $disk = [];
public array $network = [];
public int $processTotal = 0;
public int $processRunning = 0;
public string $refreshInterval = '10s';
public ?string $lastUpdated = null;
public int $processLimit = 50;
protected ?AgentClient $agent = null;
public static function getNavigationLabel(): string
{
return __('Server Status');
}
public function getTitle(): string|Htmlable
{
return __('Server Status');
}
protected function getHeaderWidgets(): array
{
return [
ServerChartsWidget::class,
ServerInfoWidget::class,
];
}
protected function getHeaderActions(): array
{
return [];
}
public function setProcessLimit(int $limit): void
{
$this->processLimit = $limit;
$this->loadMetrics();
}
public function setRefreshInterval(string $interval): void
{
$this->refreshInterval = $interval;
$this->dispatch('refresh-interval-changed', interval: $interval);
}
public function getAgent(): AgentClient
{
if ($this->agent === null) {
$this->agent = new AgentClient;
}
return $this->agent;
}
public function mount(): void
{
$this->loadMetrics();
}
public function loadMetrics(): void
{
try {
$this->overview = $this->getAgent()->metricsOverview();
$this->disk = $this->getAgent()->metricsDisk()['data'] ?? [];
$this->network = $this->getAgent()->metricsNetwork()['data'] ?? [];
// Get processes with configurable limit (0 = all)
$limit = $this->processLimit === 0 ? 500 : $this->processLimit;
$processData = $this->getAgent()->metricsProcesses($limit)['data'] ?? [];
$this->processTotal = $processData['total'] ?? 0;
$this->processRunning = $processData['running'] ?? 0;
if (! empty($processData['top'])) {
ServerProcess::captureProcesses($processData['top'], $this->processTotal);
$this->flushCachedTableRecords();
}
$this->lastUpdated = now()->format('H:i:s');
} catch (Exception $e) {
$this->overview = ['error' => $e->getMessage()];
}
}
public function table(Table $table): Table
{
return $table
->query(ServerProcess::latestBatch()->orderBy('cpu', 'desc'))
->columns([
TextColumn::make('pid')
->label(__('PID'))
->fontFamily(FontFamily::Mono)
->sortable()
->searchable()
->copyable()
->copyMessage(__('PID copied'))
->toggleable(),
TextColumn::make('user')
->label(__('User'))
->badge()
->color(fn ($state) => match ($state) {
'root' => 'danger',
'www-data', 'nginx', 'apache' => 'info',
'mysql', 'postgres' => 'warning',
default => 'gray',
})
->sortable()
->searchable()
->toggleable(),
TextColumn::make('command')
->label(__('Command'))
->limit(40)
->tooltip(fn (ServerProcess $record) => $record->command)
->searchable()
->wrap()
->toggleable(),
TextColumn::make('cpu')
->label(__('CPU %'))
->suffix('%')
->badge()
->color(fn ($state) => $state > 50 ? 'danger' : ($state > 20 ? 'warning' : 'gray'))
->sortable()
->toggleable(),
TextColumn::make('memory')
->label(__('Mem %'))
->suffix('%')
->badge()
->color(fn ($state) => $state > 50 ? 'danger' : ($state > 20 ? 'warning' : 'gray'))
->sortable()
->toggleable(),
])
->filters([
SelectFilter::make('user')
->label(__('User'))
->options(fn () => ServerProcess::latestBatch()
->distinct()
->pluck('user', 'user')
->toArray()
)
->searchable()
->preload(),
])
->recordActions([
Action::make('kill')
->label(__('Kill'))
->icon('heroicon-o-x-circle')
->color('danger')
->requiresConfirmation()
->modalHeading(__('Kill Process'))
->modalDescription(fn (ServerProcess $record) => __('Are you sure you want to kill process :pid (:command)?', [
'pid' => $record->pid,
'command' => substr($record->command, 0, 50),
]))
->modalIcon('heroicon-o-exclamation-triangle')
->modalIconColor('danger')
->form([
Radio::make('signal')
->label(__('Signal'))
->options([
'15' => __('SIGTERM (15) - Graceful termination'),
'9' => __('SIGKILL (9) - Force kill'),
'1' => __('SIGHUP (1) - Hangup/Reload'),
])
->default('15')
->required()
->helperText(__('SIGTERM allows the process to clean up. SIGKILL forces immediate termination.')),
])
->action(fn (ServerProcess $record, array $data) => $this->killProcess($record, (int) $data['signal'])),
])
->selectable()
->bulkActions([
BulkAction::make('killSelected')
->label(__('Kill Selected'))
->icon('heroicon-o-x-circle')
->color('danger')
->requiresConfirmation()
->modalHeading(__('Kill Selected Processes'))
->modalDescription(__('Are you sure you want to kill the selected processes? This action cannot be undone.'))
->modalIcon('heroicon-o-exclamation-triangle')
->modalIconColor('danger')
->form([
Radio::make('signal')
->label(__('Signal'))
->options([
'15' => __('SIGTERM (15) - Graceful termination'),
'9' => __('SIGKILL (9) - Force kill'),
])
->default('15')
->required(),
])
->action(fn (Collection $records, array $data) => $this->killProcesses($records, (int) $data['signal']))
->deselectRecordsAfterCompletion(),
])
->headerActions([
ActionGroup::make([
Action::make('limit25')
->label(__('Show 25 processes'))
->icon(fn () => $this->processLimit === 25 ? 'heroicon-o-check' : null)
->action(fn () => $this->setProcessLimit(25)),
Action::make('limit50')
->label(__('Show 50 processes'))
->icon(fn () => $this->processLimit === 50 ? 'heroicon-o-check' : null)
->action(fn () => $this->setProcessLimit(50)),
Action::make('limit100')
->label(__('Show 100 processes'))
->icon(fn () => $this->processLimit === 100 ? 'heroicon-o-check' : null)
->action(fn () => $this->setProcessLimit(100)),
Action::make('limitAll')
->label(__('Show all processes'))
->icon(fn () => $this->processLimit === 0 ? 'heroicon-o-check' : null)
->action(fn () => $this->setProcessLimit(0)),
])
->label(fn () => __('Process Limit: :limit', ['limit' => $this->processLimit === 0 ? __('All') : $this->processLimit]))
->icon('heroicon-o-queue-list')
->color('gray')
->button(),
Action::make('refreshProcesses')
->label(fn () => $this->lastUpdated ? __('Refresh (:time)', ['time' => $this->lastUpdated]) : __('Refresh'))
->icon('heroicon-o-arrow-path')
->color('gray')
->action(fn () => $this->loadMetrics()),
])
->heading(__('Process List'))
->description(__(':total total processes, :running running', ['total' => $this->processTotal, 'running' => $this->processRunning]))
->paginated([10, 25, 50, 100])
->defaultPaginationPageOption(25)
->poll($this->refreshInterval === 'off' ? null : $this->refreshInterval)
->striped()
->defaultSort('cpu', 'desc')
->persistFiltersInSession()
->persistSearchInSession();
}
public function killProcess(ServerProcess $process, int $signal = 15): void
{
try {
$result = $this->getAgent()->send('system.kill_process', [
'pid' => $process->pid,
'signal' => $signal,
]);
if ($result['success'] ?? false) {
Notification::make()
->title(__('Process killed'))
->body(__('Process :pid has been terminated with signal :signal.', [
'pid' => $process->pid,
'signal' => $signal,
]))
->success()
->send();
// Refresh the process list
$this->loadMetrics();
} else {
throw new Exception($result['error'] ?? __('Unknown error'));
}
} catch (Exception $e) {
Notification::make()
->title(__('Failed to kill process'))
->body($e->getMessage())
->danger()
->send();
}
}
public function killProcesses(Collection $records, int $signal = 15): void
{
$killed = 0;
$failed = 0;
foreach ($records as $process) {
try {
$result = $this->getAgent()->send('system.kill_process', [
'pid' => $process->pid,
'signal' => $signal,
]);
if ($result['success'] ?? false) {
$killed++;
} else {
$failed++;
}
} catch (Exception $e) {
$failed++;
}
}
if ($killed > 0) {
Notification::make()
->title(__('Processes killed'))
->body(__(':count process(es) terminated successfully.', ['count' => $killed]))
->success()
->send();
}
if ($failed > 0) {
Notification::make()
->title(__('Some processes failed'))
->body(__(':count process(es) could not be killed.', ['count' => $failed]))
->warning()
->send();
}
// Refresh the process list
$this->loadMetrics();
}
public function refresh(): void
{
$this->loadMetrics();
}
public function getListeners(): array
{
return [
'refresh' => 'loadMetrics',
];
}
}

View File

@@ -0,0 +1,318 @@
<?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\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\View;
class ServerUpdates extends Page implements HasActions, HasTable
{
use InteractsWithActions;
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedArrowPathRoundedSquare;
protected static ?int $navigationSort = 16;
protected static ?string $slug = 'server-updates';
protected string $view = 'filament.admin.pages.server-updates';
public array $packages = [];
public ?string $currentVersion = null;
public ?int $behindCount = null;
public array $recentChanges = [];
public ?string $lastCheckedAt = null;
public ?string $jabaliOutput = null;
public ?string $jabaliOutputTitle = null;
public ?string $jabaliOutputAt = null;
public bool $isChecking = false;
public bool $isUpgrading = false;
public array $refreshOutput = [];
public ?string $refreshOutputAt = null;
public ?string $refreshOutputTitle = null;
protected ?AgentClient $agent = null;
protected bool $updatesLoaded = false;
public function getTitle(): string|Htmlable
{
return __('System Updates');
}
public static function getNavigationLabel(): string
{
return __('System Updates');
}
public function mount(): void
{
$this->loadUpdates(false);
$this->loadVersionInfo();
}
public function getUpdateStatusLabelProperty(): string
{
if ($this->behindCount === null) {
return __('Not checked');
}
if ($this->behindCount === 0) {
return __('Up to date');
}
return __(':count commit(s) behind', ['count' => $this->behindCount]);
}
protected function getAgent(): AgentClient
{
return $this->agent ??= new AgentClient;
}
public function loadUpdates(bool $refreshTable = true, bool $refreshApt = false): void
{
try {
$result = $this->getAgent()->updatesList($refreshApt);
$this->packages = $result['packages'] ?? [];
$this->updatesLoaded = true;
if ($refreshApt) {
$refreshOutput = $result['refresh_output'] ?? [];
$refreshLines = is_array($refreshOutput) ? $refreshOutput : [$refreshOutput];
$this->refreshOutput = ! empty(array_filter($refreshLines, static fn ($line) => $line !== null && $line !== ''))
? $refreshLines
: [__('No output captured.')];
$this->refreshOutputAt = now()->format('Y-m-d H:i:s');
$this->refreshOutputTitle = __('Update Refresh Output');
}
$warnings = $result['warnings'] ?? [];
if (! empty($warnings)) {
Notification::make()
->title(__('Update check completed with warnings'))
->body(implode("\n", array_filter($warnings)))
->warning()
->send();
}
} catch (\Exception $e) {
$this->packages = [];
$this->updatesLoaded = true;
if ($refreshApt) {
$this->refreshOutput = [$e->getMessage()];
$this->refreshOutputAt = now()->format('Y-m-d H:i:s');
$this->refreshOutputTitle = __('Update Refresh Output');
}
Notification::make()
->title(__('Failed to load updates'))
->body($e->getMessage())
->danger()
->send();
}
if ($refreshTable) {
$this->resetTable();
}
}
public function loadVersionInfo(): void
{
$this->currentVersion = $this->readVersion();
}
public function checkForUpdates(): void
{
$this->isChecking = true;
try {
$exitCode = Artisan::call('jabali:upgrade', ['--check' => true]);
$output = trim(Artisan::output());
if ($exitCode !== 0) {
throw new \RuntimeException($output !== '' ? $output : __('Update check failed.'));
}
$this->jabaliOutput = $output !== '' ? $output : __('No output captured.');
$this->jabaliOutputTitle = __('Update Check Output');
$this->jabaliOutputAt = now()->format('Y-m-d H:i:s');
$this->lastCheckedAt = $this->jabaliOutputAt;
$this->parseUpdateOutput($output);
Notification::make()
->title(__('Update check completed'))
->success()
->send();
} catch (\Throwable $e) {
$this->jabaliOutput = $e->getMessage();
$this->jabaliOutputTitle = __('Update Check Output');
$this->jabaliOutputAt = now()->format('Y-m-d H:i:s');
Notification::make()
->title(__('Update check failed'))
->body($e->getMessage())
->danger()
->send();
} finally {
$this->isChecking = false;
}
}
public function performUpgrade(): void
{
$this->isUpgrading = true;
try {
$exitCode = Artisan::call('jabali:upgrade', ['--force' => true]);
$output = trim(Artisan::output());
if ($exitCode !== 0) {
throw new \RuntimeException($output !== '' ? $output : __('Upgrade failed.'));
}
$this->jabaliOutput = $output !== '' ? $output : __('No output captured.');
$this->jabaliOutputTitle = __('Upgrade Output');
$this->jabaliOutputAt = now()->format('Y-m-d H:i:s');
$this->loadVersionInfo();
$this->behindCount = 0;
$this->recentChanges = [];
Notification::make()
->title(__('Upgrade completed'))
->success()
->send();
} catch (\Throwable $e) {
$this->jabaliOutput = $e->getMessage();
$this->jabaliOutputTitle = __('Upgrade Output');
$this->jabaliOutputAt = now()->format('Y-m-d H:i:s');
Notification::make()
->title(__('Upgrade failed'))
->body($e->getMessage())
->danger()
->send();
} finally {
$this->isUpgrading = false;
}
}
public function runUpdates(): void
{
try {
$result = $this->getAgent()->updatesRun();
$output = $result['output'] ?? [];
$outputLines = is_array($output) ? $output : [$output];
$this->refreshOutput = ! empty(array_filter($outputLines, static fn ($line) => $line !== null && $line !== ''))
? $outputLines
: [__('No output captured.')];
$this->refreshOutputAt = now()->format('Y-m-d H:i:s');
$this->refreshOutputTitle = __('System Update Output');
Notification::make()
->title(__('Updates completed'))
->success()
->send();
} catch (\Exception $e) {
$this->refreshOutput = [$e->getMessage()];
$this->refreshOutputAt = now()->format('Y-m-d H:i:s');
$this->refreshOutputTitle = __('System Update Output');
Notification::make()
->title(__('Update failed'))
->body($e->getMessage())
->danger()
->send();
}
$this->loadUpdates();
$this->dispatch('$refresh');
}
protected function parseUpdateOutput(string $output): void
{
$this->behindCount = null;
$this->recentChanges = [];
if (preg_match('/Updates available:\s+(\d+)/', $output, $matches)) {
$this->behindCount = (int) $matches[1];
} elseif (str_contains(strtolower($output), 'up to date')) {
$this->behindCount = 0;
}
if (preg_match('/Recent changes:\s*(.+)$/s', $output, $matches)) {
$lines = preg_split('/\r?\n/', trim($matches[1]));
$this->recentChanges = array_values(array_filter($lines, static fn (string $line): bool => trim($line) !== ''));
}
}
protected function readVersion(): string
{
$versionFile = base_path('VERSION');
if (! File::exists($versionFile)) {
return 'unknown';
}
$content = File::get($versionFile);
if (preg_match('/VERSION=(.+)/', $content, $matches)) {
return trim($matches[1]);
}
return 'unknown';
}
public function table(Table $table): Table
{
return $table
->records(function () {
if (! $this->updatesLoaded) {
$this->loadUpdates(false);
}
return collect($this->packages)
->mapWithKeys(function (array $package, int $index): array {
$keyParts = [
$package['name'] ?? (string) $index,
$package['current_version'] ?? '',
$package['new_version'] ?? '',
];
$key = implode('|', array_filter($keyParts, fn (string $part): bool => $part !== ''));
return [$key !== '' ? $key : (string) $index => $package];
})
->all();
})
->columns([
TextColumn::make('name')
->label(__('Package')),
TextColumn::make('current_version')
->label(__('Current Version')),
TextColumn::make('new_version')
->label(__('New Version')),
])
->emptyStateHeading(__('No updates available'))
->emptyStateDescription(__('Your system packages are up to date.'));
}
}

View File

@@ -0,0 +1,328 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Pages;
use App\Models\AuditLog;
use App\Services\Agent\AgentClient;
use BackedEnum;
use Exception;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
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\Database\Eloquent\Model;
class Services extends Page implements HasActions, HasForms, HasTable
{
use InteractsWithActions;
use InteractsWithForms;
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-cog-6-tooth';
protected static ?int $navigationSort = 10;
public static function getNavigationLabel(): string
{
return __('Services');
}
protected string $view = 'filament.admin.pages.services';
public array $services = [];
public ?string $selectedService = null;
protected ?AgentClient $agent = null;
protected array $baseServices = [
'nginx' => ['name' => 'Nginx', 'description' => 'Web Server', 'icon' => 'globe'],
'mariadb' => ['name' => 'MariaDB', 'description' => 'Database Server', 'icon' => 'database'],
'redis-server' => ['name' => 'Redis', 'description' => 'Cache Server', 'icon' => 'bolt'],
'postfix' => ['name' => 'Postfix', 'description' => 'Mail Transfer Agent', 'icon' => 'envelope'],
'dovecot' => ['name' => 'Dovecot', 'description' => 'IMAP/POP3 Server', 'icon' => 'inbox'],
'rspamd' => ['name' => 'Rspamd', 'description' => 'Spam Filter', 'icon' => 'shield'],
'clamav-daemon' => ['name' => 'ClamAV', 'description' => 'Antivirus Scanner', 'icon' => 'bug'],
'named' => ['name' => 'BIND9', 'description' => 'DNS Server', 'icon' => 'server'],
'opendkim' => ['name' => 'OpenDKIM', 'description' => 'DKIM Signing', 'icon' => 'key'],
'fail2ban' => ['name' => 'Fail2Ban', 'description' => 'Intrusion Prevention', 'icon' => 'lock'],
'ssh' => ['name' => 'SSH', 'description' => 'Secure Shell', 'icon' => 'terminal'],
'cron' => ['name' => 'Cron', 'description' => 'Task Scheduler', 'icon' => 'clock'],
];
protected ?array $managedServices = null;
protected function getManagedServices(): array
{
if ($this->managedServices !== null) {
return $this->managedServices;
}
$this->managedServices = [];
foreach ($this->baseServices as $key => $config) {
$this->managedServices[$key] = $config;
if ($key === 'nginx') {
foreach ($this->detectPhpFpmVersions() as $service => $phpConfig) {
$this->managedServices[$service] = $phpConfig;
}
}
}
return $this->managedServices;
}
protected function detectPhpFpmVersions(): array
{
$phpServices = [];
$output = [];
exec('ls /lib/systemd/system/php*-fpm.service 2>/dev/null', $output);
foreach ($output as $servicePath) {
if (preg_match('/php([\d.]+)-fpm\.service$/', $servicePath, $matches)) {
$version = $matches[1];
$serviceName = "php{$version}-fpm";
$phpServices[$serviceName] = [
'name' => "PHP {$version} FPM",
'description' => 'PHP FastCGI Process Manager',
'icon' => 'code',
];
}
}
uksort($phpServices, function ($a, $b) {
preg_match('/php([\d.]+)-fpm/', $a, $matchA);
preg_match('/php([\d.]+)-fpm/', $b, $matchB);
return version_compare($matchB[1] ?? '0', $matchA[1] ?? '0');
});
return $phpServices;
}
public function getTitle(): string|Htmlable
{
return __('Service Manager');
}
public function getAgent(): AgentClient
{
return $this->agent ??= new AgentClient;
}
public function mount(): void
{
$this->loadServices();
}
public function loadServices(): void
{
$managedServices = $this->getManagedServices();
try {
$result = $this->getAgent()->send('service.list', [
'services' => array_keys($managedServices),
]);
if ($result['success'] ?? false) {
$this->services = [];
foreach ($result['services'] ?? [] as $name => $status) {
$config = $managedServices[$name] ?? [
'name' => ucfirst($name),
'description' => '',
'icon' => 'cog',
];
$this->services[$name] = array_merge($config, [
'service' => $name,
'is_active' => $status['is_active'] ?? false,
'is_enabled' => $status['is_enabled'] ?? false,
'status' => $status['status'] ?? 'unknown',
]);
}
}
} catch (Exception $e) {
Notification::make()->title(__('Error loading services'))->body($e->getMessage())->danger()->send();
}
}
public function table(Table $table): Table
{
return $table
->records(fn () => array_values($this->services))
->columns([
TextColumn::make('name')
->label(__('Service'))
->icon(fn (array $record): string => match ($record['icon'] ?? 'cog') {
'globe' => 'heroicon-o-globe-alt',
'code' => 'heroicon-o-code-bracket',
'database' => 'heroicon-o-circle-stack',
'bolt' => 'heroicon-o-bolt',
'envelope' => 'heroicon-o-envelope',
'inbox' => 'heroicon-o-inbox',
'shield' => 'heroicon-o-shield-check',
'server' => 'heroicon-o-server',
'key' => 'heroicon-o-key',
'lock' => 'heroicon-o-lock-closed',
'terminal' => 'heroicon-o-command-line',
'clock' => 'heroicon-o-clock',
'bug' => 'heroicon-o-bug-ant',
default => 'heroicon-o-cog-6-tooth',
})
->iconColor(fn (array $record): string => $record['is_active'] ? 'success' : 'danger')
->description(fn (array $record): string => $record['description'] ?? '')
->weight('medium'),
TextColumn::make('is_active')
->label(__('Status'))
->badge()
->formatStateUsing(fn (array $record): string => $record['is_active'] ? __('Running') : __('Stopped'))
->color(fn (array $record): string => $record['is_active'] ? 'success' : 'danger'),
TextColumn::make('is_enabled')
->label(__('Boot'))
->badge()
->formatStateUsing(fn (array $record): string => $record['is_enabled'] ? __('Enabled') : __('Disabled'))
->color(fn (array $record): string => $record['is_enabled'] ? 'success' : 'warning'),
])
->recordActions([
Action::make('start')
->label(__('Start'))
->icon('heroicon-o-play')
->color('success')
->size('sm')
->visible(fn (array $record): bool => ! $record['is_active'])
->action(fn (array $record) => $this->executeServiceAction($record['service'], 'start')),
Action::make('stop')
->label(__('Stop'))
->icon('heroicon-o-stop')
->color('danger')
->size('sm')
->visible(fn (array $record): bool => $record['is_active'])
->requiresConfirmation()
->modalHeading(__('Stop Service'))
->modalDescription(fn (array $record): string => __('Are you sure you want to stop :service? This may affect running websites and services.', ['service' => $record['name']]))
->modalSubmitActionLabel(__('Stop Service'))
->action(fn (array $record) => $this->executeServiceAction($record['service'], 'stop')),
Action::make('restart')
->label(fn (array $record): string => $this->shouldReloadService($record['service']) ? __('Reload') : __('Restart'))
->icon('heroicon-o-arrow-path')
->color('info')
->size('sm')
->visible(fn (array $record): bool => $record['is_active'])
->action(fn (array $record) => $this->executeServiceAction(
$record['service'],
$this->shouldReloadService($record['service']) ? 'reload' : 'restart'
)),
Action::make('enable')
->label(__('Enable'))
->icon('heroicon-o-check')
->color('gray')
->size('sm')
->visible(fn (array $record): bool => ! $record['is_enabled'])
->action(fn (array $record) => $this->executeServiceAction($record['service'], 'enable')),
Action::make('disable')
->label(__('Disable'))
->icon('heroicon-o-x-mark')
->color('warning')
->size('sm')
->visible(fn (array $record): bool => $record['is_enabled'])
->requiresConfirmation()
->modalHeading(__('Disable Service'))
->modalDescription(fn (array $record): string => __("Are you sure you want to disable :service? It won't start automatically on boot.", ['service' => $record['name']]))
->modalSubmitActionLabel(__('Disable Service'))
->action(fn (array $record) => $this->executeServiceAction($record['service'], 'disable')),
])
->headerActions([
Action::make('refresh')
->label(__('Refresh'))
->icon('heroicon-o-arrow-path')
->color('gray')
->action(function () {
$this->loadServices();
$this->resetTable();
Notification::make()->title(__('Services refreshed'))->success()->duration(1500)->send();
}),
])
->emptyStateHeading(__('No services found'))
->emptyStateDescription(__('Unable to load system services'))
->emptyStateIcon('heroicon-o-cog-6-tooth')
->striped();
}
public function getTableRecordKey(Model|array $record): string
{
return is_array($record) ? $record['service'] : $record->getKey();
}
protected function executeServiceAction(string $service, string $action): void
{
try {
$result = $this->getAgent()->send("service.{$action}", [
'service' => $service,
]);
if ($result['success'] ?? false) {
$notificationTitle = match ($action) {
'start' => __(':service started', ['service' => ucfirst($service)]),
'stop' => __(':service stopped', ['service' => ucfirst($service)]),
'restart' => __(':service restarted', ['service' => ucfirst($service)]),
'reload' => __(':service reloaded', ['service' => ucfirst($service)]),
'enable' => __(':service enabled', ['service' => ucfirst($service)]),
'disable' => __(':service disabled', ['service' => ucfirst($service)]),
default => ucfirst($service).' '.$action
};
$actionPast = match ($action) {
'start' => 'started',
'stop' => 'stopped',
'restart' => 'restarted',
'reload' => 'reloaded',
'enable' => 'enabled',
'disable' => 'disabled',
default => $action
};
Notification::make()
->title($notificationTitle)
->success()
->send();
AuditLog::logServiceAction($actionPast, $service);
$this->loadServices();
$this->resetTable();
} else {
throw new Exception($result['error'] ?? $result['message'] ?? __('Unknown error'));
}
} catch (Exception $e) {
Notification::make()
->title(__('Action failed'))
->body($e->getMessage())
->danger()
->send();
}
}
protected function getHeaderActions(): array
{
return [
];
}
protected function shouldReloadService(string $service): bool
{
if ($service === 'nginx') {
return true;
}
return preg_match('/^php(\d+\.\d+)?-fpm$/', $service) === 1;
}
}

View File

@@ -0,0 +1,589 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Pages;
use App\Filament\Admin\Widgets\SslStatsOverview;
use App\Models\Domain;
use App\Models\SslCertificate;
use App\Models\User;
use App\Services\Agent\AgentClient;
use BackedEnum;
use Exception;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Artisan;
class SslManager extends Page implements HasTable
{
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
protected static ?int $navigationSort = 8;
public static function getNavigationLabel(): string
{
return __('SSL Manager');
}
public function getTitle(): string
{
return __('SSL Manager');
}
protected string $view = 'filament.admin.pages.ssl-manager';
public bool $isRunning = false;
public string $autoSslLog = '';
public ?string $lastUpdated = null;
protected ?AgentClient $agent = null;
protected function getHeaderWidgets(): array
{
return [
SslStatsOverview::class,
];
}
public function getHeaderWidgetsColumns(): int|array
{
return 6;
}
public function getAgent(): AgentClient
{
if ($this->agent === null) {
$this->agent = new AgentClient;
}
return $this->agent;
}
public function mount(): void
{
$this->lastUpdated = now()->format('H:i:s');
}
public function table(Table $table): Table
{
return $table
->query(Domain::with(['user', 'sslCertificate']))
->columns([
TextColumn::make('domain')
->label(__('Domain'))
->searchable()
->sortable()
->description(fn (Domain $record) => $record->user?->username ?? __('Unknown')),
TextColumn::make('sslCertificate.type')
->label(__('Type'))
->badge()
->color('gray')
->formatStateUsing(fn ($state) => $state ? ucfirst(str_replace('_', ' ', $state)) : __('No SSL')),
TextColumn::make('sslCertificate.status')
->label(__('Status'))
->badge()
->color(fn ($state) => match ($state) {
'active' => 'success',
'expired' => 'danger',
'expiring' => 'warning',
'failed' => 'danger',
default => 'gray',
})
->formatStateUsing(fn ($state) => $state ? ucfirst($state) : __('No Certificate')),
TextColumn::make('sslCertificate.expires_at')
->label(__('Expires'))
->date('M d, Y')
->description(fn (Domain $record) => $record->sslCertificate?->days_until_expiry !== null
? __(':days days', ['days' => $record->sslCertificate->days_until_expiry])
: null)
->color(fn (Domain $record) => match (true) {
$record->sslCertificate?->days_until_expiry <= 7 => 'danger',
$record->sslCertificate?->days_until_expiry <= 30 => 'warning',
default => 'gray',
}),
TextColumn::make('sslCertificate.last_check_at')
->label(__('Last Check'))
->since()
->sortable(),
TextColumn::make('sslCertificate.last_error')
->label(__('Error'))
->limit(30)
->tooltip(fn ($state) => $state)
->color('danger')
->placeholder('-'),
])
->filters([
SelectFilter::make('ssl_status')
->label(__('Status'))
->options([
'active' => __('Active'),
'no_ssl' => __('No SSL'),
'expiring' => __('Expiring Soon'),
'expired' => __('Expired'),
'failed' => __('Failed'),
])
->query(function (Builder $query, array $data) {
if (! $data['value']) {
return $query;
}
return match ($data['value']) {
'active' => $query->whereHas('sslCertificate', fn ($q) => $q->where('status', 'active')),
'no_ssl' => $query->whereDoesntHave('sslCertificate'),
'expiring' => $query->whereHas('sslCertificate', fn ($q) => $q->where('status', 'active')
->where('expires_at', '<=', now()->addDays(30))
->where('expires_at', '>', now())),
'expired' => $query->whereHas('sslCertificate', fn ($q) => $q->where('status', 'expired')
->orWhere('expires_at', '<', now())),
'failed' => $query->whereHas('sslCertificate', fn ($q) => $q->where('status', 'failed')),
default => $query,
};
}),
SelectFilter::make('user_id')
->label(__('User'))
->relationship('user', 'username'),
])
->recordActions([
Action::make('issue')
->label(__('Issue'))
->icon('heroicon-o-lock-closed')
->color('success')
->visible(fn (Domain $record) => ! $record->sslCertificate || $record->sslCertificate->status === 'failed')
->action(fn (Domain $record) => $this->issueSslForDomain($record->id)),
Action::make('renew')
->label(__('Renew'))
->icon('heroicon-o-arrow-path')
->color('primary')
->visible(fn (Domain $record) => $record->sslCertificate?->type === 'lets_encrypt' && $record->sslCertificate?->status === 'active')
->action(fn (Domain $record) => $this->renewSslForDomain($record->id)),
Action::make('check')
->label(__('Check'))
->icon('heroicon-o-magnifying-glass')
->color('gray')
->action(fn (Domain $record) => $this->checkSslForDomain($record->id)),
])
->heading(__('Domain Certificates'))
->poll('30s');
}
public function issueSslForDomain(int $domainId): void
{
try {
$domain = Domain::with('user')->findOrFail($domainId);
$result = $this->getAgent()->sslIssue(
$domain->domain,
$domain->user->username,
$domain->user->email,
true
);
if ($result['success'] ?? false) {
SslCertificate::updateOrCreate(
['domain_id' => $domain->id],
[
'type' => 'lets_encrypt',
'status' => 'active',
'issuer' => "Let's Encrypt",
'certificate' => $result['certificate'] ?? null,
'issued_at' => now(),
'expires_at' => isset($result['valid_to']) ? \Carbon\Carbon::parse($result['valid_to']) : now()->addMonths(3),
'last_check_at' => now(),
'last_error' => null,
'renewal_attempts' => 0,
'auto_renew' => true,
]
);
$domain->update(['ssl_enabled' => true]);
Notification::make()
->title(__('SSL Certificate Issued'))
->body(__('Certificate issued for :domain', ['domain' => $domain->domain]))
->success()
->send();
} else {
SslCertificate::updateOrCreate(
['domain_id' => $domain->id],
[
'type' => 'lets_encrypt',
'status' => 'failed',
'last_check_at' => now(),
'last_error' => $result['error'] ?? __('Unknown error'),
]
);
Notification::make()
->title(__('SSL Certificate Failed'))
->body($result['error'] ?? __('Unknown error'))
->danger()
->send();
}
} catch (Exception $e) {
Notification::make()
->title(__('Error'))
->body($e->getMessage())
->danger()
->send();
}
$this->lastUpdated = now()->format('H:i:s');
}
public function renewSslForDomain(int $domainId): void
{
try {
$domain = Domain::with('user')->findOrFail($domainId);
$result = $this->getAgent()->sslRenew($domain->domain, $domain->user->username);
if ($result['success'] ?? false) {
$ssl = $domain->sslCertificate;
if ($ssl) {
$ssl->update([
'status' => 'active',
'issued_at' => now(),
'expires_at' => isset($result['valid_to']) ? \Carbon\Carbon::parse($result['valid_to']) : now()->addMonths(3),
'last_check_at' => now(),
'last_error' => null,
'renewal_attempts' => 0,
]);
}
Notification::make()
->title(__('Certificate Renewed'))
->body(__('SSL certificate renewed for :domain', ['domain' => $domain->domain]))
->success()
->send();
} else {
Notification::make()
->title(__('Renewal Failed'))
->body($result['error'] ?? __('Unknown error'))
->danger()
->send();
}
} catch (Exception $e) {
Notification::make()
->title(__('Error'))
->body($e->getMessage())
->danger()
->send();
}
$this->lastUpdated = now()->format('H:i:s');
}
public function checkSslForDomain(int $domainId): void
{
try {
$domain = Domain::with('user')->findOrFail($domainId);
$result = $this->getAgent()->sslCheck($domain->domain, $domain->user->username);
if ($result['success'] ?? false) {
$sslData = $result['ssl'] ?? [];
if ($sslData['has_ssl'] ?? false) {
SslCertificate::updateOrCreate(
['domain_id' => $domain->id],
[
'type' => $sslData['type'] ?? 'custom',
'status' => ($sslData['is_expired'] ?? false) ? 'expired' : 'active',
'issuer' => $sslData['issuer'],
'certificate' => $sslData['certificate'] ?? null,
'issued_at' => isset($sslData['valid_from']) ? \Carbon\Carbon::parse($sslData['valid_from']) : null,
'expires_at' => isset($sslData['valid_to']) ? \Carbon\Carbon::parse($sslData['valid_to']) : null,
'last_check_at' => now(),
]
);
$domain->update(['ssl_enabled' => true]);
}
Notification::make()
->title(__('Certificate Checked'))
->body($sslData['has_ssl'] ? __('Found: :issuer', ['issuer' => $sslData['issuer']]) : __('No certificate found'))
->success()
->send();
} else {
Notification::make()
->title(__('Check Failed'))
->body($result['error'] ?? __('Unknown error'))
->danger()
->send();
}
} catch (Exception $e) {
Notification::make()
->title(__('Error'))
->body($e->getMessage())
->danger()
->send();
}
$this->lastUpdated = now()->format('H:i:s');
}
public function runAutoSsl(?string $domain = null): void
{
$this->isRunning = true;
$this->autoSslLog = '';
try {
// Ensure log directory exists with proper permissions
$logDir = storage_path('logs/ssl');
if (! is_dir($logDir)) {
@mkdir($logDir, 0775, true);
}
$params = [];
if ($domain) {
$params['--domain'] = $domain;
}
Artisan::call('jabali:ssl-check', $params);
$this->autoSslLog = Artisan::output();
Notification::make()
->title(__('SSL Check Complete'))
->body($domain
? __('SSL check completed for :domain', ['domain' => $domain])
: __('SSL certificate check completed for all domains'))
->success()
->send();
} catch (Exception $e) {
$this->autoSslLog = __('Error: :message', ['message' => $e->getMessage()]);
Notification::make()
->title(__('SSL Check Failed'))
->body($e->getMessage())
->danger()
->send();
}
$this->isRunning = false;
$this->lastUpdated = now()->format('H:i:s');
}
public function runSslCheckForUser(int $userId): void
{
$this->isRunning = true;
$this->autoSslLog = '';
try {
$user = User::findOrFail($userId);
$domains = Domain::where('user_id', $userId)->pluck('domain')->toArray();
if (empty($domains)) {
$this->autoSslLog = __('No domains found for user :user', ['user' => $user->username]);
Notification::make()
->title(__('No Domains'))
->body(__('User :user has no domains', ['user' => $user->username]))
->warning()
->send();
$this->isRunning = false;
return;
}
$this->autoSslLog = __('Checking SSL for :count domains of user :user', ['count' => count($domains), 'user' => $user->username])."\n\n";
foreach ($domains as $domain) {
Artisan::call('jabali:ssl-check', ['--domain' => $domain]);
$this->autoSslLog .= Artisan::output()."\n";
}
Notification::make()
->title(__('SSL Check Complete'))
->body(__('SSL check completed for :count domains of user :user', ['count' => count($domains), 'user' => $user->username]))
->success()
->send();
} catch (Exception $e) {
$this->autoSslLog = __('Error: :message', ['message' => $e->getMessage()]);
Notification::make()
->title(__('SSL Check Failed'))
->body($e->getMessage())
->danger()
->send();
}
$this->isRunning = false;
$this->lastUpdated = now()->format('H:i:s');
}
public function issueAllPending(): void
{
$domainsWithoutSsl = Domain::whereDoesntHave('sslCertificate')
->orWhereHas('sslCertificate', function ($q) {
$q->where('status', 'failed');
})
->with('user')
->get();
$issued = 0;
$failed = 0;
foreach ($domainsWithoutSsl as $domain) {
try {
$result = $this->getAgent()->sslIssue(
$domain->domain,
$domain->user->username,
$domain->user->email,
true
);
if ($result['success'] ?? false) {
SslCertificate::updateOrCreate(
['domain_id' => $domain->id],
[
'type' => 'lets_encrypt',
'status' => 'active',
'issuer' => "Let's Encrypt",
'certificate' => $result['certificate'] ?? null,
'issued_at' => now(),
'expires_at' => isset($result['valid_to']) ? \Carbon\Carbon::parse($result['valid_to']) : now()->addMonths(3),
'last_check_at' => now(),
'last_error' => null,
'renewal_attempts' => 0,
'auto_renew' => true,
]
);
$domain->update(['ssl_enabled' => true]);
$issued++;
} else {
SslCertificate::updateOrCreate(
['domain_id' => $domain->id],
[
'type' => 'lets_encrypt',
'status' => 'failed',
'last_check_at' => now(),
'last_error' => $result['error'] ?? __('Unknown error'),
]
);
$failed++;
}
} catch (Exception $e) {
$failed++;
}
}
Notification::make()
->title(__('Bulk SSL Issuance Complete'))
->body(__('Issued: :issued, Failed: :failed', ['issued' => $issued, 'failed' => $failed]))
->success()
->send();
$this->lastUpdated = now()->format('H:i:s');
}
public function getLetsEncryptLog(): string
{
$logFiles = [
'/var/log/letsencrypt/letsencrypt.log',
'/var/log/certbot/letsencrypt.log',
'/var/log/certbot.log',
];
$logContent = '';
$foundFile = null;
foreach ($logFiles as $logFile) {
if (file_exists($logFile)) {
$foundFile = $logFile;
$lines = file($logFile);
$lastLines = array_slice($lines, -500);
$logContent .= "=== {$logFile} ===\n".implode('', $lastLines);
break;
}
}
if (! $foundFile) {
$certbotLogs = glob('/var/log/letsencrypt/*.log');
if (! empty($certbotLogs)) {
$foundFile = end($certbotLogs);
$lines = file($foundFile);
$lastLines = array_slice($lines, -500);
$logContent = "=== {$foundFile} ===\n".implode('', $lastLines);
}
}
if (! $foundFile) {
return __("No Let's Encrypt/Certbot log files found.")."\n\n".__('Searched locations:')."\n".implode("\n", $logFiles)."\n/var/log/letsencrypt/*.log";
}
return $logContent;
}
protected function getHeaderActions(): array
{
return [
Action::make('runAutoSsl')
->label(__('Run SSL Check'))
->icon('heroicon-o-play')
->color('success')
->modalHeading(__('Run SSL Check'))
->modalDescription(__('Check SSL certificates and automatically issue/renew them.'))
->modalWidth('md')
->form([
Select::make('scope')
->label(__('Scope'))
->options([
'all' => __('All Domains'),
'user' => __('Specific User'),
'domain' => __('Specific Domain'),
])
->default('all')
->live()
->required(),
Select::make('user_id')
->label(__('User'))
->options(fn () => User::pluck('username', 'id')->toArray())
->searchable()
->visible(fn ($get) => $get('scope') === 'user')
->required(fn ($get) => $get('scope') === 'user'),
Select::make('domain')
->label(__('Domain'))
->options(fn () => Domain::pluck('domain', 'domain')->toArray())
->searchable()
->visible(fn ($get) => $get('scope') === 'domain')
->required(fn ($get) => $get('scope') === 'domain'),
])
->action(function (array $data): void {
match ($data['scope']) {
'user' => $this->runSslCheckForUser((int) $data['user_id']),
'domain' => $this->runAutoSsl($data['domain']),
default => $this->runAutoSsl(),
};
}),
Action::make('issueAllPending')
->label(__('Issue All Pending'))
->icon('heroicon-o-shield-check')
->color('primary')
->requiresConfirmation()
->modalHeading(__('Issue SSL for All Pending Domains'))
->modalDescription(__('This will attempt to issue SSL certificates for all domains without active certificates. This may take a while.'))
->action(fn () => $this->issueAllPending()),
Action::make('viewLog')
->label(__('View Log'))
->icon('heroicon-o-document-text')
->color('gray')
->modalHeading(__("Let's Encrypt Log"))
->modalWidth('4xl')
->modalContent(fn () => view('filament.admin.pages.ssl-log-modal', ['log' => $this->getLetsEncryptLog()]))
->modalSubmitAction(false)
->modalCancelActionLabel(__('Close')),
];
}
}

View File

@@ -0,0 +1,689 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Pages;
use App\Models\Setting;
use App\Services\Agent\AgentClient;
use BackedEnum;
use Exception;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Section;
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 Waf extends Page implements HasForms, HasTable
{
use InteractsWithForms;
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedShieldCheck;
protected static ?int $navigationSort = 20;
protected static ?string $slug = 'waf';
protected string $view = 'filament.admin.pages.waf';
protected static bool $shouldRegisterNavigation = false;
public bool $wafInstalled = false;
public array $wafFormData = [];
public array $auditEntries = [];
public bool $auditLoaded = false;
public function getTitle(): string|Htmlable
{
return __('ModSecurity / WAF');
}
public static function getNavigationLabel(): string
{
return __('ModSecurity / WAF');
}
public function mount(): void
{
$this->wafInstalled = $this->detectWaf();
$this->wafFormData = [
'enabled' => Setting::get('waf_enabled', '0') === '1',
'paranoia' => Setting::get('waf_paranoia', '1'),
'audit_log' => Setting::get('waf_audit_log', '1') === '1',
];
$this->loadAuditLogs(false);
}
protected function detectWaf(): bool
{
$paths = [
'/etc/nginx/modsec/main.conf',
'/etc/nginx/modsecurity.conf',
'/etc/modsecurity/modsecurity.conf',
'/etc/modsecurity/modsecurity.conf-recommended',
];
foreach ($paths as $path) {
if (file_exists($path)) {
return true;
}
}
return false;
}
protected function getForms(): array
{
return ['wafForm'];
}
public function wafForm(\Filament\Schemas\Schema $schema): \Filament\Schemas\Schema
{
return $schema
->statePath('wafFormData')
->schema([
Section::make(__('WAF Settings'))
->schema([
Toggle::make('enabled')
->label(__('Enable ModSecurity'))
->disabled(fn () => ! $this->wafInstalled)
->helperText(fn () => $this->wafInstalled ? null : __('ModSecurity is not installed. Install it to enable WAF.')),
Select::make('paranoia')
->label(__('Paranoia Level'))
->options([
'1' => '1 - Basic',
'2' => '2 - Moderate',
'3' => '3 - Strict',
'4' => '4 - Very Strict',
])
->default('1'),
Toggle::make('audit_log')
->label(__('Enable Audit Log')),
])
->columns(2),
]);
}
public function saveWafSettings(): void
{
$data = $this->wafForm->getState();
$requestedEnabled = ! empty($data['enabled']);
if ($requestedEnabled && ! $this->wafInstalled) {
$requestedEnabled = false;
}
Setting::set('waf_enabled', $requestedEnabled ? '1' : '0');
Setting::set('waf_paranoia', (string) ($data['paranoia'] ?? '1'));
Setting::set('waf_audit_log', ! empty($data['audit_log']) ? '1' : '0');
$whitelistRules = $this->getWhitelistRules();
try {
$agent = new AgentClient;
$agent->wafApplySettings(
$requestedEnabled,
(string) ($data['paranoia'] ?? '1'),
! empty($data['audit_log']),
$whitelistRules
);
if (! $this->wafInstalled && ! empty($data['enabled'])) {
Notification::make()
->title(__('ModSecurity is not installed'))
->body(__('WAF was disabled automatically. Install ModSecurity to enable it.'))
->warning()
->send();
return;
}
Notification::make()
->title(__('WAF settings applied'))
->success()
->send();
} catch (Exception $e) {
Notification::make()
->title(__('WAF settings saved, but apply failed'))
->body($e->getMessage())
->warning()
->send();
}
}
public function loadAuditLogs(bool $notify = true): void
{
try {
$agent = new AgentClient;
$response = $agent->wafAuditLogList();
$entries = $response['entries'] ?? [];
if (! is_array($entries)) {
$entries = [];
}
$this->auditEntries = $this->normalizeAuditEntries($this->markWhitelisted($entries));
$this->auditLoaded = true;
if ($notify) {
Notification::make()
->title(__('WAF logs refreshed'))
->success()
->send();
}
} catch (Exception $e) {
Notification::make()
->title(__('Failed to load WAF logs'))
->body($e->getMessage())
->warning()
->send();
}
}
protected function getWhitelistRules(): array
{
$raw = Setting::get('waf_whitelist_rules', '[]');
$rules = json_decode($raw, true);
if (! is_array($rules)) {
return [];
}
$changed = false;
foreach ($rules as &$rule) {
if (! is_array($rule)) {
$rule = [];
$changed = true;
continue;
}
if (array_key_exists('enabled', $rule)) {
unset($rule['enabled']);
$changed = true;
}
$label = (string) ($rule['label'] ?? '');
if ($label === '' || str_contains($label, '{rule}') || str_contains($label, ':rule')) {
$rule['label'] = $this->defaultWhitelistLabel($rule);
$changed = true;
}
}
$rules = array_values(array_filter($rules, fn ($rule) => is_array($rule) && ($rule !== [])));
if ($changed) {
Setting::set('waf_whitelist_rules', json_encode($rules, JSON_UNESCAPED_SLASHES));
}
return $rules;
}
protected function defaultWhitelistLabel(array $rule): string
{
$ids = trim((string) ($rule['rule_ids'] ?? ''));
if ($ids !== '') {
return __('Rule :id', ['id' => $ids]);
}
$matchValue = trim((string) ($rule['match_value'] ?? ''));
if ($matchValue !== '') {
return $matchValue;
}
return __('Whitelist rule');
}
protected function markWhitelisted(array $entries): array
{
$rules = $this->getWhitelistRules();
foreach ($entries as &$entry) {
$entry['whitelisted'] = $this->matchesWhitelist($entry, $rules);
}
return $entries;
}
protected function normalizeAuditEntries(array $entries): array
{
return array_map(function (array $entry): array {
$entry['__key'] = md5(implode('|', [
(string) ($entry['timestamp'] ?? ''),
(string) ($entry['rule_id'] ?? ''),
(string) ($entry['uri'] ?? ''),
(string) ($entry['remote_ip'] ?? ''),
(string) ($entry['host'] ?? ''),
]));
return $entry;
}, $entries);
}
protected function matchesWhitelist(array $entry, array $rules): bool
{
$ruleId = (string) ($entry['rule_id'] ?? '');
$uri = (string) ($entry['uri'] ?? '');
$uriPath = $this->stripQueryString($uri);
$host = (string) ($entry['host'] ?? '');
$ip = (string) ($entry['remote_ip'] ?? '');
foreach ($rules as $rule) {
if (!is_array($rule)) {
continue;
}
$idsRaw = (string) ($rule['rule_ids'] ?? '');
$ids = preg_split('/[,\s]+/', $idsRaw, -1, PREG_SPLIT_NO_EMPTY) ?: [];
$ids = array_map('trim', $ids);
if ($ruleId !== '' && !empty($ids) && !in_array($ruleId, $ids, true)) {
continue;
}
$matchType = (string) ($rule['match_type'] ?? '');
$matchValue = (string) ($rule['match_value'] ?? '');
if ($matchType === 'ip' && $matchValue !== '' && $this->ipMatches($ip, $matchValue)) {
return true;
}
if ($matchType === 'uri_exact' && $matchValue !== '' && ($uri === $matchValue || $uriPath === $matchValue)) {
return true;
}
if ($matchType === 'uri_prefix' && $matchValue !== '' && (str_starts_with($uri, $matchValue) || str_starts_with($uriPath, $matchValue))) {
return true;
}
if ($matchType === 'host' && $matchValue !== '' && $host === $matchValue) {
return true;
}
}
return false;
}
protected function ruleMatchesEntry(array $rule, array $entry): bool
{
if (!is_array($rule)) {
return false;
}
$ruleId = (string) ($entry['rule_id'] ?? '');
$uri = (string) ($entry['uri'] ?? '');
$uriPath = $this->stripQueryString($uri);
$host = (string) ($entry['host'] ?? '');
$ip = (string) ($entry['remote_ip'] ?? '');
$idsRaw = (string) ($rule['rule_ids'] ?? '');
$ids = preg_split('/[,\s]+/', $idsRaw, -1, PREG_SPLIT_NO_EMPTY) ?: [];
$ids = array_map('trim', $ids);
if ($ruleId !== '' && !empty($ids) && !in_array($ruleId, $ids, true)) {
return false;
}
$matchType = (string) ($rule['match_type'] ?? '');
$matchValue = (string) ($rule['match_value'] ?? '');
if ($matchType === 'ip' && $matchValue !== '' && $this->ipMatches($ip, $matchValue)) {
return true;
}
if ($matchType === 'uri_exact' && $matchValue !== '' && ($uri === $matchValue || $uriPath === $matchValue)) {
return true;
}
if ($matchType === 'uri_prefix' && $matchValue !== '' && (str_starts_with($uri, $matchValue) || str_starts_with($uriPath, $matchValue))) {
return true;
}
if ($matchType === 'host' && $matchValue !== '' && $host === $matchValue) {
return true;
}
return false;
}
protected function ipMatches(string $ip, string $rule): bool
{
if ($ip === '' || $rule === '') {
return false;
}
if (! str_contains($rule, '/')) {
return $ip === $rule;
}
[$subnet, $bits] = array_pad(explode('/', $rule, 2), 2, null);
$bits = is_numeric($bits) ? (int) $bits : null;
if ($bits === null || $bits < 0 || $bits > 32) {
return $ip === $rule;
}
$ipLong = ip2long($ip);
$subnetLong = ip2long($subnet);
if ($ipLong === false || $subnetLong === false) {
return $ip === $rule;
}
$mask = -1 << (32 - $bits);
return ($ipLong & $mask) === ($subnetLong & $mask);
}
protected function formatUriForDisplay(array $record): string
{
$host = (string) ($record['host'] ?? '');
$uri = (string) ($record['uri'] ?? '');
if ($host !== '' && $uri !== '') {
return $host.$uri;
}
return $uri !== '' ? $uri : $host;
}
public function whitelistEntry(array $record): void
{
$rules = $this->getWhitelistRules();
$matchType = 'uri_prefix';
$rawUri = (string) ($record['uri'] ?? '');
$matchValue = $this->stripQueryString($rawUri);
if ($matchValue === '') {
$matchType = 'ip';
$matchValue = (string) ($record['remote_ip'] ?? '');
}
if ($matchValue === '' || empty($record['rule_id'])) {
Notification::make()
->title(__('Unable to whitelist entry'))
->body(__('Missing URI/IP or rule ID for this entry.'))
->warning()
->send();
return;
}
$rules[] = [
'label' => __('Rule :rule', ['rule' => $record['rule_id'] ?? '']),
'match_type' => $matchType,
'match_value' => $matchValue,
'rule_ids' => $record['rule_id'] ?? '',
];
Setting::set('waf_whitelist_rules', json_encode(array_values($rules), JSON_UNESCAPED_SLASHES));
try {
$agent = new AgentClient;
$agent->wafApplySettings(
Setting::get('waf_enabled', '0') === '1',
(string) Setting::get('waf_paranoia', '1'),
Setting::get('waf_audit_log', '1') === '1',
$rules
);
} catch (Exception $e) {
Notification::make()
->title(__('Whitelist saved, but apply failed'))
->body($e->getMessage())
->warning()
->send();
}
$this->loadAuditLogs(false);
$this->resetTable();
$this->dispatch('waf-whitelist-updated');
$this->dispatch('waf-blocked-updated');
Notification::make()
->title(__('Rule whitelisted'))
->success()
->send();
}
protected function stripQueryString(string $uri): string
{
$pos = strpos($uri, '?');
if ($pos === false) {
return $uri;
}
return substr($uri, 0, $pos);
}
public function removeWhitelistEntry(array $record): void
{
$rules = $this->getWhitelistRules();
$beforeCount = count($rules);
$rules = array_values(array_filter($rules, function (array $rule) use ($record): bool {
return ! $this->ruleMatchesEntry($rule, $record);
}));
if (count($rules) === $beforeCount) {
Notification::make()
->title(__('No matching whitelist rule found'))
->warning()
->send();
return;
}
Setting::set('waf_whitelist_rules', json_encode(array_values($rules), JSON_UNESCAPED_SLASHES));
try {
$agent = new AgentClient;
$agent->wafApplySettings(
Setting::get('waf_enabled', '0') === '1',
(string) Setting::get('waf_paranoia', '1'),
Setting::get('waf_audit_log', '1') === '1',
$rules
);
} catch (Exception $e) {
Notification::make()
->title(__('Whitelist updated, but apply failed'))
->body($e->getMessage())
->warning()
->send();
}
$this->loadAuditLogs(false);
$this->resetTable();
$this->dispatch('waf-whitelist-updated');
$this->dispatch('waf-blocked-updated');
Notification::make()
->title(__('Whitelist removed'))
->success()
->send();
}
public function table(Table $table): Table
{
return $table
->persistColumnsInSession(false)
->paginated([25, 50, 100])
->defaultPaginationPageOption(25)
->records(function (?array $filters, ?string $search, int|string $page, int|string $recordsPerPage, ?string $sortColumn, ?string $sortDirection) {
if (! $this->auditLoaded) {
$this->loadAuditLogs(false);
}
$records = $this->auditEntries;
$records = $this->filterRecords($records, $search);
$records = $this->sortRecords($records, $sortColumn, $sortDirection);
return $this->paginateRecords($records, $page, $recordsPerPage);
})
->columns([
TextColumn::make('timestamp')
->label(__('Time'))
->formatStateUsing(function (array $record): string {
$timestamp = (int) ($record['timestamp'] ?? 0);
return $timestamp > 0 ? date('Y-m-d H:i:s', $timestamp) : '';
})
->sortable(),
TextColumn::make('rule_id')
->label(__('Rule ID'))
->fontFamily('mono')
->sortable()
->searchable(),
TextColumn::make('event_type')
->label(__('Type'))
->badge()
->getStateUsing(function (array $record): string {
if (!empty($record['blocked'])) {
return __('Blocked');
}
$severity = (int) ($record['severity'] ?? 0);
if ($severity >= 4) {
return __('Error');
}
return __('Warning');
})
->color(function (array $record): string {
if (!empty($record['blocked'])) {
return 'danger';
}
$severity = (int) ($record['severity'] ?? 0);
if ($severity >= 4) {
return 'warning';
}
return 'gray';
})
->sortable()
->toggleable(),
TextColumn::make('message')
->label(__('Message'))
->wrap()
->limit(80)
->searchable(),
TextColumn::make('uri')
->label(__('URI'))
->getStateUsing(fn (array $record): string => $this->formatUriForDisplay($record))
->formatStateUsing(fn (string $state): string => Str::limit($state, 52, '…'))
->tooltip(fn (array $record): string => $this->formatUriForDisplay($record))
->copyable()
->copyableState(fn (array $record): string => $this->formatUriForDisplay($record))
->extraAttributes([
'class' => 'inline-block max-w-[240px] truncate',
'style' => 'max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block;',
])
->wrap(false)
->searchable(),
TextColumn::make('remote_ip')
->label(__('Source IP'))
->fontFamily('mono')
->copyable()
->toggleable(isToggledHiddenByDefault: false),
TextColumn::make('host')
->label(__('Host'))
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('whitelisted')
->label(__('Whitelisted'))
->badge()
->formatStateUsing(fn (array $record): string => !empty($record['whitelisted']) ? __('Yes') : __('No'))
->color(fn (array $record): string => !empty($record['whitelisted']) ? 'success' : 'gray'),
])
->recordActions([
\Filament\Actions\Action::make('whitelist')
->label(__('Whitelist'))
->icon('heroicon-o-check-badge')
->color('primary')
->visible(fn (array $record): bool => empty($record['whitelisted']))
->action(fn (array $record) => $this->whitelistEntry($record)),
\Filament\Actions\Action::make('removeWhitelist')
->label(__('Remove whitelist'))
->icon('heroicon-o-x-mark')
->color('danger')
->visible(fn (array $record): bool => !empty($record['whitelisted']))
->requiresConfirmation()
->action(fn (array $record) => $this->removeWhitelistEntry($record)),
])
->emptyStateHeading(__('No blocked rules found'))
->emptyStateDescription(__('No ModSecurity denials found in the audit log.'))
->headerActions([
\Filament\Actions\Action::make('refresh')
->label(__('Refresh Logs'))
->icon('heroicon-o-arrow-path')
->action(fn () => $this->loadAuditLogs()),
]);
}
#[\Livewire\Attributes\On('waf-blocked-updated')]
public function refreshBlockedTable(): void
{
$this->loadAuditLogs(false);
$this->resetTable();
}
protected function filterRecords(array $records, ?string $search): array
{
if (! $search) {
return $records;
}
return array_values(array_filter($records, function (array $record) use ($search) {
$haystack = implode(' ', array_filter([
(string) ($record['rule_id'] ?? ''),
(string) ($record['message'] ?? ''),
(string) ($record['uri'] ?? ''),
(string) ($record['remote_ip'] ?? ''),
(string) ($record['host'] ?? ''),
]));
return str_contains(Str::lower($haystack), Str::lower($search));
}));
}
protected function sortRecords(array $records, ?string $sortColumn, ?string $sortDirection): array
{
$direction = $sortDirection === 'asc' ? 'asc' : 'desc';
if (! $sortColumn) {
return $records;
}
usort($records, function (array $a, array $b) use ($sortColumn, $direction): int {
$aValue = $a[$sortColumn] ?? null;
$bValue = $b[$sortColumn] ?? null;
if (is_numeric($aValue) && is_numeric($bValue)) {
$result = (float) $aValue <=> (float) $bValue;
} else {
$result = strcmp((string) $aValue, (string) $bValue);
}
return $direction === 'asc' ? $result : -$result;
});
return $records;
}
protected function paginateRecords(array $records, int|string $page, int|string $recordsPerPage): LengthAwarePaginator
{
$page = max(1, (int) $page);
$perPage = max(1, (int) $recordsPerPage);
$total = count($records);
$items = array_slice($records, ($page - 1) * $perPage, $perPage);
return new LengthAwarePaginator(
$items,
$total,
$perPage,
$page,
[
'path' => request()->url(),
'pageName' => $this->getTablePaginationPageName(),
],
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Resources\GeoBlockRules;
use App\Filament\Admin\Resources\GeoBlockRules\Pages\CreateGeoBlockRule;
use App\Filament\Admin\Resources\GeoBlockRules\Pages\EditGeoBlockRule;
use App\Filament\Admin\Resources\GeoBlockRules\Pages\ListGeoBlockRules;
use App\Filament\Admin\Resources\GeoBlockRules\Schemas\GeoBlockRuleForm;
use App\Filament\Admin\Resources\GeoBlockRules\Tables\GeoBlockRulesTable;
use App\Models\GeoBlockRule;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class GeoBlockRuleResource extends Resource
{
protected static ?string $model = GeoBlockRule::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedGlobeAlt;
protected static ?int $navigationSort = 21;
public static function getNavigationLabel(): string
{
return __('Geographic Blocking');
}
public static function getModelLabel(): string
{
return __('Geo Rule');
}
public static function getPluralModelLabel(): string
{
return __('Geo Rules');
}
public static function form(Schema $schema): Schema
{
return GeoBlockRuleForm::configure($schema);
}
public static function table(Table $table): Table
{
return GeoBlockRulesTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => ListGeoBlockRules::route('/'),
'create' => CreateGeoBlockRule::route('/create'),
'edit' => EditGeoBlockRule::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Resources\GeoBlockRules\Pages;
use App\Filament\Admin\Resources\GeoBlockRules\GeoBlockRuleResource;
use App\Services\System\GeoBlockService;
use Exception;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;
class CreateGeoBlockRule extends CreateRecord
{
protected static string $resource = GeoBlockRuleResource::class;
protected function afterCreate(): void
{
try {
app(GeoBlockService::class)->applyCurrentRules();
Notification::make()
->title(__('Geo rules applied'))
->success()
->send();
} catch (Exception $e) {
Notification::make()
->title(__('Geo rules apply failed'))
->body($e->getMessage())
->danger()
->send();
}
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Resources\GeoBlockRules\Pages;
use App\Filament\Admin\Resources\GeoBlockRules\GeoBlockRuleResource;
use App\Services\System\GeoBlockService;
use Exception;
use Filament\Actions\DeleteAction;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
class EditGeoBlockRule extends EditRecord
{
protected static string $resource = GeoBlockRuleResource::class;
protected function afterSave(): void
{
$this->applyGeoRules();
}
protected function getHeaderActions(): array
{
return [
DeleteAction::make()
->after(fn () => $this->applyGeoRules()),
];
}
protected function applyGeoRules(): void
{
try {
app(GeoBlockService::class)->applyCurrentRules();
Notification::make()
->title(__('Geo rules applied'))
->success()
->send();
} catch (Exception $e) {
Notification::make()
->title(__('Geo rules apply failed'))
->body($e->getMessage())
->danger()
->send();
}
}
}

View File

@@ -0,0 +1,141 @@
<?php
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\FileUpload;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Support\Facades\Storage;
class ListGeoBlockRules extends ListRecords
{
protected static string $resource = GeoBlockRuleResource::class;
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();
}
}),
Action::make('uploadGeoIpDatabase')
->label(__('Upload GeoIP Database'))
->icon('heroicon-o-arrow-up-tray')
->form([
FileUpload::make('mmdb_file')
->label(__('GeoIP .mmdb File'))
->required()
->acceptedFileTypes(['application/octet-stream', 'application/x-maxmind-db'])
->helperText(__('Upload a MaxMind GeoIP .mmdb file (GeoLite2 or GeoIP2).')),
TextInput::make('edition')
->label(__('Edition ID'))
->helperText(__('Example: GeoLite2-Country'))
->default(fn (): string => (string) (DnsSetting::get('geoip_edition_ids') ?? 'GeoLite2-Country')),
])
->action(function (array $data): void {
if (empty($data['mmdb_file'])) {
Notification::make()
->title(__('No file uploaded'))
->danger()
->send();
return;
}
$edition = trim((string) ($data['edition'] ?? 'GeoLite2-Country'));
if ($edition === '') {
$edition = 'GeoLite2-Country';
}
$filePath = Storage::disk('local')->path($data['mmdb_file']);
if (! file_exists($filePath)) {
Notification::make()
->title(__('Uploaded file not found'))
->danger()
->send();
return;
}
$content = base64_encode((string) file_get_contents($filePath));
try {
$agent = new AgentClient;
$result = $agent->geoUploadDatabase($edition, $content);
Notification::make()
->title(__('GeoIP database uploaded'))
->body($result['path'] ?? null)
->success()
->send();
} catch (Exception $e) {
Notification::make()
->title(__('GeoIP upload failed'))
->body($e->getMessage())
->danger()
->send();
}
}),
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Resources\GeoBlockRules\Schemas;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class GeoBlockRuleForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->columns(1)
->components([
Section::make(__('Geo Rule'))
->schema([
TextInput::make('country_code')
->label(__('Country Code'))
->maxLength(2)
->minLength(2)
->required()
->helperText(__('Use ISO-3166 alpha-2 code (e.g., US, DE, FR).')),
Select::make('action')
->label(__('Action'))
->options([
'block' => __('Block'),
'allow' => __('Allow'),
])
->default('block')
->required(),
TextInput::make('notes')
->label(__('Notes')),
Toggle::make('is_active')
->label(__('Active'))
->default(true),
])
->columns(2),
]);
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Resources\GeoBlockRules\Tables;
use App\Services\System\GeoBlockService;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Notifications\Notification;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class GeoBlockRulesTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('country_code')
->label(__('Country'))
->formatStateUsing(fn ($state) => strtoupper($state))
->searchable(),
TextColumn::make('action')
->label(__('Action'))
->badge()
->color(fn (string $state): string => $state === 'block' ? 'danger' : 'success'),
TextColumn::make('notes')
->label(__('Notes'))
->wrap(),
IconColumn::make('is_active')
->label(__('Active'))
->boolean(),
])
->recordActions([
EditAction::make(),
DeleteAction::make()
->after(function () {
try {
app(GeoBlockService::class)->applyCurrentRules();
} catch (\Exception $e) {
Notification::make()
->title(__('Geo rules sync failed'))
->body($e->getMessage())
->danger()
->send();
}
}),
])
->emptyStateHeading(__('No geo rules'))
->emptyStateDescription(__('Add a country rule to allow or block traffic.'));
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Resources\HostingPackages;
use App\Filament\Admin\Resources\HostingPackages\Pages\CreateHostingPackage;
use App\Filament\Admin\Resources\HostingPackages\Pages\EditHostingPackage;
use App\Filament\Admin\Resources\HostingPackages\Pages\ListHostingPackages;
use App\Filament\Admin\Resources\HostingPackages\Schemas\HostingPackageForm;
use App\Filament\Admin\Resources\HostingPackages\Tables\HostingPackagesTable;
use App\Models\HostingPackage;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class HostingPackageResource extends Resource
{
protected static ?string $model = HostingPackage::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedCube;
protected static ?int $navigationSort = 2;
public static function getNavigationLabel(): string
{
return __('Hosting Packages');
}
public static function getModelLabel(): string
{
return __('Hosting Package');
}
public static function getPluralModelLabel(): string
{
return __('Hosting Packages');
}
public static function form(Schema $schema): Schema
{
return HostingPackageForm::configure($schema);
}
public static function table(Table $table): Table
{
return HostingPackagesTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => ListHostingPackages::route('/'),
'create' => CreateHostingPackage::route('/create'),
'edit' => EditHostingPackage::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Resources\HostingPackages\Pages;
use App\Filament\Admin\Resources\HostingPackages\HostingPackageResource;
use Filament\Resources\Pages\CreateRecord;
class CreateHostingPackage extends CreateRecord
{
protected static string $resource = HostingPackageResource::class;
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Resources\HostingPackages\Pages;
use App\Filament\Admin\Resources\HostingPackages\HostingPackageResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditHostingPackage extends EditRecord
{
protected static string $resource = HostingPackageResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Resources\HostingPackages\Pages;
use App\Filament\Admin\Resources\HostingPackages\HostingPackageResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListHostingPackages extends ListRecords
{
protected static string $resource = HostingPackageResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Resources\HostingPackages\Schemas;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class HostingPackageForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->columns(1)
->components([
Section::make(__('Package Details'))
->schema([
TextInput::make('name')
->label(__('Name'))
->required()
->maxLength(120)
->unique(ignoreRecord: true),
Textarea::make('description')
->label(__('Description'))
->rows(3)
->columnSpanFull(),
Toggle::make('is_active')
->label(__('Active'))
->default(true),
])
->columns(2),
Section::make(__('Resource Limits'))
->description(__('Leave blank for unlimited.'))
->schema([
TextInput::make('disk_quota_mb')
->label(__('Disk Quota (MB)'))
->numeric()
->minValue(0)
->helperText(__('Example: 10240 = 10 GB')),
TextInput::make('bandwidth_gb')
->label(__('Bandwidth (GB / month)'))
->numeric()
->minValue(0),
TextInput::make('domains_limit')
->label(__('Domains Limit'))
->numeric()
->minValue(0),
TextInput::make('databases_limit')
->label(__('Databases Limit'))
->numeric()
->minValue(0),
TextInput::make('mailboxes_limit')
->label(__('Mailboxes Limit'))
->numeric()
->minValue(0),
])
->columns(2),
]);
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Resources\HostingPackages\Tables;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class HostingPackagesTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label(__('Name'))
->searchable()
->sortable(),
TextColumn::make('disk_quota_mb')
->label(__('Disk'))
->getStateUsing(function ($record) {
$quota = $record->disk_quota_mb;
if (! $quota) {
return __('Unlimited');
}
return $quota >= 1024
? number_format($quota / 1024, 1).' GB'
: $quota.' MB';
}),
TextColumn::make('bandwidth_gb')
->label(__('Bandwidth'))
->getStateUsing(fn ($record) => $record->bandwidth_gb ? $record->bandwidth_gb.' GB' : __('Unlimited')),
TextColumn::make('domains_limit')
->label(__('Domains'))
->getStateUsing(fn ($record) => $record->domains_limit ?: __('Unlimited')),
TextColumn::make('databases_limit')
->label(__('Databases'))
->getStateUsing(fn ($record) => $record->databases_limit ?: __('Unlimited')),
TextColumn::make('mailboxes_limit')
->label(__('Mailboxes'))
->getStateUsing(fn ($record) => $record->mailboxes_limit ?: __('Unlimited')),
IconColumn::make('is_active')
->label(__('Active'))
->boolean(),
])
->recordActions([
EditAction::make(),
DeleteAction::make(),
])
->defaultSort('name');
}
}

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Resources\Users\Pages;
use App\Filament\Admin\Resources\Users\UserResource;
use App\Models\HostingPackage;
use App\Services\Agent\AgentClient;
use App\Services\System\LinuxUserService;
use Exception;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;
class CreateUser extends CreateRecord
{
protected static string $resource = UserResource::class;
protected ?HostingPackage $selectedPackage = null;
protected function mutateFormDataBeforeCreate(array $data): array
{
// Generate SFTP password (same as user password or random)
if (! empty($data['password'])) {
$data['sftp_password'] = $data['password'];
}
if (! empty($data['hosting_package_id'])) {
$this->selectedPackage = HostingPackage::find($data['hosting_package_id']);
$data['disk_quota_mb'] = $this->selectedPackage?->disk_quota_mb;
} else {
$this->selectedPackage = null;
$data['disk_quota_mb'] = null;
}
return $data;
}
protected function afterCreate(): void
{
$createLinuxUser = $this->data['create_linux_user'] ?? true;
if ($createLinuxUser) {
try {
$linuxService = new LinuxUserService;
// Get the plain password before it was hashed
$password = $this->data['sftp_password'] ?? null;
$linuxService->createUser($this->record, $password);
Notification::make()
->title(__('Linux user created'))
->body(__("System user ':username' has been created.", ['username' => $this->record->username]))
->success()
->send();
// Apply disk quota if enabled
$this->applyDiskQuota();
} catch (Exception $e) {
Notification::make()
->title(__('Linux user creation failed'))
->body($e->getMessage())
->danger()
->send();
}
}
if (! $this->record->hosting_package_id) {
Notification::make()
->title(__('No hosting package selected'))
->body(__('This user has unlimited quotas.'))
->warning()
->send();
}
}
protected function applyDiskQuota(): void
{
$quotaMb = $this->record->disk_quota_mb;
if (! $quotaMb || $quotaMb <= 0) {
return;
}
// Always try to apply quota when set
try {
$agent = new AgentClient;
$result = $agent->quotaSet($this->record->username, (int) $quotaMb);
if ($result['success'] ?? false) {
Notification::make()
->title(__('Disk quota applied'))
->body(__("Quota of :quota GB set for ':username'.", ['quota' => number_format($quotaMb / 1024, 1), 'username' => $this->record->username]))
->success()
->send();
} else {
throw new Exception($result['error'] ?? __('Unknown error'));
}
} catch (Exception $e) {
// Show warning but don't fail - quota value is saved in database
Notification::make()
->title(__('Disk quota failed'))
->body(__('Value saved but filesystem quota not applied: :error', ['error' => $e->getMessage()]))
->warning()
->send();
}
}
}

View File

@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Resources\Users\Pages;
use App\Filament\Admin\Resources\Users\UserResource;
use App\Models\HostingPackage;
use App\Services\Agent\AgentClient;
use App\Services\System\LinuxUserService;
use Exception;
use Filament\Actions;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
class EditUser extends EditRecord
{
protected static string $resource = UserResource::class;
protected ?int $originalQuota = null;
protected ?HostingPackage $selectedPackage = null;
protected function mutateFormDataBeforeFill(array $data): array
{
$this->originalQuota = $data['disk_quota_mb'] ?? null;
return $data;
}
protected function mutateFormDataBeforeSave(array $data): array
{
if (! empty($data['hosting_package_id'])) {
$this->selectedPackage = HostingPackage::find($data['hosting_package_id']);
$data['disk_quota_mb'] = $this->selectedPackage?->disk_quota_mb;
} else {
$this->selectedPackage = null;
$data['disk_quota_mb'] = null;
}
return $data;
}
protected function afterSave(): void
{
$newQuota = $this->record->disk_quota_mb;
if ($newQuota !== $this->originalQuota) {
// Always try to apply quota when changed
try {
$agent = new AgentClient;
$result = $agent->quotaSet($this->record->username, (int) ($newQuota ?? 0));
if ($result['success'] ?? false) {
$message = $newQuota && $newQuota > 0
? __('Quota updated to :size GB', ['size' => number_format($newQuota / 1024, 1)])
: __('Quota removed (unlimited)');
Notification::make()
->title(__('Disk quota updated'))
->body($message)
->success()
->send();
} else {
throw new Exception($result['error'] ?? __('Unknown error'));
}
} catch (Exception $e) {
// Show warning but don't fail - quota value is saved in database
Notification::make()
->title(__('Disk quota update failed'))
->body(__('Value saved but filesystem quota not applied: :error', ['error' => $e->getMessage()]))
->warning()
->send();
}
}
}
protected function getHeaderActions(): array
{
return [
Actions\Action::make('loginAsUser')
->label(__('Login as User'))
->icon('heroicon-o-arrow-right-on-rectangle')
->color('info')
->visible(fn () => ! $this->record->is_admin && $this->record->is_active)
->url(fn () => route('impersonate.start', ['user' => $this->record->id]), shouldOpenInNewTab: true),
Actions\DeleteAction::make()
->visible(fn () => (int) $this->record->id !== 1)
->form([
Toggle::make('remove_home')
->label(__('Delete home directory'))
->helperText(__('Warning: This will permanently delete /home/:username and all its contents!', ['username' => $this->record->username]))
->default(false),
])
->action(function (array $data) {
$removeHome = $data['remove_home'] ?? false;
$username = $this->record->username;
$steps = [];
try {
$linuxService = new LinuxUserService;
$domains = $this->record->domains()->pluck('domain')->all();
if ($linuxService->userExists($username)) {
$result = $linuxService->deleteUser($username, $removeHome, $domains);
if (! ($result['success'] ?? false)) {
throw new Exception($result['error'] ?? __('Failed to delete Linux user'));
}
$steps = array_merge($steps, $result['steps'] ?? []);
} else {
$steps[] = __('Linux user not found on the server');
}
} catch (Exception $e) {
Notification::make()
->title(__('Linux user deletion failed'))
->body($e->getMessage())
->danger()
->send();
}
// Delete from database
$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'));
}),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Admin\Resources\Users\Pages;
use App\Filament\Admin\Resources\Users\UserResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListUsers extends ListRecords
{
protected static string $resource = UserResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,178 @@
<?php
namespace App\Filament\Admin\Resources\Users\Schemas;
use App\Models\HostingPackage;
use Filament\Actions\Action;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Illuminate\Support\Facades\Hash;
class UserForm
{
/**
* Generate a secure password with uppercase, lowercase, numbers, and special characters
*/
public static function generateSecurePassword(int $length = 16): string
{
$uppercase = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
$lowercase = 'abcdefghjkmnpqrstuvwxyz';
$numbers = '23456789';
$special = '!@#$%^&*';
// Ensure at least one of each type
$password = $uppercase[random_int(0, strlen($uppercase) - 1)]
.$lowercase[random_int(0, strlen($lowercase) - 1)]
.$numbers[random_int(0, strlen($numbers) - 1)]
.$special[random_int(0, strlen($special) - 1)];
// Fill rest with random characters from all pools
$allChars = $uppercase.$lowercase.$numbers.$special;
for ($i = 4; $i < $length; $i++) {
$password .= $allChars[random_int(0, strlen($allChars) - 1)];
}
// Shuffle the password
return str_shuffle($password);
}
public static function configure(Schema $schema): Schema
{
return $schema
->columns(1)
->components([
Section::make(__('User Information'))
->schema([
TextInput::make('name')
->label(__('Name'))
->required()
->maxLength(255),
TextInput::make('username')
->label(__('Username'))
->required()
->maxLength(32)
->alphaNum()
->unique(ignoreRecord: true)
->rules(['regex:/^[a-z][a-z0-9_]{0,31}$/'])
->helperText(__('Lowercase letters, numbers, and underscores only. Must start with a letter.'))
->disabled(fn (string $operation): bool => $operation === 'edit'),
TextInput::make('email')
->label(__('Email address'))
->email()
->required()
->unique(ignoreRecord: true)
->maxLength(255),
TextInput::make('password')
->password()
->revealable()
->dehydrateStateUsing(fn ($state) => filled($state) ? Hash::make($state) : null)
->dehydrated(fn ($state) => filled($state))
->required(fn (string $operation): bool => $operation === 'create')
->minLength(8)
->rules([
'regex:/[a-z]/', // lowercase
'regex:/[A-Z]/', // uppercase
'regex:/[0-9]/', // number
])
->suffixActions([
Action::make('generatePassword')
->icon('heroicon-o-arrow-path')
->tooltip(__('Generate secure password'))
->action(function ($set) {
$password = self::generateSecurePassword();
$set('password', $password);
}),
Action::make('copyPassword')
->icon('heroicon-o-clipboard-document')
->tooltip(__('Copy to clipboard'))
->action(function ($state, $livewire) {
if ($state) {
$escaped = addslashes($state);
$livewire->js("navigator.clipboard.writeText('{$escaped}')");
\Filament\Notifications\Notification::make()
->title(__('Copied to clipboard'))
->success()
->duration(2000)
->send();
}
}),
])
->helperText(__('Minimum 8 characters with uppercase, lowercase, and numbers'))
->label(fn (string $operation): string => $operation === 'create' ? __('Password') : __('New Password')),
])
->columns(2),
Section::make(__('Account Settings'))
->schema([
Toggle::make('is_admin')
->label(__('Administrator'))
->helperText(__('Grant full administrative access'))
->inline(false),
Toggle::make('is_active')
->label(__('Active'))
->default(true)
->helperText(__('Inactive users cannot log in'))
->inline(false),
Placeholder::make('package_notice')
->label(__('Hosting Package'))
->content(__('No hosting package selected. This user will have unlimited quotas.'))
->visible(fn ($get) => blank($get('hosting_package_id'))),
Select::make('hosting_package_id')
->label(__('Hosting Package'))
->searchable()
->preload()
->options(fn () => ['' => __('No package (Unlimited)')] + HostingPackage::query()
->where('is_active', true)
->orderBy('name')
->pluck('name', 'id')
->toArray())
->default('')
->afterStateHydrated(fn ($state, $set) => $set('hosting_package_id', $state ?? ''))
->dehydrateStateUsing(fn ($state) => filled($state) ? (int) $state : null)
->helperText(__('Assign a package to set quotas.'))
->columnSpanFull(),
Toggle::make('create_linux_user')
->label(__('Create Linux User'))
->default(true)
->helperText(__('Create a system user account'))
->visibleOn('create')
->dehydrated(false)
->inline(false),
DateTimePicker::make('email_verified_at')
->label(__('Email Verified At')),
])
->columns(4),
Section::make(__('System Information'))
->schema([
Placeholder::make('home_directory_display')
->label(__('Home Directory'))
->content(fn ($record) => $record?->home_directory ?? '/home/'.__('username')),
Placeholder::make('created_at_display')
->label(__('Created'))
->content(fn ($record) => $record?->created_at?->format('M d, Y H:i') ?? __('N/A')),
Placeholder::make('updated_at_display')
->label(__('Last Updated'))
->content(fn ($record) => $record?->updated_at?->format('M d, Y H:i') ?? __('N/A')),
])
->columns(3)
->visibleOn('edit')
->collapsible(),
]);
}
}

View File

@@ -0,0 +1,225 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Resources\Users\Tables;
use App\Services\System\LinuxUserService;
use Exception;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
class UsersTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('id')
->label(__('ID'))
->sortable(),
TextColumn::make('name')
->label(__('Name'))
->searchable()
->sortable(),
TextColumn::make('username')
->label(__('Username'))
->searchable()
->sortable()
->copyable(),
TextColumn::make('email')
->label(__('Email'))
->searchable()
->sortable(),
TextColumn::make('home_directory')
->label(__('Home'))
->toggleable(isToggledHiddenByDefault: true),
IconColumn::make('is_admin')
->boolean()
->label(__('Admin')),
IconColumn::make('is_active')
->boolean()
->label(__('Active')),
TextColumn::make('disk_usage')
->label(__('Disk Usage'))
->getStateUsing(function ($record) {
$used = $record->disk_usage_formatted;
$quotaMb = $record->disk_quota_mb;
if (! $quotaMb || $quotaMb <= 0) {
return $used;
}
$quota = $quotaMb >= 1024
? number_format($quotaMb / 1024, 1).' GB'
: $quotaMb.' MB';
return "{$used} / {$quota}";
})
->description(function ($record) {
if (! $record->disk_quota_mb || $record->disk_quota_mb <= 0) {
return __('Unlimited');
}
return $record->disk_usage_percent.'% '.__('used');
})
->color(function ($record) {
if (! $record->disk_quota_mb || $record->disk_quota_mb <= 0) {
return 'gray';
}
$percent = $record->disk_usage_percent;
if ($percent >= 90) {
return 'danger';
}
if ($percent >= 75) {
return 'warning';
}
return null;
}),
TextColumn::make('created_at')
->label(__('Created'))
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
TernaryFilter::make('is_admin')
->label(__('Administrator')),
TernaryFilter::make('is_active')
->label(__('Active')),
])
->recordActions([
Action::make('loginAsUser')
->label(__('Login as User'))
->icon('heroicon-o-arrow-right-on-rectangle')
->color('info')
->visible(fn ($record) => ! $record->is_admin && $record->is_active)
->url(fn ($record) => route('impersonate.start', ['user' => $record->id]), shouldOpenInNewTab: true),
EditAction::make(),
DeleteAction::make()
->visible(fn ($record) => (int) $record->id !== 1)
->form([
Toggle::make('remove_home')
->label(__('Delete home directory'))
->helperText(fn ($record) => __('Warning: This will permanently delete /home/:username and all its contents!', ['username' => $record->username]))
->default(false),
])
->action(function ($record, array $data) {
$removeHome = $data['remove_home'] ?? false;
$username = $record->username;
$steps = [];
try {
$linuxService = new LinuxUserService;
$domains = $record->domains()->pluck('domain')->all();
if ($linuxService->userExists($username)) {
$result = $linuxService->deleteUser($username, $removeHome, $domains);
if (! ($result['success'] ?? false)) {
throw new Exception($result['error'] ?? __('Failed to delete Linux user'));
}
$steps = array_merge($steps, $result['steps'] ?? []);
} else {
$steps[] = __('Linux user not found on the server');
}
} catch (Exception $e) {
Notification::make()
->title(__('Linux user deletion failed'))
->body($e->getMessage())
->danger()
->send();
}
$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([
DeleteBulkAction::make()
->form([
Toggle::make('remove_home')
->label(__('Delete home directories'))
->helperText(__('Warning: This will permanently delete all home directories for selected users!'))
->default(false),
])
->action(function ($records, array $data) {
$removeHome = $data['remove_home'] ?? false;
$linuxService = new LinuxUserService;
$skippedPrimaryAdmin = 0;
$deletedCount = 0;
foreach ($records as $record) {
if ((int) $record->id === 1) {
$skippedPrimaryAdmin++;
continue;
}
try {
if ($linuxService->userExists($record->username)) {
$linuxService->deleteUser($record->username, $removeHome);
}
} catch (Exception $e) {
Notification::make()
->title(__('Failed to delete Linux user :username', ['username' => $record->username]))
->body($e->getMessage())
->danger()
->send();
}
$record->delete();
$deletedCount++;
}
if ($skippedPrimaryAdmin > 0) {
Notification::make()
->title(__('Primary admin account cannot be deleted'))
->body(__(':count account(s) were skipped.', ['count' => $skippedPrimaryAdmin]))
->warning()
->send();
}
if ($deletedCount > 0) {
Notification::make()
->title(__('Users deleted'))
->success()
->send();
} else {
Notification::make()
->title(__('No users deleted'))
->warning()
->send();
}
}),
])
->checkIfRecordIsSelectableUsing(fn ($record): bool => (int) $record->id !== 1);
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Filament\Admin\Resources\Users;
use App\Filament\Admin\Resources\Users\Pages\CreateUser;
use App\Filament\Admin\Resources\Users\Pages\EditUser;
use App\Filament\Admin\Resources\Users\Pages\ListUsers;
use App\Filament\Admin\Resources\Users\Schemas\UserForm;
use App\Filament\Admin\Resources\Users\Tables\UsersTable;
use App\Models\User;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class UserResource extends Resource
{
protected static ?string $model = User::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
protected static ?int $navigationSort = 1;
public static function getNavigationLabel(): string
{
return __('Users');
}
public static function getModelLabel(): string
{
return __('User');
}
public static function getPluralModelLabel(): string
{
return __('Users');
}
public static function form(Schema $schema): Schema
{
return UserForm::configure($schema);
}
public static function table(Table $table): Table
{
return UsersTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListUsers::route('/'),
'create' => CreateUser::route('/create'),
'edit' => EditUser::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Resources\WebhookEndpoints\Pages;
use App\Filament\Admin\Resources\WebhookEndpoints\WebhookEndpointResource;
use Filament\Resources\Pages\CreateRecord;
class CreateWebhookEndpoint extends CreateRecord
{
protected static string $resource = WebhookEndpointResource::class;
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Resources\WebhookEndpoints\Pages;
use App\Filament\Admin\Resources\WebhookEndpoints\WebhookEndpointResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditWebhookEndpoint extends EditRecord
{
protected static string $resource = WebhookEndpointResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Resources\WebhookEndpoints\Pages;
use App\Filament\Admin\Resources\WebhookEndpoints\WebhookEndpointResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListWebhookEndpoints extends ListRecords
{
protected static string $resource = WebhookEndpointResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Resources\WebhookEndpoints\Schemas;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Illuminate\Support\Str;
class WebhookEndpointForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->columns(1)
->components([
Section::make(__('Webhook Details'))
->schema([
TextInput::make('name')
->label(__('Name'))
->required()
->maxLength(120),
TextInput::make('url')
->label(__('URL'))
->url()
->required(),
CheckboxList::make('events')
->label(__('Events'))
->options([
'backup.completed' => __('Backup Completed'),
'ssl.expiring' => __('SSL Expiring'),
'migration.completed' => __('Migration Completed'),
'user.suspended' => __('User Suspended'),
])
->columns(2),
TextInput::make('secret_token')
->label(__('Secret Token'))
->helperText(__('Used to sign webhook payloads'))
->default(fn () => Str::random(32))
->password()
->revealable(),
Toggle::make('is_active')
->label(__('Active'))
->default(true),
])
->columns(2),
]);
}
}

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Resources\WebhookEndpoints\Tables;
use App\Models\WebhookEndpoint;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Notifications\Notification;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
class WebhookEndpointsTable
{
public static function configure(Table $table): Table
{
return $table
->query(WebhookEndpoint::query())
->columns([
TextColumn::make('name')
->label(__('Name'))
->searchable(),
TextColumn::make('url')
->label(__('URL'))
->limit(40)
->tooltip(fn (WebhookEndpoint $record) => $record->url),
TextColumn::make('events')
->label(__('Events'))
->formatStateUsing(function ($state): string {
if (is_array($state)) {
return implode(', ', $state);
}
return (string) $state;
})
->wrap(),
IconColumn::make('is_active')
->label(__('Active'))
->boolean(),
TextColumn::make('last_triggered_at')
->label(__('Last Triggered'))
->since(),
TextColumn::make('last_response_code')
->label(__('Last Response')),
])
->recordActions([
Action::make('test')
->label(__('Test'))
->icon('heroicon-o-signal')
->color('info')
->action(function (WebhookEndpoint $record): void {
$payload = [
'event' => 'test',
'timestamp' => now()->toIso8601String(),
'request_id' => Str::uuid()->toString(),
];
$headers = [];
if (! empty($record->secret_token)) {
$signature = hash_hmac('sha256', json_encode($payload), $record->secret_token);
$headers['X-Jabali-Signature'] = $signature;
}
try {
$response = Http::withHeaders($headers)->post($record->url, $payload);
$record->update([
'last_response_code' => $response->status(),
'last_triggered_at' => now(),
]);
Notification::make()
->title(__('Webhook delivered'))
->body(__('Status: :status', ['status' => $response->status()]))
->success()
->send();
} catch (\Exception $e) {
$record->update([
'last_response_code' => null,
'last_triggered_at' => now(),
]);
Notification::make()
->title(__('Webhook failed'))
->body($e->getMessage())
->danger()
->send();
}
}),
EditAction::make(),
DeleteAction::make(),
])
->emptyStateHeading(__('No webhooks configured'))
->emptyStateDescription(__('Add a webhook to receive system notifications.'));
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Resources\WebhookEndpoints;
use App\Filament\Admin\Resources\WebhookEndpoints\Pages\CreateWebhookEndpoint;
use App\Filament\Admin\Resources\WebhookEndpoints\Pages\EditWebhookEndpoint;
use App\Filament\Admin\Resources\WebhookEndpoints\Pages\ListWebhookEndpoints;
use App\Filament\Admin\Resources\WebhookEndpoints\Schemas\WebhookEndpointForm;
use App\Filament\Admin\Resources\WebhookEndpoints\Tables\WebhookEndpointsTable;
use App\Models\WebhookEndpoint;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class WebhookEndpointResource extends Resource
{
protected static ?string $model = WebhookEndpoint::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedBellAlert;
protected static ?int $navigationSort = 18;
public static function getNavigationLabel(): string
{
return __('Webhook Notifications');
}
public static function getModelLabel(): string
{
return __('Webhook');
}
public static function getPluralModelLabel(): string
{
return __('Webhooks');
}
public static function form(Schema $schema): Schema
{
return WebhookEndpointForm::configure($schema);
}
public static function table(Table $table): Table
{
return WebhookEndpointsTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => ListWebhookEndpoints::route('/'),
'create' => CreateWebhookEndpoint::route('/create'),
'edit' => EditWebhookEndpoint::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Widgets;
use App\Models\Domain;
use App\Models\SslCertificate;
use App\Models\User;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class AdminStatsOverview extends BaseWidget
{
protected function getStats(): array
{
$userCount = User::where('is_admin', false)->count();
$domainCount = Domain::count();
$sslActiveCount = SslCertificate::where('status', 'active')->count();
$sslExpiringCount = SslCertificate::where('status', 'active')
->where('expires_at', '<=', now()->addDays(30))
->where('expires_at', '>', now())
->count();
return [
Stat::make(__('Users'), $userCount)
->description(__('Total accounts'))
->descriptionIcon('heroicon-m-users')
->color('danger')
->url(route('filament.admin.resources.users.index')),
Stat::make(__('Domains'), $domainCount)
->description(__('Hosted domains'))
->descriptionIcon('heroicon-m-globe-alt')
->color('success')
->url(url('/jabali-admin/dns-zones')),
Stat::make(__('SSL Certificates'), $sslActiveCount)
->description($sslExpiringCount > 0 ? __(':count expiring soon', ['count' => $sslExpiringCount]) : __('All certificates valid'))
->descriptionIcon($sslExpiringCount > 0 ? 'heroicon-m-exclamation-triangle' : 'heroicon-m-check-circle')
->color($sslExpiringCount > 0 ? 'warning' : 'info')
->url(url('/jabali-admin/ssl-manager')),
Stat::make(__('Server Status'), __('Healthy'))
->description(__('View metrics'))
->descriptionIcon('heroicon-m-server')
->color('gray')
->url(url('/jabali-admin/server-status')),
];
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Widgets\Dashboard;
use App\Models\AuditLog;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Schemas\Concerns\InteractsWithSchemas;
use Filament\Schemas\Contracts\HasSchemas;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Livewire\Component;
class RecentActivityTable extends Component implements HasTable, HasSchemas, HasActions
{
use InteractsWithTable;
use InteractsWithSchemas;
use InteractsWithActions;
public function makeFilamentTranslatableContentDriver(): ?\Filament\Support\Contracts\TranslatableContentDriver
{
return null;
}
public function table(Table $table): Table
{
return $table
->query(AuditLog::query()->with('user')->latest()->limit(10))
->columns([
TextColumn::make('created_at')
->label(__('Time'))
->dateTime('M d, H:i')
->color('gray'),
TextColumn::make('user.name')
->label(__('User'))
->icon('heroicon-o-user')
->default(__('System')),
TextColumn::make('action')
->label(__('Action'))
->badge()
->color(fn (string $state): string => match ($state) {
'create', 'created' => 'success',
'update', 'updated' => 'warning',
'delete', 'deleted' => 'danger',
'login' => 'info',
default => 'gray',
}),
TextColumn::make('description')
->label(__('Description'))
->limit(50)
->wrap(),
])
->paginated(false)
->striped()
->emptyStateHeading(__('No activity'))
->emptyStateDescription(__('No recent activity recorded.'))
->emptyStateIcon('heroicon-o-document-text');
}
public function render()
{
return $this->getTable()->render();
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Widgets;
use App\Models\Domain;
use App\Models\Mailbox;
use App\Models\MysqlCredential;
use App\Models\User;
use Filament\Widgets\Widget;
class DashboardStatsWidget extends Widget
{
protected static ?int $sort = 1;
protected int|string|array $columnSpan = 'full';
protected string $view = 'filament.admin.widgets.dashboard-stats';
public function getStats(): array
{
$userCount = User::where('is_admin', false)->count();
$domainCount = Domain::count();
$mailboxCount = Mailbox::count();
$databaseCount = MysqlCredential::count();
return [
[
'value' => $userCount,
'label' => __('Users'),
'icon' => 'heroicon-o-users',
'color' => 'primary',
],
[
'value' => $domainCount,
'label' => __('Domains'),
'icon' => 'heroicon-o-globe-alt',
'color' => 'success',
],
[
'value' => $mailboxCount,
'label' => __('Mailboxes'),
'icon' => 'heroicon-o-envelope',
'color' => 'info',
],
[
'value' => $databaseCount,
'label' => __('Databases'),
'icon' => 'heroicon-o-circle-stack',
'color' => 'warning',
],
];
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Widgets;
use App\Services\Agent\AgentClient;
use Filament\Widgets\Widget;
class DiskUsageWidget extends Widget
{
protected string $view = 'filament.admin.widgets.disk-usage';
protected int | string | array $columnSpan = 1;
public function getData(): array
{
try {
$agent = new AgentClient();
return $agent->metricsDisk()['data'] ?? [];
} catch (\Exception $e) {
return [];
}
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Widgets;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Schemas\Concerns\InteractsWithSchemas;
use Filament\Schemas\Contracts\HasSchemas;
use Filament\Support\Contracts\TranslatableContentDriver;
use Filament\Support\Enums\FontFamily;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Livewire\Attributes\Reactive;
use Livewire\Component;
class DnsPendingAddsTable extends Component implements HasActions, HasSchemas, HasTable
{
use InteractsWithActions;
use InteractsWithSchemas;
use InteractsWithTable;
#[Reactive]
public array $records = [];
public function makeFilamentTranslatableContentDriver(): ?TranslatableContentDriver
{
return null;
}
/**
* @return array<int, array<string, mixed>>
*/
protected function getRecords(): array
{
return collect($this->records)->values()->all();
}
public function table(Table $table): Table
{
return $table
->records(fn () => $this->getRecords())
->columns([
TextColumn::make('type')
->label(__('Type'))
->badge()
->color('success'),
TextColumn::make('name')
->label(__('Name'))
->fontFamily(FontFamily::Mono),
TextColumn::make('content')
->label(__('Content'))
->fontFamily(FontFamily::Mono)
->limit(50)
->tooltip(fn (array $record): string => (string) ($record['content'] ?? '')),
TextColumn::make('ttl')
->label(__('TTL')),
TextColumn::make('priority')
->label(__('Priority'))
->placeholder('-'),
])
->actions([
Action::make('removePending')
->label(__('Remove'))
->icon('heroicon-o-x-mark')
->color('danger')
->action(function (array $record): void {
$key = $record['key'] ?? null;
if (! $key) {
return;
}
$this->dispatch('dns-pending-add-remove', key: $key);
}),
])
->striped()
->paginated(false)
->emptyStateHeading(__('No pending records'))
->emptyStateDescription(__('Queued records will appear here.'))
->emptyStateIcon('heroicon-o-plus-circle')
->poll(null);
}
public function render()
{
return $this->getTable()->render();
}
}

View File

@@ -0,0 +1,321 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Widgets;
use App\Models\DnsRecord;
use App\Models\DnsSetting;
use App\Models\Domain;
use App\Services\Agent\AgentClient;
use Exception;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Components\Select;
use Filament\Notifications\Notification;
use Filament\Schemas\Concerns\InteractsWithSchemas;
use Filament\Schemas\Contracts\HasSchemas;
use Filament\Support\Contracts\TranslatableContentDriver;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Livewire\Attributes\On;
use Livewire\Component;
class DomainIpAssignmentsTable extends Component implements HasActions, HasSchemas, HasTable
{
use InteractsWithActions;
use InteractsWithSchemas;
use InteractsWithTable;
public ?string $defaultIp = null;
public ?string $defaultIpv6 = null;
protected ?AgentClient $agent = null;
public function makeFilamentTranslatableContentDriver(): ?TranslatableContentDriver
{
return null;
}
public function mount(): void
{
$this->loadDefaults();
}
#[On('ip-defaults-updated')]
public function refreshDefaults(): void
{
$this->loadDefaults();
$this->resetTable();
}
protected function loadDefaults(): void
{
$settings = DnsSetting::getAll();
$this->defaultIp = $settings['default_ip'] ?? null;
$this->defaultIpv6 = $settings['default_ipv6'] ?? null;
}
protected function getAgent(): AgentClient
{
return $this->agent ??= new AgentClient;
}
public function table(Table $table): Table
{
return $table
->query(Domain::query()->with('user')->orderBy('domain'))
->columns([
TextColumn::make('domain')
->label(__('Domain'))
->searchable()
->sortable()
->description(fn (Domain $record) => $record->user?->username ?? __('Unknown')),
TextColumn::make('ip_address')
->label(__('IPv4'))
->badge()
->color(fn (Domain $record): string => $record->ip_address ? 'primary' : 'gray')
->getStateUsing(fn (Domain $record): string => $this->formatIpDisplay($record->ip_address, $this->defaultIp))
->description(fn (Domain $record): string => $this->formatIpDescription($record->ip_address, $this->defaultIp)),
TextColumn::make('ipv6_address')
->label(__('IPv6'))
->badge()
->color(fn (Domain $record): string => $record->ipv6_address ? 'primary' : 'gray')
->getStateUsing(fn (Domain $record): string => $this->formatIpDisplay($record->ipv6_address, $this->defaultIpv6))
->description(fn (Domain $record): string => $this->formatIpDescription($record->ipv6_address, $this->defaultIpv6)),
])
->recordActions([
Action::make('assign')
->label(__('Assign IPs'))
->icon('heroicon-o-adjustments-horizontal')
->color('primary')
->modalHeading(fn (Domain $record): string => __('Assign IPs for :domain', ['domain' => $record->domain]))
->modalDescription(__('Select the IPv4 and IPv6 addresses to use for this domain.'))
->modalSubmitActionLabel(__('Save Assignments'))
->form([
Select::make('ip_address')
->label(__('IPv4 Address'))
->options(fn () => $this->getIpv4Options())
->placeholder(__('Use default IPv4'))
->searchable()
->nullable(),
Select::make('ipv6_address')
->label(__('IPv6 Address'))
->options(fn () => $this->getIpv6Options())
->placeholder(__('Use default IPv6'))
->searchable()
->nullable(),
])
->fillForm(fn (Domain $record): array => [
'ip_address' => $record->ip_address,
'ipv6_address' => $record->ipv6_address,
])
->action(fn (Domain $record, array $data) => $this->assignIps($record, $data)),
])
->striped()
->emptyStateHeading(__('No domains found'))
->emptyStateDescription(__('Create a domain to assign IPs.'))
->emptyStateIcon('heroicon-o-globe-alt');
}
protected function formatIpDisplay(?string $assigned, ?string $default): string
{
if (! empty($assigned)) {
return $assigned;
}
return $default ?: '-';
}
protected function formatIpDescription(?string $assigned, ?string $default): string
{
if (! empty($assigned)) {
return __('Assigned');
}
return $default ? __('Default') : __('Not set');
}
/**
* @return array<string, string>
*/
protected function getIpv4Options(): array
{
return $this->getIpOptionsByVersion(4);
}
/**
* @return array<string, string>
*/
protected function getIpv6Options(): array
{
return $this->getIpOptionsByVersion(6);
}
/**
* @return array<string, string>
*/
protected function getIpOptionsByVersion(int $version): array
{
try {
$result = $this->getAgent()->ipList();
} catch (Exception) {
return [];
}
$options = [];
foreach (($result['addresses'] ?? []) as $address) {
$addressVersion = (int) ($address['version'] ?? 4);
if ($addressVersion !== $version) {
continue;
}
$ip = $address['ip'] ?? null;
if (! $ip) {
continue;
}
$options[$ip] = $this->formatIpOptionLabel($address);
}
return $options;
}
protected function formatIpOptionLabel(array $address): string
{
$ip = $address['ip'] ?? '';
$cidr = $address['cidr'] ?? null;
$interface = $address['interface'] ?? null;
$scope = $address['scope'] ?? null;
$label = $ip;
if ($cidr) {
$label .= '/'.$cidr;
}
if ($interface) {
$label .= ' • '.$interface;
}
if ($scope) {
$label .= ' • '.$scope;
}
return $label;
}
protected function assignIps(Domain $record, array $data): void
{
$settings = DnsSetting::getAll();
$defaultIp = $settings['default_ip'] ?? null;
$defaultIpv6 = $settings['default_ipv6'] ?? null;
$previousIpv4 = $record->ip_address ?: $defaultIp;
$previousIpv6 = $record->ipv6_address ?: $defaultIpv6;
$record->update([
'ip_address' => $data['ip_address'] ?: null,
'ipv6_address' => $data['ipv6_address'] ?: null,
]);
$newIpv4 = $record->ip_address ?: $defaultIp;
$newIpv6 = $record->ipv6_address ?: $defaultIpv6;
$ttl = (int) ($settings['default_ttl'] ?? 3600);
$this->updateDefaultDnsRecords($record, $previousIpv4, $newIpv4, $previousIpv6, $newIpv6, $ttl);
$this->syncDnsZone($record);
Notification::make()
->title(__('IP assignments updated'))
->success()
->send();
$this->dispatch('notificationsSent');
$this->resetTable();
}
protected function updateDefaultDnsRecords(Domain $domain, ?string $previousIpv4, ?string $newIpv4, ?string $previousIpv6, ?string $newIpv6, int $ttl): void
{
foreach (['@', 'www', 'mail'] as $name) {
$this->updateDnsRecord($domain, $name, 'A', $previousIpv4, $newIpv4, $ttl);
$this->updateDnsRecord($domain, $name, 'AAAA', $previousIpv6, $newIpv6, $ttl);
}
}
protected function updateDnsRecord(Domain $domain, string $name, string $type, ?string $previousContent, ?string $newContent, int $ttl): void
{
$query = DnsRecord::query()
->where('domain_id', $domain->id)
->where('name', $name)
->where('type', $type);
if (empty($newContent)) {
if (! empty($previousContent)) {
$query->where('content', $previousContent)->delete();
}
return;
}
if (! empty($previousContent)) {
$updated = (clone $query)
->where('content', $previousContent)
->update(['content' => $newContent, 'ttl' => $ttl]);
if ($updated > 0) {
return;
}
}
$exists = (clone $query)
->where('content', $newContent)
->exists();
if (! $exists) {
DnsRecord::create([
'domain_id' => $domain->id,
'name' => $name,
'type' => $type,
'content' => $newContent,
'ttl' => $ttl,
'priority' => null,
]);
}
}
protected function syncDnsZone(Domain $domain): void
{
$settings = DnsSetting::getAll();
$hostname = gethostname() ?: 'localhost';
$serverIp = trim(shell_exec("hostname -I | awk '{print $1}'") ?? '') ?: '127.0.0.1';
$serverIpv6 = $settings['default_ipv6'] ?? null;
try {
$records = $domain->dnsRecords()->get()->toArray();
$this->getAgent()->send('dns.sync_zone', [
'domain' => $domain->domain,
'records' => $records,
'ns1' => $settings['ns1'] ?? "ns1.{$hostname}",
'ns2' => $settings['ns2'] ?? "ns2.{$hostname}",
'admin_email' => $settings['admin_email'] ?? "admin.{$hostname}",
'default_ip' => $settings['default_ip'] ?? $serverIp,
'default_ipv6' => $serverIpv6,
'default_ttl' => (int) ($settings['default_ttl'] ?? 3600),
]);
} catch (Exception $e) {
Notification::make()
->title(__('DNS sync failed'))
->body($e->getMessage())
->warning()
->send();
$this->dispatch('notificationsSent');
}
}
public function render()
{
return $this->getTable()->render();
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Widgets;
use App\Services\Agent\AgentClient;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class MemoryWidget extends BaseWidget
{
protected function getColumns(): int
{
return 4;
}
protected function getStats(): array
{
try {
$agent = new AgentClient();
$overview = $agent->metricsOverview();
$memory = $overview['memory'] ?? [];
} catch (\Exception $e) {
return [];
}
$total = ($memory['total'] ?? 0) / 1024;
$used = ($memory['used'] ?? 0) / 1024;
$cached = ($memory['cached'] ?? 0) / 1024;
$available = ($memory['available'] ?? 0) / 1024;
return [
Stat::make(__('Total'), number_format($total, 1) . ' GB')
->description(__('Total memory'))
->color('gray'),
Stat::make(__('Used'), number_format($used, 1) . ' GB')
->description(__('In use'))
->color('success'),
Stat::make(__('Cached'), number_format($cached, 1) . ' GB')
->description(__('Cached data'))
->color('primary'),
Stat::make(__('Available'), number_format($available, 1) . ' GB')
->description(__('Free to use'))
->color('warning'),
];
}
}

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Widgets;
use App\Services\Agent\AgentClient;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Schemas\Concerns\InteractsWithSchemas;
use Filament\Schemas\Contracts\HasSchemas;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Livewire\Component;
class NetworkTableWidget extends Component implements HasTable, HasSchemas, HasActions
{
use InteractsWithTable;
use InteractsWithSchemas;
use InteractsWithActions;
public array $interfaces = [];
public function mount(): void
{
$this->loadNetwork();
}
protected function loadNetwork(): void
{
try {
$agent = new AgentClient();
$network = $agent->metricsNetwork()['data'] ?? [];
$interfaces = [];
foreach (($network['interfaces'] ?? []) as $name => $data) {
$interfaces[] = [
'name' => $name,
'ip' => $data['ip'] ?? '-',
'rx' => $data['rx_human'] ?? '0',
'tx' => $data['tx_human'] ?? '0',
];
}
$this->interfaces = $interfaces;
} catch (\Exception $e) {
$this->interfaces = [];
}
}
public function makeFilamentTranslatableContentDriver(): ?\Filament\Support\Contracts\TranslatableContentDriver
{
return null;
}
public function table(Table $table): Table
{
return $table
->records(fn () => $this->interfaces)
->columns([
TextColumn::make('name')
->label(__('Interface'))
->weight('medium'),
TextColumn::make('ip')
->label(__('IP Address'))
->fontFamily('mono')
->color('gray'),
TextColumn::make('rx')
->label(__('Download'))
->icon('heroicon-o-arrow-down')
->badge()
->color('success'),
TextColumn::make('tx')
->label(__('Upload'))
->icon('heroicon-o-arrow-up')
->badge()
->color('info'),
])
->paginated(false)
->striped();
}
public function render()
{
return $this->getTable()->render();
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Widgets;
use App\Services\Agent\AgentClient;
use Filament\Widgets\Widget;
class ProcessesWidget extends Widget
{
protected string $view = 'filament.admin.widgets.processes';
protected int | string | array $columnSpan = 'full';
public function getData(): array
{
try {
$agent = new AgentClient();
return $agent->metricsProcesses(10)['data'] ?? [];
} catch (\Exception $e) {
return [];
}
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Widgets;
use Filament\Widgets\Widget;
class QuickActions extends Widget
{
protected static ?int $sort = 0;
protected int|string|array $columnSpan = 'full';
protected string $view = 'filament.admin.widgets.quick-actions';
public function getActions(): array
{
return [
[
'label' => __('Users'),
'icon' => 'heroicon-o-users',
'url' => route('filament.admin.resources.users.index'),
],
[
'label' => __('Services'),
'icon' => 'heroicon-o-server',
'url' => route('filament.admin.pages.services'),
],
[
'label' => __('Server'),
'icon' => 'heroicon-o-cpu-chip',
'url' => route('filament.admin.pages.server-status'),
],
[
'label' => __('Settings'),
'icon' => 'heroicon-o-cog-6-tooth',
'url' => route('filament.admin.pages.server-settings'),
],
[
'label' => __('Security'),
'icon' => 'heroicon-o-shield-check',
'url' => route('filament.admin.pages.security'),
],
[
'label' => __('SSL'),
'icon' => 'heroicon-o-lock-closed',
'url' => route('filament.admin.pages.ssl-manager'),
],
[
'label' => __('PHP'),
'icon' => 'heroicon-o-code-bracket',
'url' => route('filament.admin.pages.php-manager'),
],
[
'label' => __('DNS'),
'icon' => 'heroicon-o-server-stack',
'url' => route('filament.admin.pages.dns-zones'),
],
[
'label' => __('Backups'),
'icon' => 'heroicon-o-archive-box',
'url' => route('filament.admin.pages.backups'),
],
];
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Widgets\Security;
use App\Models\AuditLog;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Schemas\Concerns\InteractsWithSchemas;
use Filament\Schemas\Contracts\HasSchemas;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Livewire\Component;
class AuditLogsTable extends Component implements HasTable, HasSchemas, HasActions
{
use InteractsWithTable;
use InteractsWithSchemas;
use InteractsWithActions;
public function makeFilamentTranslatableContentDriver(): ?\Filament\Support\Contracts\TranslatableContentDriver
{
return null;
}
public function table(Table $table): Table
{
return $table
->query(AuditLog::query()->with('user')->latest())
->columns([
TextColumn::make('action')
->label(__('Action'))
->badge()
->color(fn (string $state): string => match ($state) {
'login', 'logout' => 'info',
'create', 'created' => 'success',
'delete', 'deleted' => 'danger',
'update', 'updated' => 'warning',
default => 'gray',
})
->searchable(),
TextColumn::make('category')
->label(__('Category'))
->badge()
->color('gray')
->searchable(),
TextColumn::make('description')
->label(__('Description'))
->limit(50)
->tooltip(fn ($record) => $record->description)
->searchable(),
TextColumn::make('user.name')
->label(__('User'))
->default(__('System'))
->searchable(),
TextColumn::make('ip_address')
->label(__('IP'))
->fontFamily('mono')
->size('xs')
->color('gray')
->searchable(),
TextColumn::make('created_at')
->label(__('Time'))
->since()
->sortable(),
])
->defaultSort('created_at', 'desc')
->striped()
->paginated([10, 25, 50, 100])
->defaultPaginationPageOption(10)
->emptyStateHeading(__('No audit logs'))
->emptyStateDescription(__('No actions have been logged yet.'))
->emptyStateIcon('heroicon-o-clipboard-document-list');
}
public function render()
{
return $this->getTable()->render();
}
}

View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Widgets\Security;
use App\Services\Agent\AgentClient;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Notifications\Notification;
use Filament\Schemas\Concerns\InteractsWithSchemas;
use Filament\Schemas\Contracts\HasSchemas;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Livewire\Component;
class BannedIpsTable extends Component implements HasTable, HasSchemas, HasActions
{
use InteractsWithTable;
use InteractsWithSchemas;
use InteractsWithActions;
public array $jails = [];
protected function reloadBannedIps(): void
{
try {
$agent = new AgentClient();
$result = $agent->send('fail2ban.status', []);
if ($result['success'] ?? false) {
$this->jails = $result['jails'] ?? [];
$this->resetTable();
}
} catch (\Exception $e) {
// Keep existing data on error
}
}
public function makeFilamentTranslatableContentDriver(): ?\Filament\Support\Contracts\TranslatableContentDriver
{
return null;
}
protected function getBannedIpRecords(): array
{
$records = [];
foreach ($this->jails as $jail) {
$jailName = $jail['name'] ?? '';
$bannedIps = $jail['banned_ips'] ?? [];
foreach ($bannedIps as $ip) {
$records[] = [
'id' => "{$jailName}_{$ip}",
'jail' => $jailName,
'ip' => $ip,
];
}
}
return $records;
}
public function table(Table $table): Table
{
return $table
->records(fn () => $this->getBannedIpRecords())
->columns([
TextColumn::make('jail')
->label(__('Service'))
->badge()
->color('danger')
->formatStateUsing(fn (string $state): string => ucfirst($state)),
TextColumn::make('ip')
->label(__('IP Address'))
->icon('heroicon-o-globe-alt')
->fontFamily('mono')
->copyable()
->searchable(),
])
->actions([
Action::make('unban')
->label(__('Unban'))
->icon('heroicon-o-lock-open')
->color('success')
->requiresConfirmation()
->modalHeading(__('Unban IP Address'))
->modalDescription(fn (array $record): string => __('Are you sure you want to unban :ip from :jail?', [
'ip' => $record['ip'] ?? '',
'jail' => ucfirst($record['jail'] ?? ''),
]))
->action(function (array $record): void {
try {
$agent = new AgentClient();
$result = $agent->send('fail2ban.unban_ip', [
'jail' => $record['jail'],
'ip' => $record['ip'],
]);
if ($result['success'] ?? false) {
Notification::make()
->title(__('IP Unbanned'))
->body(__(':ip has been unbanned from :jail', [
'ip' => $record['ip'],
'jail' => ucfirst($record['jail']),
]))
->success()
->send();
// Reload banned IPs data directly
$this->reloadBannedIps();
} else {
throw new \Exception($result['error'] ?? __('Failed to unban IP'));
}
} catch (\Exception $e) {
Notification::make()
->title(__('Error'))
->body($e->getMessage())
->danger()
->send();
}
}),
])
->striped()
->emptyStateHeading(__('No banned IPs'))
->emptyStateDescription(__('No IP addresses are currently banned.'))
->emptyStateIcon('heroicon-o-check-circle');
}
public function render()
{
return $this->getTable()->render();
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Widgets\Security;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Schemas\Concerns\InteractsWithSchemas;
use Filament\Schemas\Contracts\HasSchemas;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Livewire\Component;
class Fail2banLogsTable extends Component implements HasActions, HasSchemas, HasTable
{
use InteractsWithActions;
use InteractsWithSchemas;
use InteractsWithTable;
public array $logs = [];
public function makeFilamentTranslatableContentDriver(): ?\Filament\Support\Contracts\TranslatableContentDriver
{
return null;
}
public function table(Table $table): Table
{
return $table
->records(fn () => $this->logs)
->columns([
TextColumn::make('time')
->label(__('Time'))
->fontFamily('mono')
->sortable(),
TextColumn::make('jail')
->label(__('Jail'))
->badge()
->color('gray'),
TextColumn::make('action')
->label(__('Action'))
->badge()
->color(fn (string $state): string => match (strtolower($state)) {
'ban' => 'danger',
'unban' => 'success',
'found' => 'warning',
default => 'gray',
}),
TextColumn::make('ip')
->label(__('IP'))
->fontFamily('mono')
->copyable()
->placeholder('-'),
TextColumn::make('message')
->label(__('Message'))
->wrap(),
])
->emptyStateHeading(__('No log entries'))
->emptyStateDescription(__('Fail2ban logs will appear here once activity is detected.'))
->striped();
}
public function render()
{
return $this->getTable()->render();
}
}

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Widgets\Security;
use App\Services\Agent\AgentClient;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Notifications\Notification;
use Filament\Schemas\Concerns\InteractsWithSchemas;
use Filament\Schemas\Contracts\HasSchemas;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Livewire\Component;
class JailsTable extends Component implements HasTable, HasSchemas, HasActions
{
use InteractsWithTable;
use InteractsWithSchemas;
use InteractsWithActions;
public array $jails = [];
protected function reloadJails(): void
{
try {
$agent = new AgentClient();
$result = $agent->send('fail2ban.list_jails', []);
if ($result['success'] ?? false) {
$this->jails = $result['jails'] ?? [];
$this->resetTable();
}
} catch (\Exception $e) {
// Keep existing data on error
}
}
public function makeFilamentTranslatableContentDriver(): ?\Filament\Support\Contracts\TranslatableContentDriver
{
return null;
}
public function table(Table $table): Table
{
return $table
->records(fn () => $this->jails)
->columns([
TextColumn::make('name')
->label(__('Service'))
->formatStateUsing(fn (string $state): string => ucfirst($state))
->searchable(),
TextColumn::make('description')
->label(__('Description'))
->limit(50)
->color('gray'),
IconColumn::make('active')
->label(__('Active'))
->boolean()
->trueIcon('heroicon-o-check-circle')
->falseIcon('heroicon-o-x-circle')
->trueColor('success')
->falseColor('gray'),
IconColumn::make('enabled')
->label(__('Enabled'))
->boolean()
->trueIcon('heroicon-o-shield-check')
->falseIcon('heroicon-o-shield-exclamation')
->trueColor('success')
->falseColor('gray'),
])
->actions([
Action::make('toggle')
->label(fn (array $record): string => ($record['enabled'] ?? false) ? __('Disable') : __('Enable'))
->icon(fn (array $record): string => ($record['enabled'] ?? false) ? 'heroicon-o-x-circle' : 'heroicon-o-check-circle')
->color(fn (array $record): string => ($record['enabled'] ?? false) ? 'danger' : 'success')
->disabled(fn (array $record): bool => ($record['name'] ?? '') === 'sshd')
->action(function (array $record): void {
$name = $record['name'] ?? '';
$enabled = $record['enabled'] ?? false;
try {
$agent = new AgentClient();
$action = $enabled ? 'fail2ban.disable_jail' : 'fail2ban.enable_jail';
$result = $agent->send($action, ['jail' => $name]);
if ($result['success'] ?? false) {
Notification::make()
->title($enabled ? __('Jail disabled') : __('Jail enabled'))
->success()
->send();
// Reload jails data directly
$this->reloadJails();
} else {
throw new \Exception($result['error'] ?? __('Operation failed'));
}
} catch (\Exception $e) {
Notification::make()
->title(__('Error'))
->body($e->getMessage())
->danger()
->send();
}
}),
])
->striped()
->emptyStateHeading(__('No protection modules'))
->emptyStateDescription(__('No Fail2ban jails are configured.'))
->emptyStateIcon('heroicon-o-shield-exclamation');
}
public function render()
{
return $this->getTable()->render();
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Widgets\Security;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Schemas\Concerns\InteractsWithSchemas;
use Filament\Schemas\Contracts\HasSchemas;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Livewire\Component;
class LynisResultsTable extends Component implements HasTable, HasSchemas, HasActions
{
use InteractsWithTable;
use InteractsWithSchemas;
use InteractsWithActions;
public array $results = [];
public string $type = 'warnings'; // 'warnings' or 'suggestions'
public function makeFilamentTranslatableContentDriver(): ?\Filament\Support\Contracts\TranslatableContentDriver
{
return null;
}
protected function getRecords(): array
{
$items = $this->results[$this->type] ?? [];
return array_map(fn ($item, $index) => [
'id' => $index,
'message' => $item,
], $items, array_keys($items));
}
public function table(Table $table): Table
{
$icon = $this->type === 'warnings' ? 'heroicon-o-exclamation-triangle' : 'heroicon-o-light-bulb';
$color = $this->type === 'warnings' ? 'warning' : 'info';
return $table
->records(fn () => $this->getRecords())
->columns([
TextColumn::make('message')
->label($this->type === 'warnings' ? __('Warning') : __('Suggestion'))
->icon($icon)
->iconColor($color)
->wrap()
->searchable(),
])
->striped()
->paginated([10, 25, 50])
->emptyStateHeading($this->type === 'warnings' ? __('No warnings') : __('No suggestions'))
->emptyStateDescription(__('The system audit found no issues in this category.'))
->emptyStateIcon('heroicon-o-check-circle');
}
public function render()
{
return $this->getTable()->render();
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Widgets\Security;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Schemas\Concerns\InteractsWithSchemas;
use Filament\Schemas\Contracts\HasSchemas;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Livewire\Component;
class NiktoResultsTable extends Component implements HasTable, HasSchemas, HasActions
{
use InteractsWithTable;
use InteractsWithSchemas;
use InteractsWithActions;
public array $results = [];
public string $type = 'vulnerabilities'; // 'vulnerabilities' or 'info'
public function makeFilamentTranslatableContentDriver(): ?\Filament\Support\Contracts\TranslatableContentDriver
{
return null;
}
protected function getRecords(): array
{
$items = $this->results[$this->type] ?? [];
return array_map(fn ($item, $index) => [
'id' => $index,
'message' => $item,
], $items, array_keys($items));
}
public function table(Table $table): Table
{
$icon = $this->type === 'vulnerabilities' ? 'heroicon-o-exclamation-triangle' : 'heroicon-o-information-circle';
$color = $this->type === 'vulnerabilities' ? 'danger' : 'info';
return $table
->records(fn () => $this->getRecords())
->columns([
TextColumn::make('message')
->label($this->type === 'vulnerabilities' ? __('Vulnerability') : __('Information'))
->icon($icon)
->iconColor($color)
->wrap()
->searchable(),
])
->striped()
->paginated([10, 25, 50])
->emptyStateHeading($this->type === 'vulnerabilities' ? __('No vulnerabilities') : __('No information'))
->emptyStateDescription(__('No issues found in this category.'))
->emptyStateIcon('heroicon-o-check-circle');
}
public function render()
{
return $this->getTable()->render();
}
}

Some files were not shown because too many files have changed in this diff Show More