Initial commit
This commit is contained in:
18
.editorconfig
Normal file
18
.editorconfig
Normal 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
62
.env.example
Normal 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}"
|
||||
1
.git-authorized-committers
Normal file
1
.git-authorized-committers
Normal file
@@ -0,0 +1 @@
|
||||
admin@jabali.lan
|
||||
3
.git-authorized-remotes
Normal file
3
.git-authorized-remotes
Normal 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
11
.gitattributes
vendored
Normal 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
58
.githooks/pre-commit
Executable 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
24
.gitignore
vendored
Normal 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
36
.mcp.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
25
AGENTS.md
Normal file
25
AGENTS.md
Normal 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
35
CONTEXT.md
Normal 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
8
DECISIONS.md
Normal 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
75
LICENSE
Normal 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
122
Makefile
Normal 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
160
README.md
Normal 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: 
|
||||
- Server Status: 
|
||||
- Server Settings: 
|
||||
- Security Center: 
|
||||
- Users: 
|
||||
- SSL Manager: 
|
||||
- DNS Zones: 
|
||||
- Backups: 
|
||||
- Services: 
|
||||
|
||||
User panel:
|
||||
|
||||
- Dashboard: 
|
||||
- Domain Management: 
|
||||
- Backups: 
|
||||
- cPanel Migration: 
|
||||
|
||||
## 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
8
TODO.md
Normal 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.
|
||||
35
app/Actions/Fortify/CreateNewUser.php
Normal file
35
app/Actions/Fortify/CreateNewUser.php
Normal 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']),
|
||||
]);
|
||||
}
|
||||
}
|
||||
25
app/Actions/Fortify/PasswordValidationRules.php
Normal file
25
app/Actions/Fortify/PasswordValidationRules.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
29
app/Actions/Fortify/ResetUserPassword.php
Normal file
29
app/Actions/Fortify/ResetUserPassword.php
Normal 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();
|
||||
}
|
||||
}
|
||||
32
app/Actions/Fortify/UpdateUserPassword.php
Normal file
32
app/Actions/Fortify/UpdateUserPassword.php
Normal 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();
|
||||
}
|
||||
}
|
||||
56
app/Actions/Fortify/UpdateUserProfileInformation.php
Normal file
56
app/Actions/Fortify/UpdateUserProfileInformation.php
Normal 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();
|
||||
}
|
||||
}
|
||||
19
app/Actions/Jetstream/DeleteUser.php
Normal file
19
app/Actions/Jetstream/DeleteUser.php
Normal 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();
|
||||
}
|
||||
}
|
||||
79
app/Console/Commands/CheckDiskQuotas.php
Normal file
79
app/Console/Commands/CheckDiskQuotas.php
Normal 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;
|
||||
}
|
||||
}
|
||||
81
app/Console/Commands/CheckFail2banAlerts.php
Normal file
81
app/Console/Commands/CheckFail2banAlerts.php
Normal 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;
|
||||
}
|
||||
}
|
||||
251
app/Console/Commands/CheckFileIntegrity.php
Normal file
251
app/Console/Commands/CheckFileIntegrity.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
105
app/Console/Commands/Jabali/DomainCommand.php
Normal file
105
app/Console/Commands/Jabali/DomainCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
389
app/Console/Commands/Jabali/ImportProcessCommand.php
Normal file
389
app/Console/Commands/Jabali/ImportProcessCommand.php
Normal 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");
|
||||
}
|
||||
}
|
||||
124
app/Console/Commands/Jabali/MigrateRedisUsersCommand.php
Normal file
124
app/Console/Commands/Jabali/MigrateRedisUsersCommand.php
Normal 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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
463
app/Console/Commands/Jabali/SslCheckCommand.php
Normal file
463
app/Console/Commands/Jabali/SslCheckCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
808
app/Console/Commands/Jabali/UpgradeCommand.php
Normal file
808
app/Console/Commands/Jabali/UpgradeCommand.php
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
106
app/Console/Commands/Jabali/UserCommand.php
Normal file
106
app/Console/Commands/Jabali/UserCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
233
app/Console/Commands/ManageGitProtection.php
Normal file
233
app/Console/Commands/ManageGitProtection.php
Normal 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;
|
||||
}
|
||||
}
|
||||
66
app/Console/Commands/NotifyHighLoad.php
Normal file
66
app/Console/Commands/NotifyHighLoad.php
Normal 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',
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
75
app/Console/Commands/NotifyServiceHealth.php
Normal file
75
app/Console/Commands/NotifyServiceHealth.php
Normal 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']
|
||||
);
|
||||
}
|
||||
}
|
||||
25
app/Console/Commands/NotifySshLogin.php
Normal file
25
app/Console/Commands/NotifySshLogin.php
Normal 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;
|
||||
}
|
||||
}
|
||||
282
app/Console/Commands/RunBackupSchedules.php
Normal file
282
app/Console/Commands/RunBackupSchedules.php
Normal 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.");
|
||||
}
|
||||
}
|
||||
121
app/Console/Commands/RunUserCronJobs.php
Normal file
121
app/Console/Commands/RunUserCronJobs.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
58
app/Console/Commands/SyncMailboxQuotas.php
Normal file
58
app/Console/Commands/SyncMailboxQuotas.php
Normal 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;
|
||||
}
|
||||
}
|
||||
58
app/Filament/Admin/Pages/Auth/Login.php
Normal file
58
app/Filament/Admin/Pages/Auth/Login.php
Normal 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;
|
||||
}
|
||||
}
|
||||
188
app/Filament/Admin/Pages/Auth/TwoFactorChallenge.php
Normal file
188
app/Filament/Admin/Pages/Auth/TwoFactorChallenge.php
Normal 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;
|
||||
}
|
||||
}
|
||||
152
app/Filament/Admin/Pages/AutomationApi.php
Normal file
152
app/Filament/Admin/Pages/AutomationApi.php
Normal 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.'));
|
||||
}
|
||||
}
|
||||
1610
app/Filament/Admin/Pages/Backups.php
Normal file
1610
app/Filament/Admin/Pages/Backups.php
Normal file
File diff suppressed because it is too large
Load Diff
2417
app/Filament/Admin/Pages/CpanelMigration.php
Normal file
2417
app/Filament/Admin/Pages/CpanelMigration.php
Normal file
File diff suppressed because it is too large
Load Diff
105
app/Filament/Admin/Pages/Dashboard.php
Normal file
105
app/Filament/Admin/Pages/Dashboard.php
Normal 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();
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
154
app/Filament/Admin/Pages/DatabaseTuning.php
Normal file
154
app/Filament/Admin/Pages/DatabaseTuning.php
Normal 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.'));
|
||||
}
|
||||
}
|
||||
975
app/Filament/Admin/Pages/DnsZones.php
Normal file
975
app/Filament/Admin/Pages/DnsZones.php
Normal 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));
|
||||
}
|
||||
}
|
||||
395
app/Filament/Admin/Pages/EmailLogs.php
Normal file
395
app/Filament/Admin/Pages/EmailLogs.php
Normal file
@@ -0,0 +1,395 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Admin\Pages;
|
||||
|
||||
use App\Services\Agent\AgentClient;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\Concerns\InteractsWithActions;
|
||||
use Filament\Actions\Contracts\HasActions;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Contracts\Support\Htmlable;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class EmailLogs extends Page implements HasActions, HasTable
|
||||
{
|
||||
use InteractsWithActions;
|
||||
use InteractsWithTable;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedInbox;
|
||||
|
||||
protected static ?int $navigationSort = 14;
|
||||
|
||||
protected static ?string $slug = 'email-logs';
|
||||
|
||||
protected string $view = 'filament.admin.pages.email-logs';
|
||||
|
||||
public string $viewMode = 'logs';
|
||||
|
||||
public array $logs = [];
|
||||
|
||||
public array $queueItems = [];
|
||||
|
||||
protected ?AgentClient $agent = null;
|
||||
|
||||
protected bool $logsLoaded = false;
|
||||
|
||||
protected bool $queueLoaded = false;
|
||||
|
||||
public function getTitle(): string|Htmlable
|
||||
{
|
||||
return __('Email Logs');
|
||||
}
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('Email Logs');
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->loadLogs(false);
|
||||
}
|
||||
|
||||
protected function getAgent(): AgentClient
|
||||
{
|
||||
return $this->agent ??= new AgentClient;
|
||||
}
|
||||
|
||||
public function loadLogs(bool $refreshTable = true): void
|
||||
{
|
||||
try {
|
||||
$result = $this->getAgent()->send('email.get_logs', [
|
||||
'limit' => 200,
|
||||
]);
|
||||
|
||||
$this->logs = $result['logs'] ?? [];
|
||||
$this->logsLoaded = true;
|
||||
} catch (\Exception $e) {
|
||||
$this->logs = [];
|
||||
$this->logsLoaded = true;
|
||||
|
||||
Notification::make()
|
||||
->title(__('Failed to load email logs'))
|
||||
->body($e->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
|
||||
if ($refreshTable) {
|
||||
$this->resetTable();
|
||||
}
|
||||
}
|
||||
|
||||
public function loadQueue(bool $refreshTable = true): void
|
||||
{
|
||||
try {
|
||||
$result = $this->getAgent()->send('mail.queue_list');
|
||||
$this->queueItems = $result['queue'] ?? [];
|
||||
$this->queueLoaded = true;
|
||||
} catch (\Exception $e) {
|
||||
$this->queueItems = [];
|
||||
$this->queueLoaded = true;
|
||||
Notification::make()
|
||||
->title(__('Failed to load mail queue'))
|
||||
->body($e->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
|
||||
if ($refreshTable) {
|
||||
$this->resetTable();
|
||||
}
|
||||
}
|
||||
|
||||
public function setViewMode(string $mode): void
|
||||
{
|
||||
$mode = in_array($mode, ['logs', 'queue'], true) ? $mode : 'logs';
|
||||
if ($this->viewMode === $mode) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->viewMode = $mode;
|
||||
|
||||
if ($mode === 'queue') {
|
||||
$this->loadQueue(false);
|
||||
} else {
|
||||
$this->loadLogs(false);
|
||||
}
|
||||
|
||||
$this->resetTable();
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->paginated([25, 50, 100])
|
||||
->defaultPaginationPageOption(25)
|
||||
->records(function (?array $filters, ?string $search, int|string $page, int|string $recordsPerPage, ?string $sortColumn, ?string $sortDirection) {
|
||||
if ($this->viewMode === 'queue') {
|
||||
if (! $this->queueLoaded) {
|
||||
$this->loadQueue(false);
|
||||
}
|
||||
$records = $this->queueItems;
|
||||
} else {
|
||||
if (! $this->logsLoaded) {
|
||||
$this->loadLogs(false);
|
||||
}
|
||||
$records = $this->logs;
|
||||
}
|
||||
|
||||
$records = $this->filterRecords($records, $search);
|
||||
$records = $this->sortRecords($records, $sortColumn, $sortDirection);
|
||||
|
||||
return $this->paginateRecords($records, $page, $recordsPerPage);
|
||||
})
|
||||
->columns($this->viewMode === 'queue' ? $this->getQueueColumns() : $this->getLogColumns())
|
||||
->recordActions($this->viewMode === 'queue' ? $this->getQueueActions() : [])
|
||||
->emptyStateHeading($this->viewMode === 'queue' ? __('Mail queue is empty') : __('No email logs found'))
|
||||
->emptyStateDescription($this->viewMode === 'queue' ? __('No deferred messages found.') : __('Mail logs are empty or unavailable.'))
|
||||
->headerActions([
|
||||
Action::make('viewLogs')
|
||||
->label(__('Logs'))
|
||||
->color($this->viewMode === 'logs' ? 'primary' : 'gray')
|
||||
->action(fn () => $this->setViewMode('logs')),
|
||||
Action::make('viewQueue')
|
||||
->label(__('Queue'))
|
||||
->color($this->viewMode === 'queue' ? 'primary' : 'gray')
|
||||
->action(fn () => $this->setViewMode('queue')),
|
||||
Action::make('refresh')
|
||||
->label(__('Refresh'))
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->action(function (): void {
|
||||
if ($this->viewMode === 'queue') {
|
||||
$this->loadQueue();
|
||||
} else {
|
||||
$this->loadLogs();
|
||||
}
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function getLogColumns(): array
|
||||
{
|
||||
return [
|
||||
TextColumn::make('timestamp')
|
||||
->label(__('Time'))
|
||||
->formatStateUsing(function (array $record): string {
|
||||
$timestamp = (int) ($record['timestamp'] ?? 0);
|
||||
|
||||
return $timestamp > 0 ? date('Y-m-d H:i:s', $timestamp) : '';
|
||||
})
|
||||
->sortable(),
|
||||
TextColumn::make('queue_id')
|
||||
->label(__('Queue ID'))
|
||||
->fontFamily('mono')
|
||||
->copyable()
|
||||
->toggleable(),
|
||||
TextColumn::make('component')
|
||||
->label(__('Component'))
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('from')
|
||||
->label(__('From'))
|
||||
->wrap()
|
||||
->searchable(),
|
||||
TextColumn::make('to')
|
||||
->label(__('To'))
|
||||
->wrap()
|
||||
->searchable(),
|
||||
TextColumn::make('status')
|
||||
->label(__('Status'))
|
||||
->badge()
|
||||
->formatStateUsing(fn (array $record): string => (string) ($record['status'] ?? 'unknown')),
|
||||
TextColumn::make('relay')
|
||||
->label(__('Relay'))
|
||||
->toggleable(),
|
||||
TextColumn::make('message')
|
||||
->label(__('Details'))
|
||||
->wrap()
|
||||
->limit(80)
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getQueueColumns(): array
|
||||
{
|
||||
return [
|
||||
TextColumn::make('id')
|
||||
->label(__('Queue ID'))
|
||||
->fontFamily('mono')
|
||||
->copyable(),
|
||||
TextColumn::make('arrival')
|
||||
->label(__('Arrival')),
|
||||
TextColumn::make('sender')
|
||||
->label(__('Sender'))
|
||||
->wrap()
|
||||
->searchable(),
|
||||
TextColumn::make('recipients')
|
||||
->label(__('Recipients'))
|
||||
->formatStateUsing(function ($state): string {
|
||||
if (is_array($state)) {
|
||||
$recipients = $state;
|
||||
} elseif ($state === null || $state === '') {
|
||||
$recipients = [];
|
||||
} else {
|
||||
$recipients = [(string) $state];
|
||||
}
|
||||
|
||||
$recipients = array_values(array_filter(array_map(function ($recipient): ?string {
|
||||
if (is_array($recipient)) {
|
||||
return (string) ($recipient['address']
|
||||
?? $recipient['recipient']
|
||||
?? $recipient['email']
|
||||
?? $recipient[0]
|
||||
?? '');
|
||||
}
|
||||
|
||||
return $recipient === null ? null : (string) $recipient;
|
||||
}, $recipients), static fn (?string $value): bool => $value !== null && $value !== ''));
|
||||
|
||||
if (empty($recipients)) {
|
||||
return __('Unknown');
|
||||
}
|
||||
|
||||
$first = $recipients[0] ?? '';
|
||||
$count = count($recipients);
|
||||
|
||||
return $count > 1 ? $first.' +'.($count - 1) : $first;
|
||||
})
|
||||
->wrap(),
|
||||
TextColumn::make('size')
|
||||
->label(__('Size'))
|
||||
->formatStateUsing(fn ($state): string => is_scalar($state) ? (string) $state : ''),
|
||||
TextColumn::make('status')
|
||||
->label(__('Status'))
|
||||
->wrap(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getQueueActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('retry')
|
||||
->label(__('Retry'))
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('info')
|
||||
->action(function (array $record): void {
|
||||
try {
|
||||
$result = $this->getAgent()->send('mail.queue_retry', ['id' => $record['id'] ?? '']);
|
||||
if ($result['success'] ?? false) {
|
||||
Notification::make()->title(__('Message retried'))->success()->send();
|
||||
$this->loadQueue();
|
||||
} else {
|
||||
throw new \Exception($result['error'] ?? __('Failed to retry message'));
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Notification::make()->title(__('Retry failed'))->body($e->getMessage())->danger()->send();
|
||||
}
|
||||
}),
|
||||
Action::make('delete')
|
||||
->label(__('Delete'))
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->action(function (array $record): void {
|
||||
try {
|
||||
$result = $this->getAgent()->send('mail.queue_delete', ['id' => $record['id'] ?? '']);
|
||||
if ($result['success'] ?? false) {
|
||||
Notification::make()->title(__('Message deleted'))->success()->send();
|
||||
$this->loadQueue();
|
||||
} else {
|
||||
throw new \Exception($result['error'] ?? __('Failed to delete message'));
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Notification::make()->title(__('Delete failed'))->body($e->getMessage())->danger()->send();
|
||||
}
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
protected function filterRecords(array $records, ?string $search): array
|
||||
{
|
||||
$search = trim((string) $search);
|
||||
if ($search === '') {
|
||||
return $records;
|
||||
}
|
||||
|
||||
$search = Str::lower($search);
|
||||
|
||||
return array_values(array_filter($records, function (array $record) use ($search): bool {
|
||||
if ($this->viewMode === 'queue') {
|
||||
$recipients = $record['recipients'] ?? [];
|
||||
$haystack = implode(' ', array_filter([
|
||||
(string) ($record['id'] ?? ''),
|
||||
(string) ($record['sender'] ?? ''),
|
||||
implode(' ', $recipients),
|
||||
(string) ($record['status'] ?? ''),
|
||||
]));
|
||||
} else {
|
||||
$haystack = implode(' ', array_filter([
|
||||
(string) ($record['queue_id'] ?? ''),
|
||||
(string) ($record['from'] ?? ''),
|
||||
(string) ($record['to'] ?? ''),
|
||||
(string) ($record['status'] ?? ''),
|
||||
(string) ($record['message'] ?? ''),
|
||||
(string) ($record['component'] ?? ''),
|
||||
]));
|
||||
}
|
||||
|
||||
return str_contains(Str::lower($haystack), $search);
|
||||
}));
|
||||
}
|
||||
|
||||
protected function sortRecords(array $records, ?string $sortColumn, ?string $sortDirection): array
|
||||
{
|
||||
$direction = $sortDirection === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
if (! $sortColumn) {
|
||||
return $records;
|
||||
}
|
||||
|
||||
usort($records, function (array $a, array $b) use ($sortColumn, $direction): int {
|
||||
$aValue = $a[$sortColumn] ?? null;
|
||||
$bValue = $b[$sortColumn] ?? null;
|
||||
|
||||
if (is_numeric($aValue) && is_numeric($bValue)) {
|
||||
$result = (float) $aValue <=> (float) $bValue;
|
||||
} else {
|
||||
$result = strcmp((string) $aValue, (string) $bValue);
|
||||
}
|
||||
|
||||
return $direction === 'asc' ? $result : -$result;
|
||||
});
|
||||
|
||||
return $records;
|
||||
}
|
||||
|
||||
protected function paginateRecords(array $records, int|string $page, int|string $recordsPerPage): LengthAwarePaginator
|
||||
{
|
||||
$page = max(1, (int) $page);
|
||||
$perPage = max(1, (int) $recordsPerPage);
|
||||
|
||||
$total = count($records);
|
||||
$items = array_slice($records, ($page - 1) * $perPage, $perPage);
|
||||
|
||||
return new LengthAwarePaginator(
|
||||
$items,
|
||||
$total,
|
||||
$perPage,
|
||||
$page,
|
||||
[
|
||||
'path' => request()->url(),
|
||||
'pageName' => $this->getTablePaginationPageName(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
176
app/Filament/Admin/Pages/EmailQueue.php
Normal file
176
app/Filament/Admin/Pages/EmailQueue.php
Normal 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()),
|
||||
]);
|
||||
}
|
||||
}
|
||||
330
app/Filament/Admin/Pages/IpAddresses.php
Normal file
330
app/Filament/Admin/Pages/IpAddresses.php
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
85
app/Filament/Admin/Pages/Migration.php
Normal file
85
app/Filament/Admin/Pages/Migration.php
Normal 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'),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
291
app/Filament/Admin/Pages/PhpManager.php
Normal file
291
app/Filament/Admin/Pages/PhpManager.php
Normal 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 [
|
||||
];
|
||||
}
|
||||
}
|
||||
2689
app/Filament/Admin/Pages/Security.php
Normal file
2689
app/Filament/Admin/Pages/Security.php
Normal file
File diff suppressed because it is too large
Load Diff
1482
app/Filament/Admin/Pages/ServerSettings.php
Normal file
1482
app/Filament/Admin/Pages/ServerSettings.php
Normal file
File diff suppressed because it is too large
Load Diff
363
app/Filament/Admin/Pages/ServerStatus.php
Normal file
363
app/Filament/Admin/Pages/ServerStatus.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
318
app/Filament/Admin/Pages/ServerUpdates.php
Normal file
318
app/Filament/Admin/Pages/ServerUpdates.php
Normal 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.'));
|
||||
}
|
||||
}
|
||||
328
app/Filament/Admin/Pages/Services.php
Normal file
328
app/Filament/Admin/Pages/Services.php
Normal 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;
|
||||
}
|
||||
}
|
||||
589
app/Filament/Admin/Pages/SslManager.php
Normal file
589
app/Filament/Admin/Pages/SslManager.php
Normal 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')),
|
||||
];
|
||||
}
|
||||
}
|
||||
689
app/Filament/Admin/Pages/Waf.php
Normal file
689
app/Filament/Admin/Pages/Waf.php
Normal 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(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
1267
app/Filament/Admin/Pages/WhmMigration.php
Normal file
1267
app/Filament/Admin/Pages/WhmMigration.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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.'));
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
108
app/Filament/Admin/Resources/Users/Pages/CreateUser.php
Normal file
108
app/Filament/Admin/Resources/Users/Pages/CreateUser.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
140
app/Filament/Admin/Resources/Users/Pages/EditUser.php
Normal file
140
app/Filament/Admin/Resources/Users/Pages/EditUser.php
Normal 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'));
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
19
app/Filament/Admin/Resources/Users/Pages/ListUsers.php
Normal file
19
app/Filament/Admin/Resources/Users/Pages/ListUsers.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
178
app/Filament/Admin/Resources/Users/Schemas/UserForm.php
Normal file
178
app/Filament/Admin/Resources/Users/Schemas/UserForm.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
225
app/Filament/Admin/Resources/Users/Tables/UsersTable.php
Normal file
225
app/Filament/Admin/Resources/Users/Tables/UsersTable.php
Normal 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);
|
||||
}
|
||||
}
|
||||
65
app/Filament/Admin/Resources/Users/UserResource.php
Normal file
65
app/Filament/Admin/Resources/Users/UserResource.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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.'));
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
51
app/Filament/Admin/Widgets/AdminStatsOverview.php
Normal file
51
app/Filament/Admin/Widgets/AdminStatsOverview.php
Normal 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')),
|
||||
];
|
||||
}
|
||||
}
|
||||
68
app/Filament/Admin/Widgets/Dashboard/RecentActivityTable.php
Normal file
68
app/Filament/Admin/Widgets/Dashboard/RecentActivityTable.php
Normal 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();
|
||||
}
|
||||
}
|
||||
54
app/Filament/Admin/Widgets/DashboardStatsWidget.php
Normal file
54
app/Filament/Admin/Widgets/DashboardStatsWidget.php
Normal 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',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
25
app/Filament/Admin/Widgets/DiskUsageWidget.php
Normal file
25
app/Filament/Admin/Widgets/DiskUsageWidget.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
92
app/Filament/Admin/Widgets/DnsPendingAddsTable.php
Normal file
92
app/Filament/Admin/Widgets/DnsPendingAddsTable.php
Normal 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();
|
||||
}
|
||||
}
|
||||
321
app/Filament/Admin/Widgets/DomainIpAssignmentsTable.php
Normal file
321
app/Filament/Admin/Widgets/DomainIpAssignmentsTable.php
Normal 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();
|
||||
}
|
||||
}
|
||||
51
app/Filament/Admin/Widgets/MemoryWidget.php
Normal file
51
app/Filament/Admin/Widgets/MemoryWidget.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
88
app/Filament/Admin/Widgets/NetworkTableWidget.php
Normal file
88
app/Filament/Admin/Widgets/NetworkTableWidget.php
Normal 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();
|
||||
}
|
||||
}
|
||||
25
app/Filament/Admin/Widgets/ProcessesWidget.php
Normal file
25
app/Filament/Admin/Widgets/ProcessesWidget.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
67
app/Filament/Admin/Widgets/QuickActions.php
Normal file
67
app/Filament/Admin/Widgets/QuickActions.php
Normal 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'),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
83
app/Filament/Admin/Widgets/Security/AuditLogsTable.php
Normal file
83
app/Filament/Admin/Widgets/Security/AuditLogsTable.php
Normal 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();
|
||||
}
|
||||
}
|
||||
134
app/Filament/Admin/Widgets/Security/BannedIpsTable.php
Normal file
134
app/Filament/Admin/Widgets/Security/BannedIpsTable.php
Normal 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();
|
||||
}
|
||||
}
|
||||
70
app/Filament/Admin/Widgets/Security/Fail2banLogsTable.php
Normal file
70
app/Filament/Admin/Widgets/Security/Fail2banLogsTable.php
Normal 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();
|
||||
}
|
||||
}
|
||||
121
app/Filament/Admin/Widgets/Security/JailsTable.php
Normal file
121
app/Filament/Admin/Widgets/Security/JailsTable.php
Normal 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();
|
||||
}
|
||||
}
|
||||
66
app/Filament/Admin/Widgets/Security/LynisResultsTable.php
Normal file
66
app/Filament/Admin/Widgets/Security/LynisResultsTable.php
Normal 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();
|
||||
}
|
||||
}
|
||||
66
app/Filament/Admin/Widgets/Security/NiktoResultsTable.php
Normal file
66
app/Filament/Admin/Widgets/Security/NiktoResultsTable.php
Normal 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
Reference in New Issue
Block a user