commit 27a9dfa84dc12d307bf93a10ba40f486ee3b050d Author: codex Date: Mon Feb 2 03:11:45 2026 +0200 Initial commit diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a186cd2 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..07423d5 --- /dev/null +++ b/.env.example @@ -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}" diff --git a/.git-authorized-committers b/.git-authorized-committers new file mode 100644 index 0000000..aa22fb9 --- /dev/null +++ b/.git-authorized-committers @@ -0,0 +1 @@ +admin@jabali.lan diff --git a/.git-authorized-remotes b/.git-authorized-remotes new file mode 100644 index 0000000..9648684 --- /dev/null +++ b/.git-authorized-remotes @@ -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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fcb21d3 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..5f07f27 --- /dev/null +++ b/.githooks/pre-commit @@ -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!" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..699f948 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..4f60375 --- /dev/null +++ b/.mcp.json @@ -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" + ] + } + } +} \ No newline at end of file diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 0000000..d6430c9 --- /dev/null +++ b/AGENT.md @@ -0,0 +1,2019 @@ +# Jabali Web Hosting Panel + +A modern web hosting control panel built with Laravel 12, Filament v5, and Livewire 4. + +## Installation Requirements + +**Critical for mail server functionality:** +- Fresh Debian 12/13 installation (no existing web/mail software) +- Domain with glue records pointing to server IP (`ns1.domain.com` → IP) +- PTR record (reverse DNS) pointing to mail hostname (`IP` → `mail.domain.com`) +- Port 25 open (check with VPS provider) + +See README.md "Prerequisites" section for detailed DNS setup instructions. + +### Subdomain Installation + +The panel can be installed on a subdomain (e.g., `panel.example.com`). The installer automatically: + +- Extracts the root domain (`example.com`) for DNS zone creation +- Sets nameservers as `ns1.example.com` and `ns2.example.com` +- Creates an A record for the subdomain (`panel` → server IP) +- Configures email at the root domain (`webmaster@example.com`) + +**Example:** Installing on `panel.example.com` +``` +DNS Zone: example.com +NS Records: ns1.example.com, ns2.example.com +A Records: @, www, panel, mail, ns1, ns2 → server IP +Email: webmaster@example.com +``` + +## Database + +The panel uses **SQLite** by default (not MySQL). The database file is located at: +``` +/var/www/jabali/database/database.sqlite +``` + +## Quick Reference + +```bash +# IMPORTANT: All artisan commands must be run from /var/www/jabali/ +cd /var/www/jabali + +# Development +composer dev # Start all dev servers (artisan, queue, pail, vite) +composer test # Run PHPUnit tests +./vendor/bin/pint # Format PHP code +php artisan serve # Web server only +php artisan tinker # Interactive REPL + +# Production +php artisan migrate # Run migrations +php artisan config:cache # Cache configuration +php artisan route:cache # Cache routes +``` + +## Git Workflow + +**Important:** Only push to git when explicitly requested by the user. Do not auto-push after commits. + +### Version Numbers + +**IMPORTANT:** Before every push, bump the `VERSION` in the `VERSION` file: + +```bash +# VERSION file format: +VERSION=0.9-rc + +# Bump before pushing: +# 0.9-rc → 0.9-rc1 → 0.9-rc2 → 0.9-rc3 → ... +``` + +| Field | When to Bump | Format | +|-------|--------------|--------| +| `VERSION` | Every push | `0.9-rc`, `0.9-rc1`, `0.9-rc2`, ... | + +## Project Structure + +``` +/var/www/jabali/ +├── app/ +│ ├── Filament/ +│ │ ├── Admin/ # Admin panel (route: /admin) +│ │ │ ├── Pages/ # Admin pages (Dashboard, Services, etc.) +│ │ │ ├── Resources/# Admin resources (Users) +│ │ │ └── Widgets/ # Admin widgets (Stats, Disk, Network) +│ │ └── Jabali/ # User panel (route: /panel) +│ │ ├── Pages/ # User pages (Domains, Email, WordPress, etc.) +│ │ └── Widgets/ # User widgets (Stats, Disk, Domains) +│ ├── Models/ # Eloquent models (22 models) +│ ├── Services/ # Business logic services +│ └── Console/Commands/ # Artisan commands +├── bin/ +│ ├── jabali-agent # Privileged operations daemon (runs as root) +│ └── screenshot # Chromium screenshot capture script +├── config/ # Laravel config files +│ └── languages.php # Supported languages configuration +├── database/migrations/ # Database migrations +├── lang/ # Translation files (JSON) +│ ├── en.json # English (base) +│ ├── es.json # Spanish +│ ├── fr.json # French +│ ├── ru.json # Russian +│ ├── pt.json # Portuguese +│ ├── ar.json # Arabic (RTL) +│ └── he.json # Hebrew (RTL) +└── resources/views/filament/ # Blade templates for Filament pages +``` + +## Architecture + +### Two Panels +- **Admin Panel** (`/admin`): Server-wide management, user administration +- **User Panel** (`/panel`): Per-user domain, email, database management + +### Privileged Agent +The `bin/jabali-agent` daemon runs as root and handles operations requiring elevated privileges: +- System user creation/deletion +- Domain/vhost configuration +- Email (Postfix/Dovecot) management +- SSL certificate operations +- Database operations +- Backup operations + +Communication via Unix socket at `/var/run/jabali/agent.sock`. + +### Key Services +- **AgentClient**: PHP client for communicating with jabali-agent +- **AdminNotificationService**: System notifications (SSL, backups, quotas) + +### Self-Healing Services +The `bin/jabali-health-monitor` daemon automatically monitors and restarts critical services when they fail. + +**Monitored Services:** +- nginx, mariadb, jabali-agent, php-fpm +- postfix, dovecot, named (if installed) +- redis-server, fail2ban (if installed) + +**Features:** +- Checks services every 30 seconds +- Automatic restart on failure (up to 3 attempts) +- Email notifications via `AdminNotificationService` +- Systemd restart policies as backup protection + +**Files:** +| File | Purpose | +|------|---------| +| `bin/jabali-health-monitor` | Health monitoring daemon | +| `/etc/systemd/system/jabali-health-monitor.service` | Systemd service unit | +| `/var/log/jabali/health-monitor.log` | Event log | +| `/var/run/jabali/health-monitor.state` | Service state tracking | + +**Commands:** +```bash +# Check status +systemctl status jabali-health-monitor + +# View logs +journalctl -u jabali-health-monitor -f + +# Manual notification test +php artisan notify:service-health down nginx --description="Web Server" +``` + +**Notification Setting:** `notify_service_health` in dns_settings table (Admin > Server Settings) + +### Admin Notifications & Monitoring + +The system sends email notifications to configured admin recipients for various events. + +**Configuration:** Admin > Server Settings > Notifications tab + +**Notification Types:** +| Type | Setting | Description | +|------|---------|-------------| +| SSL Errors | `notify_ssl_errors` | Certificate errors and expiration warnings | +| Backup Failures | `notify_backup_failures` | Failed scheduled backups | +| Disk Quota | `notify_disk_quota` | Users reaching 90% quota | +| Login Failures | `notify_login_failures` | Brute force and Fail2ban alerts | +| SSH Logins | `notify_ssh_logins` | Successful SSH login alerts | +| System Updates | `notify_system_updates` | Panel update availability | +| Service Health | `notify_service_health` | Service failures and auto-restarts | +| High Load | `notify_high_load` | Server load exceeds threshold | + +**Notification Log:** +All sent notifications are logged in `notification_logs` table and viewable in Admin > Server Settings > Notifications tab. + +**High Load Monitoring:** +- Monitors server load average every minute via `jabali-health-monitor` +- Configurable threshold (default: 5.0) and duration (default: 5 minutes) +- Sends alert when load exceeds threshold for configured duration +- Settings: `load_threshold`, `load_alert_minutes` in dns_settings + +**Commands:** +```bash +# Manual high load notification test +php artisan notify:high-load + +# Test email +# Use "Send Test Email" button in Server Settings > Notifications +``` + +### Backup System + +The panel provides comprehensive backup functionality for both users and administrators. + +**Backup Types:** +| Type | Description | Storage | +|------|-------------|---------| +| User Backup | Single user's domains, databases, mailboxes | `/home/{user}/backups/` | +| Server Backup (Full) | All users as tar.gz archive | `/var/backups/jabali/` | +| Server Backup (Incremental) | Rsync to remote destination | Remote SFTP/NFS | + +**Admin Backup Features:** +- **Create Server Backup**: Backup all users or selected users +- **Restore Backup**: Selective restore with modal UI +- **Download Backup**: Download local backups (creates zip for directories) + +**Restore Options:** +| Option | Description | +|--------|-------------| +| Website Files | Restore domain files to `/home/{user}/domains/` | +| Databases | Restore MySQL databases with security sanitization | +| MySQL Users | Restore MySQL users and their permissions | +| Mailboxes | Restore email mailboxes and messages | +| SSL Certificates | Restore SSL certificates for domains | +| DNS Zones | Restore DNS zone files | + +**Backup Contents:** +``` +backup_folder/ +├── manifest.json # Backup metadata +├── {username}.tar.gz # Per-user archive containing: +│ ├── files/ # Domain files +│ │ └── {domain}/ +│ ├── mysql/ # Database dumps +│ │ ├── {database}.sql.gz +│ │ └── users.sql # MySQL users and grants +│ ├── mail/ # Mailbox data +│ │ └── {domain}/{user}/ +│ ├── ssl/ # SSL certificates +│ │ └── {domain}/ +│ └── dns/ # DNS zone files +│ └── {domain}.zone +``` + +**Security Validations (Restore):** +The backup restore process includes comprehensive security checks to prevent privilege escalation: + +| Check | Description | +|-------|-------------| +| Database Prefix | Only restore databases matching user's prefix (`{username}_*`) | +| MySQL Users | Only restore users with correct prefix, block global grants | +| SQL Sanitization | Remove DEFINER, GRANT, SET GLOBAL from dumps | +| Domain Ownership | Verify user owns domains before restoring files/SSL/DNS | +| Symlink Prevention | Remove dangerous symlinks pointing outside backup | +| Path Traversal | Block `..` sequences in paths | +| DNS Validation | Validate zone files with `named-checkzone` | + +**Agent Actions:** +``` +backup.create - Create user backup +backup.restore - Restore backup with selective options +backup.get_info - Get backup manifest/contents +backup.create_server - Create server-wide backup +backup.delete - Delete backup file +backup.upload_remote - Upload backup to remote destination +backup.download_remote - Download backup from remote +backup.incremental - Create incremental backup via rsync +backup.test_destination - Test remote backup destination +``` + +**Download Route:** +``` +GET /jabali-admin/backup-download?id={backup_id} # Admin (requires is_admin) +GET /jabali-panel/backup-download?path={base64} # User (validates ownership) +``` + +**Files:** +| File | Purpose | +|------|---------| +| `app/Filament/Admin/Pages/Backups.php` | Admin backup management UI | +| `app/Filament/Jabali/Pages/Backups.php` | User backup management UI | +| `app/Http/Controllers/BackupDownloadController.php` | Download handlers | +| `bin/jabali-agent` | Backup/restore implementation | +| `/var/log/jabali/agent.log` | Security audit log for blocked operations | + +### DNSSEC Support + +DNSSEC (Domain Name System Security Extensions) adds cryptographic signatures to DNS records. + +**Management:** Admin > Server Settings > DNS tab > DNSSEC section + +**Features:** +- Enable/disable DNSSEC per domain +- Automatic KSK (Key Signing Key) and ZSK (Zone Signing Key) generation +- Uses ECDSAP256SHA256 algorithm +- Auto-generates DS records for registrar configuration +- Inline zone signing with auto-dnssec maintain + +**Agent Actions:** +``` +dns.enable_dnssec - Generate keys and sign zone +dns.disable_dnssec - Remove keys and unsign zone +dns.get_dnssec_status - Check DNSSEC status and keys +dns.get_ds_records - Get DS records for registrar +``` + +**Files:** +| Path | Description | +|------|-------------| +| `/etc/bind/keys/{domain}/` | DNSSEC keys directory | +| `/etc/bind/zones/db.{domain}` | Unsigned zone file | +| `/etc/bind/zones/db.{domain}.signed` | Signed zone file | + +**Setup Process:** +1. Enable DNSSEC for domain in Server Settings +2. Copy DS record from modal +3. Add DS record to domain registrar +4. Wait for DNS propagation (up to 48 hours) + +### Test Credentials +| Panel | URL | Email | Password | +|-------|-----|-------|----------| +| Admin | `https://jabali.lan/jabali-admin` | `admin@jabali.lan` | `q1w2E#R$` | +| User | `https://jabali.lan/jabali-panel` | `user@jabali.lan` | `wjqr9t6Z#%r&@C$4` | + +## Models + +| Model | Table | Description | +|-------|-------|-------------| +| User | users | Panel users (system users) | +| Domain | domains | Hosted domains | +| EmailDomain | email_domains | Email-enabled domains | +| Mailbox | mailboxes | Email mailboxes | +| EmailForwarder | email_forwarders | Email forwarding rules | +| DnsRecord | dns_records | DNS zone records | +| DnsSetting | dns_settings | Key-value settings store | +| SslCertificate | ssl_certificates | SSL/TLS certificates | +| MysqlCredential | mysql_credentials | Database credentials | +| Backup | backups | User backups | +| BackupSchedule | backup_schedules | Scheduled backups | +| BackupDestination | backup_destinations | Remote backup targets | +| CronJob | cron_jobs | User cron jobs | +| AuditLog | audit_logs | Admin audit trail | +| NotificationLog | notification_logs | Admin notification history | + +## Filament Pages + +### Admin Panel +- Dashboard, Services, ServerStatus, ServerSettings +- SslManager, PhpManager, EmailSettings, DnsZones +- Backups, AuditLogs, Fail2ban, ClamAV, Security, ServerImports + +### User Panel +- Dashboard, Domains, DnsRecords, Files +- Email, WordPress, Databases, Ssl +- Backups, CronJobs, SshKeys, PhpSettings, Logs + +## Dashboard Configurations + +### Admin Dashboard (`/jabali-admin`) + +**Location:** `App\Filament\Admin\Pages\Dashboard` + +**Header Widgets:** +- `DashboardStatsWidget` - Stats cards showing Users, Domains, Mailboxes, Databases, SSL Certificates + - Uses `` components with icon, value (bold), label + - Responsive: 1 col mobile, 2 cols tablet, 5 cols desktop + +**Schema Components:** +- `RecentActivityTable` - Embedded audit log table via `EmbeddedTable::make()` + +**Header Actions:** +- Refresh - Reloads the page +- Setup Wizard - Onboarding modal (visible until completed) +- Take Tour - Starts the admin panel tour + +**Files:** +| File | Purpose | +|------|---------| +| `app/Filament/Admin/Pages/Dashboard.php` | Dashboard page class | +| `app/Filament/Admin/Widgets/DashboardStatsWidget.php` | Stats widget | +| `resources/views/filament/admin/widgets/dashboard-stats.blade.php` | Stats template | +| `app/Filament/Admin/Widgets/Dashboard/RecentActivityTable.php` | Activity table widget | + +### User Dashboard (`/jabali-panel`) + +**Location:** `App\Filament\Jabali\Pages\Dashboard` + +**Widgets (in order):** +1. `StatsOverview` - Stats cards showing Domains, Mailboxes, Databases, SSL Certificates + - Responsive: 1 col mobile, 2 cols tablet, 4 cols desktop +2. `DiskUsageWidget` - Disk quota visualization with progress bar +3. `DomainsWidget` - Recent domains table with SSL status +4. `MailboxesWidget` - Recent mailboxes table +5. `RecentBackupsWidget` - Latest backups table + +**Layout:** 2-column responsive grid (1 col mobile, 2 cols desktop) + +**Subheading:** Personalized welcome message ("Welcome back, {name}!") + +**Files:** +| File | Purpose | +|------|---------| +| `app/Filament/Jabali/Pages/Dashboard.php` | Dashboard page class | +| `app/Filament/Jabali/Widgets/StatsOverview.php` | Stats widget | +| `resources/views/filament/jabali/widgets/stats-overview.blade.php` | Stats template | +| `app/Filament/Jabali/Widgets/DiskUsageWidget.php` | Disk usage widget | +| `app/Filament/Jabali/Widgets/DomainsWidget.php` | Domains table widget | +| `app/Filament/Jabali/Widgets/MailboxesWidget.php` | Mailboxes table widget | +| `app/Filament/Jabali/Widgets/RecentBackupsWidget.php` | Backups table widget | + +## Agent Actions + +The jabali-agent supports these action categories: + +``` +user.* - System user management +domain.* - Domain/vhost operations +wp.* - WordPress installation/management +email.* - Email domain/mailbox operations +mysql.* - Database operations +dns.* - DNS zone management (includes DNSSEC) +php.* - PHP version management +ssl.* - SSL certificate operations +backup.* - Backup/restore operations (see Backup System section) +service.* - System service control +ufw.* - Firewall management +file.* - File operations +ssh.* - SSH key management +cron.* - Cron job management +quota.* - Disk quota management +clamav.* - Antivirus scanning +metrics.* - System metrics +scanner.* - Security scanning (Lynis, Nikto) +logs.* - Log access +redis.* - Redis user management +``` + +**Detailed Action Reference:** + +| Action | Parameters | Description | +|--------|------------|-------------| +| `dns.enable_dnssec` | `domain` | Generate keys and sign zone | +| `dns.disable_dnssec` | `domain` | Remove keys and unsign zone | +| `dns.get_dnssec_status` | `domain` | Check DNSSEC status and keys | +| `dns.get_ds_records` | `domain` | Get DS records for registrar | +| `backup.create` | `username`, `options` | Create user backup | +| `backup.restore` | `username`, `backup_path`, `restore_*` | Restore with selective options | +| `backup.get_info` | `backup_path` | Get backup manifest | +| `backup.create_server` | `path`, `options` | Create server backup | +| `backup.delete` | `path` | Delete backup | +| `backup.upload_remote` | `local_path`, `config` | Upload to remote | +| `backup.download_remote` | `remote_path`, `local_path`, `config` | Download from remote | +| `backup.incremental` | `config`, `options` | Incremental rsync backup | +| `backup.test_destination` | `config` | Test remote destination | +| `backup.delete_remote` | `remote_path`, `config` | Delete backup from remote | + +## Backup System + +The backup system supports both user-level and server-wide backups with local and remote storage. + +### Backup Types + +| Type | Description | Storage | +|------|-------------|---------| +| **Full (tar.gz)** | Complete archive of all data | Local or remote | +| **Incremental (rsync)** | Space-efficient with hard links | Remote only (SFTP/NFS) | + +### Remote Destinations + +Supported destination types: +- **SFTP** - SSH-based file transfer to remote servers +- **NFS** - Network File System mounts +- **S3** - S3-compatible object storage (AWS, MinIO, etc.) + +### Backup Schedules & Retention + +Scheduled backups run via the `backups:run-schedules` artisan command (called by cron). Each schedule has: +- **Frequency**: Hourly, daily, weekly, or monthly +- **Retention Count**: Number of backups to keep (default: 7) +- **Destination**: Local or remote storage + +**Retention Policy:** +- Applied automatically after each backup completes +- Deletes oldest backups beyond the retention count +- Works for both local files and remote destinations +- Implemented in `App\Jobs\RunServerBackup::applyRetention()` + +**Important:** The queue worker must be running for scheduled backups and retention: +```bash +systemctl status jabali-queue # Check status +systemctl restart jabali-queue # Restart to pick up code changes +``` + +### Backup Files + +| Path | Description | +|------|-------------| +| `/var/backups/jabali/` | Default server backup location | +| `/home/{user}/backups/` | User backup location | +| `metadata/panel_data.json` | Panel database records (domains, mailboxes, etc.) | +| `mysql/` | Database dumps (.sql.gz) | +| `zones/` | DNS zone files | +| `ssl/` | SSL certificates | +| `mail/` | Mailbox data | + +### Related Models + +- `Backup` - Individual backup records +- `BackupSchedule` - Schedule configuration with retention settings +- `BackupDestination` - Remote storage configuration + +### Related Jobs + +- `RunServerBackup` - Executes backup and applies retention +- `IndexRemoteBackups` - Indexes backups on remote destinations + +## Security Features + +### Security Page (Admin) + +Located at `/jabali-admin/security`, provides: +- **Overview** - System security status and recent audit logs +- **Firewall** - UFW rules management +- **Fail2ban** - Intrusion prevention with jail management +- **Antivirus** - ClamAV scanning +- **SSH** - SSH hardening settings +- **Vulnerability Scanner** - Security scanning tools + +### Security Scanning Tools + +| Tool | Purpose | Installation | +|------|---------|--------------| +| **Lynis** | System security auditing | Pre-installed | +| **Nikto** | Web server vulnerability scanner | `/opt/nikto` (GitHub clone) | +| **WPScan** | WordPress vulnerability scanner | Ruby gem | + +**Nikto Configuration:** +- Installed from GitHub at `/opt/nikto` +- Symlinked to `/usr/local/bin/nikto` +- Called with full path in timeout commands (PATH restriction) + +**WPScan Configuration:** +- Cache directory: `/var/www/.wpscan` (owned by www-data) +- Run with `HOME=/var/www` environment variable +- Version displayed without ASCII banner using `grep -i 'version'` + +### Audit Logs + +All administrative actions are logged to the `audit_logs` table. + +**Logged Events:** +- Authentication (login, logout, login_failed) +- CRUD operations on domains, mailboxes, users +- System configuration changes +- Security-related actions + +**Audit Log Retention:** +- Configured via `audit_log_retention_days` setting (default: 90 days) +- Pruned daily at 2:00 AM via scheduled task +- Method: `AuditLog::prune()` + +**Viewing Logs:** +- Admin Panel: Security page → Overview tab (paginated table) +- Direct page: `/jabali-admin/audit-logs` (hidden from sidebar) + +### Related Files + +| File | Description | +|------|-------------| +| `app/Models/AuditLog.php` | Audit log model with prune method | +| `app/Filament/Admin/Pages/Security.php` | Security dashboard | +| `app/Filament/Admin/Widgets/Security/AuditLogsTable.php` | Audit logs table widget | +| `routes/console.php` | Scheduled audit log pruning | + +## Code Style + +- PHP 8.2+ with strict types +- Follow Laravel conventions +- Use `DnsSetting::get()` / `DnsSetting::set()` for key-value storage +- All privileged operations go through jabali-agent + +## Filament v4 UI Guidelines + +**CRITICAL: Build using ONLY Filament v4 native components and styles with proper spacing. Always use pure Filament native components with proper icons and colors.** + +### Architecture Guidelines + +1. **Use Filament Resources for Eloquent Models**: When data is backed by an Eloquent model, prefer creating a Filament Resource (`php artisan make:filament-resource`) over custom pages. + +2. **Use Tables for List Data**: When displaying list data (arrays or collections), ALWAYS use proper Filament tables via `EmbeddedTable::make()` or the `HasTable` trait. + +3. **Use Schema-based Pages for Complex UIs**: For pages with multiple sections, tabs, and mixed content (forms + tables + stats), use Schema-based pages with embedded table widgets. + +4. **Prefer Existing Patterns**: Look at existing pages in the codebase (e.g., `Security.php`, `DnsRecords.php`, `SshKeys.php`) for examples of how to implement similar features. + +### Absolute Rules (NO EXCEPTIONS) +- **NO custom HTML** - Never use raw `
`, ``, `

`, `

    ` etc. in Filament pages +- **NO inline styles** - Never use `style="..."` attributes +- **NO custom CSS** - Never create CSS files for Filament components +- **NO custom blade templates** - Don't create View components with custom HTML for UI elements +- **USE Filament components** - Sections, badges, buttons, tables, infolists, stats widgets, grids +- **USE proper icons** - Always use `->icon('heroicon-o-*')` on sections and actions +- **USE proper colors** - Always use `->iconColor('success'|'danger'|'warning'|'gray')` for status indication +- **USE Tailwind classes** - Only when absolutely necessary for minor adjustments +- **MUST be responsive** - All pages must work on mobile, tablet, and desktop + +### Allowed Components +Use these Filament native components exclusively: + +| Category | Components | +|----------|------------| +| Layout | `Section::make()`, `Grid::make()`, `Group::make()`, `Tabs::make()` | +| Display | `Text::make()`, ``, `` | +| Actions | `Actions::make()`, `Action::make()`, `` | +| Forms | TextInput, Select, Toggle, FileUpload, Checkbox, etc. | +| Data | Tables, Infolists, Stats Widgets, `EmbeddedTable::make()` | +| Feedback | ``, Notifications | + +### Tables with Array Data (IMPORTANT) + +When displaying list data, **ALWAYS use proper Filament tables**, not Sections or custom HTML. Use `EmbeddedTable::make()` to embed table widgets within Schema-based pages. + +**Creating a Self-Refreshing Table Widget:** + +For tables with actions that modify data (enable/disable, delete, etc.), the widget should reload its own data directly rather than dispatching events to the parent (which causes full page re-renders). + +```php +send('some.action', []); + if ($result['success'] ?? false) { + $this->items = $result['items'] ?? []; + $this->resetTable(); // Force table to re-render with new data + } + } 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->items) + ->columns([ + TextColumn::make('name')->label(__('Name'))->searchable(), + IconColumn::make('enabled') + ->label(__('Status')) + ->boolean() + ->trueIcon('heroicon-o-check-circle') + ->falseIcon('heroicon-o-x-circle') + ->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') + ->action(function (array $record): void { + try { + $agent = new AgentClient(); + $result = $agent->send('item.toggle', ['id' => $record['id']]); + + if ($result['success'] ?? false) { + Notification::make() + ->title(__('Status updated')) + ->success() + ->send(); + + // Reload data directly - don't dispatch events to parent + $this->reloadData(); + } else { + throw new \Exception($result['error'] ?? __('Operation failed')); + } + } catch (\Exception $e) { + Notification::make() + ->title(__('Error')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + }), + ]) + ->striped() + ->emptyStateHeading(__('No items')) + ->emptyStateIcon('heroicon-o-inbox'); + } + + public function render() + { + return $this->getTable()->render(); + } +} +``` + +**Embedding Table in Schema:** +```php +use Filament\Schemas\Components\EmbeddedTable; +use App\Filament\Admin\Widgets\Security\MyDataTable; + +// In your page's schema +Section::make(__('Data List')) + ->icon('heroicon-o-list-bullet') + ->schema([ + EmbeddedTable::make(MyDataTable::class, ['items' => $this->items]), + ]) +``` + +**Key Points:** +- Implement `HasTable`, `HasSchemas`, and `HasActions` interfaces +- Use `InteractsWithTable`, `InteractsWithSchemas`, and `InteractsWithActions` traits +- Use `->records(fn () => $this->arrayData)` for array/collection data +- Use `->query(Model::query())` for Eloquent models +- Use `->actions([])` for row actions on array-based tables +- Always implement `makeFilamentTranslatableContentDriver()` returning null +- Return `$this->getTable()->render()` in render method (NOT `view('filament-tables::index')`) +- Import actions from `Filament\Actions\Action` (NOT `Filament\Tables\Actions\Action`) +- **After modifying data, call `$this->resetTable()` to force the table to re-render** +- **Reload data directly in the widget rather than dispatching events to parent** (avoids full page re-renders) + +### Section with Icons and Colors +Always use Section's native icon and color support: +```php +use Filament\Schemas\Components\Section; + +// Status card with icon and color +Section::make(__('Active')) + ->description(__('Firewall')) + ->icon('heroicon-o-shield-check') + ->iconColor('success') // success, danger, warning, gray + +// Section with header actions +Section::make(__('Settings')) + ->icon('heroicon-o-cog') + ->headerActions([ + Action::make('save')->label(__('Save')), + ]) + ->schema([...]) +``` + +### Responsive Grid Layout +Use Grid with responsive column configuration: +```php +use Filament\Schemas\Components\Grid; +use Filament\Schemas\Components\Section; + +// 3-column responsive grid for stat cards +Grid::make(['default' => 1, 'sm' => 3]) + ->schema([ + Section::make(__('Active')) + ->description(__('Firewall')) + ->icon('heroicon-o-shield-check') + ->iconColor('success'), + Section::make('0') + ->description(__('IPs Banned')) + ->icon('heroicon-o-lock-closed') + ->iconColor('success'), + Section::make('0') + ->description(__('Threats')) + ->icon('heroicon-o-bug-ant') + ->iconColor('gray'), + ]) +``` + +### Stats Overview Widget Pattern + +For dashboard stats (domains count, mailboxes count, etc.), use a custom Widget with `` components in a blade template. + +**Widget class:** +```php + $userCount, + 'label' => __('Users'), + 'icon' => 'heroicon-o-users', + 'color' => 'primary', + ], + [ + 'value' => $domainCount, + 'label' => __('Domains'), + 'icon' => 'heroicon-o-globe-alt', + 'color' => 'success', + ], + // ... more stats + ]; + } +} +``` + +**Blade template** (`resources/views/filament/{panel}/widgets/dashboard-stats.blade.php`): +```blade + + +
    + @foreach($this->getStats() as $stat) + + + {{ $stat['value'] }} + + {{ $stat['label'] }} + + @endforeach +
    +
    +``` + +**Key points:** +- Use custom Widget extending `Filament\Widgets\Widget` with a blade view +- Use `` for each stat card with icon and icon-color +- Value goes in `heading` slot with bold styling, label goes in `description` slot +- Use CSS media queries in `'; + } + + return ''; + } + + protected function getAdminBrandName(): string + { + $brandName = DnsSetting::get('panel_name', 'Jabali'); + $cleaned = trim((string) preg_replace('/\\s*Admin\\s*$/i', '', $brandName)); + + return $cleaned !== '' ? $cleaned : 'Jabali'; + } + + protected function getOpenGraphTags(string $title, string $description): string + { + $url = url()->current(); + $image = asset('images/og-image.png'); + $siteName = DnsSetting::get('panel_name', 'Jabali'); + + return << + + + + + + + + + + HTML; + } + + protected function getLoginWordCloud(): string + { + // "Administrator" in panel supported languages (from lang/*.json) + $words = [ + 'Administrator', // English + 'Administrador', // Spanish & Portuguese + 'Administrateur', // French + 'Администратор', // Russian + 'מנהל', // Hebrew + 'مشرف', // Arabic + ]; + + // Generate rows with randomized words for varied pattern + $rows = ''; + for ($row = 0; $row < 50; $row++) { + $rowContent = ''; + $shuffled = $words; + shuffle($shuffled); + for ($col = 0; $col < 20; $col++) { + $word = $shuffled[$col % count($shuffled)]; + if ($col % 3 === 0) { + shuffle($shuffled); + } // Re-shuffle periodically + $rowContent .= $word.' · '; + } + $rows .= "
    {$rowContent}
    "; + } + + return << + .word-pattern-container { + position: fixed; + top: 50%; + left: 50%; + width: 300vw; + height: 300vh; + overflow: visible; + pointer-events: none; + z-index: -1; + transform: translate(-50%, -50%) rotate(-25deg); + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + opacity: 0.08; + } + .pattern-row { + white-space: nowrap; + font-size: 16px; + font-weight: 700; + color: #dc2626; + font-family: "Segoe UI", Arial, "Noto Sans", "Noto Sans Arabic", "Noto Sans Hebrew", sans-serif; + text-transform: uppercase; + letter-spacing: 0.08em; + line-height: 1.5; + } + .pattern-row:nth-child(even) { + margin-left: 120px; + } + .fi-simple-layout { + position: relative; + z-index: 1; + } + .fi-simple-main { + position: relative; + z-index: 10; + } + .fi-simple-main-ctn { + position: relative; + z-index: 10; + } + +
    {$rows}
    + HTML; + } +} diff --git a/app/Providers/Filament/JabaliPanelProvider.php b/app/Providers/Filament/JabaliPanelProvider.php new file mode 100644 index 0000000..55cea84 --- /dev/null +++ b/app/Providers/Filament/JabaliPanelProvider.php @@ -0,0 +1,227 @@ +default() + ->id('jabali') + ->path('jabali-panel') + ->login(Login::class) + // ->registration() + ->passwordReset() + ->profile() + ->defaultAvatarProvider(InitialsAvatarProvider::class) + ->colors([ + 'primary' => Color::Blue, + ]) + ->darkMode() + ->brandName(fn () => DnsSetting::get('panel_name', 'Jabali')) + ->brandLogo(fn () => ($logo = DnsSetting::get('custom_logo')) ? asset('storage/'.$logo) : asset('images/jabali_logo.svg')) + ->darkModeBrandLogo(fn () => ($logo = DnsSetting::get('custom_logo')) ? asset('storage/'.$logo) : asset('images/jabali_logo_dark.svg')) + ->brandLogoHeight('2rem') + ->favicon(asset('favicon.ico')) + ->renderHook( + PanelsRenderHook::HEAD_END, + fn () => $this->getOpenGraphTags('Jabali Panel', 'Web hosting control panel - Manage your domains, emails, databases and more'). + \Illuminate\Support\Facades\Vite::useBuildDirectory('build')->withEntryPoints(['resources/css/app.css', 'resources/js/server-charts.js'])->toHtml(). +$this->getRtlScript() + ) + ->renderHook( + PanelsRenderHook::BODY_START, + fn () => (request()->routeIs('filament.jabali.auth.login') ? $this->getLoginWordCloud() : '').$this->renderImpersonationNotice() + ) + ->renderHook( + PanelsRenderHook::FOOTER, + fn () => view('vendor.filament-panels.components.footer') + ) + ->renderHook( + PanelsRenderHook::USER_MENU_BEFORE, + fn () => view('components.language-switcher') + ) + ->discoverResources(in: app_path('Filament/Jabali/Resources'), for: 'App\\Filament\\Jabali\\Resources') + ->discoverPages(in: app_path('Filament/Jabali/Pages'), for: 'App\\Filament\\Jabali\\Pages') + ->pages([]) + ->discoverWidgets(in: app_path('Filament/Jabali/Widgets'), for: 'App\\Filament\\Jabali\\Widgets') + ->widgets([ + AccountWidget::class, + ]) + ->middleware([ + EncryptCookies::class, + AddQueuedCookiesToResponse::class, + StartSession::class, + AuthenticateSession::class, + ShareErrorsFromSession::class, + VerifyCsrfToken::class, + SubstituteBindings::class, + DisableBladeIconComponents::class, + DispatchServingFilamentEvent::class, + SetLocale::class, + ]) + ->authMiddleware([ + Authenticate::class, + RedirectAdminFromUserPanel::class, + ]); + } + + protected function getRtlScript(): string + { + $locale = app()->getLocale(); + $direction = config("languages.supported.{$locale}.direction", 'ltr'); + + if ($direction === 'rtl') { + return ''; + } + + return ''; + } + + protected function renderImpersonationNotice(): string + { + if (! session()->has('impersonated_by')) { + return ''; + } + + $adminId = session()->get('impersonated_by'); + $admin = User::find($adminId); + $currentUser = auth()->user(); + + if (! $admin || ! $currentUser) { + return ''; + } + + $stopUrl = url('/impersonate/stop'); + + return << + + You are logged in as: {$currentUser->name} ({$currentUser->username}) + + Return to Admin + +
+ HTML; + } + + protected function getOpenGraphTags(string $title, string $description): string + { + $url = url()->current(); + $image = asset('images/og-image.png'); + $siteName = DnsSetting::get('panel_name', 'Jabali'); + + return << + + + + + + + + + + HTML; + } + + protected function getLoginWordCloud(): string + { + // "Client Dashboard" in panel supported languages (using lang/*.json Dashboard translations) + $words = [ + 'Client Dashboard', // English + 'Panel de Cliente', // Spanish + 'Painel de Controle Cliente', // Portuguese + 'Tableau de bord Client', // French + 'Панель управления Клиента', // Russian + 'לוח בקרה לקוח', // Hebrew + 'لوحة تحكم العميل', // Arabic + ]; + + // Generate rows with randomized words for varied pattern + $rows = ''; + for ($row = 0; $row < 50; $row++) { + $rowContent = ''; + $shuffled = $words; + shuffle($shuffled); + for ($col = 0; $col < 20; $col++) { + $word = $shuffled[$col % count($shuffled)]; + if ($col % 3 === 0) { + shuffle($shuffled); + } + $rowContent .= $word.' · '; + } + $rows .= "
{$rowContent}
"; + } + + return << + .word-pattern-container { + position: fixed; + top: 50%; + left: 50%; + width: 300vw; + height: 300vh; + overflow: visible; + pointer-events: none; + z-index: -1; + transform: translate(-50%, -50%) rotate(-25deg); + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + opacity: 0.08; + } + .pattern-row { + white-space: nowrap; + font-size: 16px; + font-weight: 700; + color: #2563eb; + font-family: "Segoe UI", Arial, "Noto Sans", "Noto Sans Arabic", "Noto Sans Hebrew", sans-serif; + text-transform: uppercase; + letter-spacing: 0.08em; + line-height: 1.5; + } + .pattern-row:nth-child(even) { + margin-left: 120px; + } + .fi-simple-layout { + position: relative; + z-index: 1; + } + .fi-simple-main { + position: relative; + z-index: 10; + } + .fi-simple-main-ctn { + position: relative; + z-index: 10; + } + +
{$rows}
+ HTML; + } +} diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php new file mode 100644 index 0000000..004ced4 --- /dev/null +++ b/app/Providers/FortifyServiceProvider.php @@ -0,0 +1,48 @@ +input(Fortify::username())).'|'.$request->ip()); + + return Limit::perMinute(5)->by($throttleKey); + }); + + RateLimiter::for('two-factor', function (Request $request) { + return Limit::perMinute(5)->by($request->session()->get('login.id')); + }); + } +} diff --git a/app/Providers/JetstreamServiceProvider.php b/app/Providers/JetstreamServiceProvider.php new file mode 100644 index 0000000..9139849 --- /dev/null +++ b/app/Providers/JetstreamServiceProvider.php @@ -0,0 +1,43 @@ +configurePermissions(); + + Jetstream::deleteUsersUsing(DeleteUser::class); + } + + /** + * Configure the permissions that are available within the application. + */ + protected function configurePermissions(): void + { + Jetstream::defaultApiTokenPermissions(['read']); + + Jetstream::permissions([ + 'create', + 'read', + 'update', + 'delete', + ]); + } +} diff --git a/app/Services/AdminNotificationService.php b/app/Services/AdminNotificationService.php new file mode 100644 index 0000000..16b830b --- /dev/null +++ b/app/Services/AdminNotificationService.php @@ -0,0 +1,185 @@ +format('Y-m-d H:i:s') . "\n"; + + if (!empty($context)) { + $fullMessage .= "\nDetails:\n"; + foreach ($context as $key => $value) { + $fullMessage .= "- {$key}: {$value}\n"; + } + } + + $sender = "webmaster@{$hostname}"; + + Mail::raw($fullMessage, function ($mail) use ($recipientList, $sender, $subject, $hostname) { + $mail->from($sender, "Jabali Panel ({$hostname})"); + $mail->to($recipientList); + $mail->subject("[Jabali] {$subject}"); + }); + + Log::info("AdminNotification sent: {$type} - {$subject}"); + self::logNotification($type, $subject, $message, $recipientList, 'sent', $context); + return true; + } catch (\Exception $e) { + Log::error("AdminNotification failed: {$e->getMessage()}"); + self::logNotification($type, $subject, $message, $recipientList, 'failed', $context, $e->getMessage()); + return false; + } + } + + /** + * Log a notification to the database. + */ + protected static function logNotification( + string $type, + string $subject, + string $message, + array $recipients, + string $status, + ?array $context = null, + ?string $error = null + ): void { + try { + NotificationLog::log($type, $subject, $message, $recipients, $status, $context, $error); + } catch (\Exception $e) { + // Don't let logging failures break the notification system + Log::error("Failed to log notification: {$e->getMessage()}"); + } + } + + public static function sslError(string $domain, string $error): bool + { + return self::send( + 'ssl_errors', + "SSL Certificate Error: {$domain}", + "An SSL certificate error occurred for domain: {$domain}", + ['Domain' => $domain, 'Error' => $error] + ); + } + + public static function sslExpiring(string $domain, int $daysUntilExpiry): bool + { + return self::send( + 'ssl_errors', + "SSL Certificate Expiring: {$domain}", + "The SSL certificate for {$domain} will expire in {$daysUntilExpiry} days.", + ['Domain' => $domain, 'Days Until Expiry' => $daysUntilExpiry] + ); + } + + public static function backupFailure(string $backupName, string $error): bool + { + return self::send( + 'backup_failures', + "Backup Failed: {$backupName}", + "A scheduled backup has failed.", + ['Backup Name' => $backupName, 'Error' => $error] + ); + } + + public static function backupSuccess(string $backupName, int $sizeBytes, ?string $destination = null): bool + { + $size = self::formatBytes($sizeBytes); + $context = [ + 'Backup Name' => $backupName, + 'Size' => $size, + ]; + if ($destination) { + $context['Destination'] = $destination; + } + + return self::send( + 'backup_success', + "Backup Completed: {$backupName}", + "A backup has completed successfully.", + $context + ); + } + + protected static function formatBytes(int $bytes, int $precision = 2): string + { + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $bytes = max($bytes, 0); + $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); + $pow = min($pow, count($units) - 1); + $bytes /= pow(1024, $pow); + return round($bytes, $precision) . ' ' . $units[$pow]; + } + + public static function diskQuotaWarning(string $username, int $usagePercent): bool + { + return self::send( + 'disk_quota', + "Disk Quota Warning: {$username}", + "User {$username} has reached {$usagePercent}% of their disk quota.", + ['Username' => $username, 'Usage' => "{$usagePercent}%"] + ); + } + + public static function loginFailure(string $ip, string $service, int $attempts): bool + { + return self::send( + 'login_failures', + "Login Failure Alert: {$ip}", + "Multiple failed login attempts detected.", + ['IP Address' => $ip, 'Service' => $service, 'Attempts' => $attempts] + ); + } + + public static function systemUpdatesAvailable(int $updateCount): bool + { + return self::send( + 'system_updates', + "System Updates Available", + "{$updateCount} system update(s) are available for your Jabali Panel.", + ['Available Updates' => $updateCount] + ); + } + + public static function sshLogin(string $username, string $ip, string $method = 'password'): bool + { + return self::send( + 'ssh_logins', + "SSH Login: {$username}", + "Successful SSH login detected.", + ['Username' => $username, 'IP Address' => $ip, 'Auth Method' => $method] + ); + } +} diff --git a/app/Services/Agent/AgentClient.php b/app/Services/Agent/AgentClient.php new file mode 100644 index 0000000..7df7bd1 --- /dev/null +++ b/app/Services/Agent/AgentClient.php @@ -0,0 +1,1409 @@ +socketPath = $socketPath; + $this->timeout = $timeout; + } + + public function send(string $action, array $params = []): array + { + $socket = @socket_create(AF_UNIX, SOCK_STREAM, 0); + if (! $socket) { + throw new Exception('Failed to create socket'); + } + + socket_set_option($socket, SOL_SOCKET, SO_RCVTIMEO, ['sec' => $this->timeout, 'usec' => 0]); + socket_set_option($socket, SOL_SOCKET, SO_SNDTIMEO, ['sec' => $this->timeout, 'usec' => 0]); + + if (! @socket_connect($socket, $this->socketPath)) { + socket_close($socket); + throw new Exception('Failed to connect to agent socket'); + } + + // Sanitize params to remove any control characters that might break JSON + $sanitizedParams = $this->sanitizeForJson($params); + + $request = json_encode(['action' => $action, 'params' => $sanitizedParams], JSON_INVALID_UTF8_SUBSTITUTE | JSON_UNESCAPED_UNICODE); + if ($request === false) { + socket_close($socket); + throw new Exception('JSON encode failed: '.json_last_error_msg()); + } + socket_write($socket, $request, strlen($request)); + + $response = ''; + while (true) { + $buf = socket_read($socket, 8192); + if ($buf === '' || $buf === false) { + break; + } + $response .= $buf; + } + + socket_close($socket); + + $decoded = json_decode($response, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new Exception('Invalid response from agent: '.$response); + } + + if (isset($decoded['error'])) { + throw new Exception($decoded['error']); + } + + return $decoded; + } + + /** + * Cache lightweight metrics to reduce socket churn on polling pages. + */ + private function cachedMetrics(string $key, int $seconds, callable $callback): array + { + if ($seconds <= 0) { + return $callback(); + } + + return Cache::remember($key, now()->addSeconds($seconds), function () use ($callback): array { + return $callback(); + }); + } + + /** + * Recursively sanitize array values to ensure they are JSON-safe. + * Removes control characters (except newlines/tabs) from strings. + */ + private function sanitizeForJson(array $data): array + { + foreach ($data as $key => $value) { + if (is_array($value)) { + $data[$key] = $this->sanitizeForJson($value); + } elseif (is_string($value)) { + // Remove control characters except tab (0x09), newline (0x0A), carriage return (0x0D) + // But for base64 content (which should be the 'content' key), it should be safe already + if ($key !== 'content') { + $data[$key] = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $value); + } + } + } + + return $data; + } + + // File operations + public function fileList(string $username, string $path, bool $showHidden = false): array + { + return $this->send('file.list', ['username' => $username, 'path' => $path, 'show_hidden' => $showHidden]); + } + + public function fileRead(string $username, string $path): array + { + return $this->send('file.read', ['username' => $username, 'path' => $path]); + } + + public function fileWrite(string $username, string $path, string $content): array + { + return $this->send('file.write', ['username' => $username, 'path' => $path, 'content' => $content]); + } + + public function fileDelete(string $username, string $path): array + { + return $this->send('file.delete', ['username' => $username, 'path' => $path]); + } + + public function fileMkdir(string $username, string $path): array + { + return $this->send('file.mkdir', ['username' => $username, 'path' => $path]); + } + + public function fileRename(string $username, string $oldPath, string $newPath): array + { + // Agent expects 'path' (current file) and 'new_name' (just the filename, no path) + $newName = basename($newPath); + + return $this->send('file.rename', ['username' => $username, 'path' => $oldPath, 'new_name' => $newName]); + } + + public function fileCopy(string $username, string $source, string $destination): array + { + return $this->send('file.copy', ['username' => $username, 'source' => $source, 'destination' => $destination]); + } + + public function fileMove(string $username, string $source, string $destination): array + { + return $this->send('file.move', ['username' => $username, 'source' => $source, 'destination' => $destination]); + } + + /** + * Upload a file. For large files (>1MB), uses temp file approach to avoid JSON encoding issues. + */ + public function fileUpload(string $username, string $path, string $filename, string $content): array + { + // For files larger than 1MB, use temp file approach to avoid JSON encoding issues + $sizeThreshold = 1 * 1024 * 1024; // 1MB + + if (strlen($content) > $sizeThreshold) { + return $this->fileUploadLarge($username, $path, $filename, $content); + } + + return $this->send('file.upload', [ + 'username' => $username, + 'path' => $path, + 'filename' => $filename, + 'content' => base64_encode($content), + ]); + } + + /** + * Upload large files by writing to temp location and having agent move them. + * This avoids JSON encoding issues with large binary content. + */ + protected function fileUploadLarge(string $username, string $path, string $filename, string $content): array + { + // Create temp directory if it doesn't exist + $tempDir = '/tmp/jabali-uploads'; + if (! is_dir($tempDir)) { + mkdir($tempDir, 0700, true); + chmod($tempDir, 0700); + } else { + @chmod($tempDir, 0700); + } + + // Generate unique temp filename + $tempFile = $tempDir.'/'.uniqid('upload_', true).'_'.preg_replace('/[^a-zA-Z0-9._-]/', '_', $filename); + + // Write content to temp file + if (file_put_contents($tempFile, $content) === false) { + throw new Exception('Failed to write temp file for upload'); + } + + // Make sure the file is readable by root (agent) + chmod($tempFile, 0600); + + try { + // Call agent to move file from temp to destination + return $this->send('file.upload_temp', [ + 'username' => $username, + 'path' => $path, + 'filename' => $filename, + 'temp_path' => $tempFile, + ]); + } finally { + // Clean up temp file if it still exists (agent should have moved it) + if (file_exists($tempFile)) { + @unlink($tempFile); + } + } + } + + public function fileExtract(string $username, string $path): array + { + return $this->send('file.extract', ['username' => $username, 'path' => $path]); + } + + public function fileChmod(string $username, string $path, string $mode): array + { + return $this->send('file.chmod', ['username' => $username, 'path' => $path, 'mode' => $mode]); + } + + public function fileInfo(string $username, string $path): array + { + return $this->send('file.info', ['username' => $username, 'path' => $path]); + } + + public function fileTrash(string $username, string $path): array + { + return $this->send('file.trash', ['username' => $username, 'path' => $path]); + } + + public function fileRestore(string $username, string $trashName): array + { + return $this->send('file.restore', ['username' => $username, 'trash_name' => $trashName]); + } + + public function fileEmptyTrash(string $username): array + { + return $this->send('file.empty_trash', ['username' => $username]); + } + + public function fileListTrash(string $username): array + { + return $this->send('file.list_trash', ['username' => $username]); + } + + // Git deployment + public function gitGenerateKey(string $username): array + { + return $this->send('git.generate_key', ['username' => $username]); + } + + public function gitDeploy(string $username, string $repoUrl, string $branch, string $deployPath, ?string $deployScript = null): array + { + return $this->send('git.deploy', [ + 'username' => $username, + 'repo_url' => $repoUrl, + 'branch' => $branch, + 'deploy_path' => $deployPath, + 'deploy_script' => $deployScript, + ]); + } + + // Spam settings (Rspamd) + public function rspamdUserSettings(string $username, array $whitelist = [], array $blacklist = [], ?float $score = null): array + { + return $this->send('rspamd.user_settings', [ + 'username' => $username, + 'whitelist' => $whitelist, + 'blacklist' => $blacklist, + 'score' => $score, + ]); + } + + // Image optimization + public function imageOptimize(string $username, string $path, bool $convertWebp = false, int $quality = 82): array + { + return $this->send('image.optimize', [ + 'username' => $username, + 'path' => $path, + 'convert_webp' => $convertWebp, + 'quality' => $quality, + ]); + } + + // MySQL operations + public function mysqlListDatabases(string $username): array + { + return $this->send('mysql.list_databases', ['username' => $username]); + } + + public function mysqlCreateDatabase(string $username, string $database): array + { + return $this->send('mysql.create_database', ['username' => $username, 'database' => $database]); + } + + public function mysqlDeleteDatabase(string $username, string $database): array + { + return $this->send('mysql.delete_database', ['username' => $username, 'database' => $database]); + } + + public function mysqlListUsers(string $username): array + { + return $this->send('mysql.list_users', ['username' => $username]); + } + + public function mysqlCreateUser(string $username, string $dbUser, string $password, string $host = 'localhost'): array + { + return $this->send('mysql.create_user', ['username' => $username, 'db_user' => $dbUser, 'password' => $password, 'host' => $host]); + } + + public function mysqlDeleteUser(string $username, string $dbUser, string $host = 'localhost'): array + { + return $this->send('mysql.delete_user', ['username' => $username, 'db_user' => $dbUser, 'host' => $host]); + } + + public function mysqlChangePassword(string $username, string $dbUser, string $password, string $host = 'localhost'): array + { + return $this->send('mysql.change_password', ['username' => $username, 'db_user' => $dbUser, 'password' => $password, 'host' => $host]); + } + + public function mysqlGrantPrivileges(string $username, string $dbUser, string $database, array $privileges = ['ALL'], string $host = 'localhost'): array + { + return $this->send('mysql.grant_privileges', ['username' => $username, 'db_user' => $dbUser, 'database' => $database, 'privileges' => $privileges, 'host' => $host]); + } + + public function mysqlRevokePrivileges(string $username, string $dbUser, string $database, string $host = 'localhost'): array + { + return $this->send('mysql.revoke_privileges', ['username' => $username, 'db_user' => $dbUser, 'database' => $database, 'host' => $host]); + } + + public function mysqlGetPrivileges(string $username, string $dbUser, string $host = 'localhost'): array + { + return $this->send('mysql.get_privileges', ['username' => $username, 'db_user' => $dbUser, 'host' => $host]); + } + + public function mysqlCreateMasterUser(string $username): array + { + return $this->send('mysql.create_master_user', ['username' => $username]); + } + + public function mysqlImportDatabase(string $username, string $database, string $sqlFile): array + { + return $this->send('mysql.import_database', ['username' => $username, 'database' => $database, 'sql_file' => $sqlFile]); + } + + public function mysqlExportDatabase(string $username, string $database, string $outputFile, string $compress = 'gz'): array + { + return $this->send('mysql.export_database', ['username' => $username, 'database' => $database, 'output_file' => $outputFile, 'compress' => $compress]); + } + + // PostgreSQL operations + public function postgresListDatabases(string $username): array + { + return $this->send('postgres.list_databases', ['username' => $username]); + } + + public function postgresListUsers(string $username): array + { + return $this->send('postgres.list_users', ['username' => $username]); + } + + public function postgresCreateDatabase(string $username, string $database, string $owner): array + { + return $this->send('postgres.create_database', [ + 'username' => $username, + 'database' => $database, + 'owner' => $owner, + ]); + } + + public function postgresDeleteDatabase(string $username, string $database): array + { + return $this->send('postgres.delete_database', [ + 'username' => $username, + 'database' => $database, + ]); + } + + public function postgresCreateUser(string $username, string $dbUser, string $password): array + { + return $this->send('postgres.create_user', [ + 'username' => $username, + 'db_user' => $dbUser, + 'password' => $password, + ]); + } + + public function postgresDeleteUser(string $username, string $dbUser): array + { + return $this->send('postgres.delete_user', [ + 'username' => $username, + 'db_user' => $dbUser, + ]); + } + + public function postgresChangePassword(string $username, string $dbUser, string $password): array + { + return $this->send('postgres.change_password', [ + 'username' => $username, + 'db_user' => $dbUser, + 'password' => $password, + ]); + } + + public function postgresGrantPrivileges(string $username, string $database, string $dbUser): array + { + return $this->send('postgres.grant_privileges', [ + 'username' => $username, + 'database' => $database, + 'db_user' => $dbUser, + ]); + } + + // Domain operations + public function domainCreate(string $username, string $domain): array + { + return $this->send('domain.create', ['username' => $username, 'domain' => $domain]); + } + + public function domainAliasAdd(string $username, string $domain, string $alias): array + { + return $this->send('domain.alias_add', [ + 'username' => $username, + 'domain' => $domain, + 'alias' => $alias, + ]); + } + + public function domainAliasRemove(string $username, string $domain, string $alias): array + { + return $this->send('domain.alias_remove', [ + 'username' => $username, + 'domain' => $domain, + 'alias' => $alias, + ]); + } + + public function domainEnsureErrorPages(string $username, string $domain): array + { + return $this->send('domain.ensure_error_pages', [ + 'username' => $username, + 'domain' => $domain, + ]); + } + + public function domainDelete(string $username, string $domain, bool $deleteFiles = false): array + { + return $this->send('domain.delete', ['username' => $username, 'domain' => $domain, 'delete_files' => $deleteFiles]); + } + + public function domainList(string $username): array + { + return $this->send('domain.list', ['username' => $username]); + } + + public function domainToggle(string $username, string $domain, bool $enable): array + { + return $this->send('domain.toggle', ['username' => $username, 'domain' => $domain, 'enable' => $enable]); + } + + // WordPress operations + public function wpInstall(string $username, string $domain, array $options): array + { + return $this->send('wp.install', array_merge(['username' => $username, 'domain' => $domain], $options)); + } + + public function wpList(string $username): array + { + return $this->send('wp.list', ['username' => $username]); + } + + public function wpDelete(string $username, string $siteId, bool $deleteFiles = true, bool $deleteDatabase = true): array + { + return $this->send('wp.delete', [ + 'username' => $username, + 'site_id' => $siteId, + 'delete_files' => $deleteFiles, + 'delete_database' => $deleteDatabase, + ]); + } + + public function wpAutoLogin(string $username, string $siteId): array + { + return $this->send('wp.auto_login', ['username' => $username, 'site_id' => $siteId]); + } + + public function wpUpdate(string $username, string $siteId, string $type = 'all'): array + { + return $this->send('wp.update', ['username' => $username, 'site_id' => $siteId, 'type' => $type]); + } + + public function wpScan(string $username): array + { + return $this->send('wp.scan', ['username' => $username]); + } + + public function wpImport(string $username, string $path, ?int $domainId = null): array + { + $params = ['username' => $username, 'path' => $path]; + if ($domainId !== null) { + $params['domain_id'] = $domainId; + } + + return $this->send('wp.import', $params); + } + + public function wpCreateStaging(string $username, string $siteId, string $subdomain): array + { + return $this->send('wp.create_staging', [ + 'username' => $username, + 'site_id' => $siteId, + 'subdomain' => $subdomain, + ]); + } + + public function wpPushStaging(string $username, string $stagingSiteId): array + { + return $this->send('wp.push_staging', [ + 'username' => $username, + 'staging_site_id' => $stagingSiteId, + ]); + } + + // WordPress Cache Methods + public function wpCacheEnable(string $username, string $siteId): array + { + return $this->send('wp.cache_enable', ['username' => $username, 'site_id' => $siteId]); + } + + public function wpCacheDisable(string $username, string $siteId, bool $removePlugin = false, bool $resetData = false): array + { + return $this->send('wp.cache_disable', [ + 'username' => $username, + 'site_id' => $siteId, + 'remove_plugin' => $removePlugin, + 'reset_data' => $resetData, + ]); + } + + public function wpCacheFlush(string $username, string $siteId): array + { + return $this->send('wp.cache_flush', ['username' => $username, 'site_id' => $siteId]); + } + + public function wpCacheStatus(string $username, string $siteId): array + { + return $this->send('wp.cache_status', ['username' => $username, 'site_id' => $siteId]); + } + + // Page Cache (nginx fastcgi_cache) Methods + public function wpPageCacheEnable(string $username, string $domain, ?string $siteId = null): array + { + return $this->send('wp.page_cache_enable', ['username' => $username, 'domain' => $domain, 'site_id' => $siteId]); + } + + public function wpPageCacheDisable(string $username, string $domain, ?string $siteId = null): array + { + return $this->send('wp.page_cache_disable', ['username' => $username, 'domain' => $domain, 'site_id' => $siteId]); + } + + public function wpPageCachePurge(string $domain, ?string $path = null): array + { + return $this->send('wp.page_cache_purge', ['domain' => $domain, 'path' => $path]); + } + + public function wpPageCacheStatus(string $username, string $domain, ?string $siteId = null): array + { + return $this->send('wp.page_cache_status', ['username' => $username, 'domain' => $domain, 'site_id' => $siteId]); + } + + // DNS Management Methods + public function dnsCreateZone(string $domain, array $settings = []): array + { + return $this->send('dns.create_zone', array_merge(['domain' => $domain], $settings)); + } + + public function dnsSyncZone(string $domain, array $records, array $settings = []): array + { + return $this->send('dns.sync_zone', array_merge(['domain' => $domain, 'records' => $records], $settings)); + } + + public function dnsDeleteZone(string $domain): array + { + return $this->send('dns.delete_zone', ['domain' => $domain]); + } + + public function dnsReload(): array + { + return $this->send('dns.reload'); + } + + // DNSSEC operations + public function dnsEnableDnssec(string $domain): array + { + return $this->send('dns.enable_dnssec', ['domain' => $domain]); + } + + public function dnsDisableDnssec(string $domain): array + { + return $this->send('dns.disable_dnssec', ['domain' => $domain]); + } + + public function dnsGetDnssecStatus(string $domain): array + { + return $this->send('dns.get_dnssec_status', ['domain' => $domain]); + } + + public function dnsGetDsRecords(string $domain): array + { + return $this->send('dns.get_ds_records', ['domain' => $domain]); + } + + // User operations + public function userExists(string $username): bool + { + $result = $this->send('user.exists', ['username' => $username]); + + return $result['exists'] ?? false; + } + + public function deleteUser(string $username, bool $removeHome = false, array $domains = []): array + { + return $this->send('user.delete', [ + 'username' => $username, + 'remove_home' => $removeHome, + 'domains' => $domains, + ]); + } + + public function createUser(string $username, ?string $password = null): array + { + return $this->send('user.create', ['username' => $username, 'password' => $password]); + } + + // Email Domain operations + public function emailEnableDomain(string $username, string $domain): array + { + return $this->send('email.enable_domain', ['username' => $username, 'domain' => $domain]); + } + + public function emailDisableDomain(string $username, string $domain): array + { + return $this->send('email.disable_domain', ['username' => $username, 'domain' => $domain]); + } + + public function emailGenerateDkim(string $username, string $domain, string $selector = 'default'): array + { + return $this->send('email.generate_dkim', ['username' => $username, 'domain' => $domain, 'selector' => $selector]); + } + + public function emailGetDomainInfo(string $username, string $domain): array + { + return $this->send('email.domain_info', ['username' => $username, 'domain' => $domain]); + } + + // Mailbox operations + public function mailboxCreate(string $username, string $email, string $password, int $quotaBytes = 1073741824): array + { + return $this->send('email.mailbox_create', [ + 'username' => $username, + 'email' => $email, + 'password' => $password, + 'quota_bytes' => $quotaBytes, + ]); + } + + public function mailboxDelete(string $username, string $email, bool $deleteFiles = false, ?string $maildirPath = null): array + { + return $this->send('email.mailbox_delete', [ + 'username' => $username, + 'email' => $email, + 'delete_files' => $deleteFiles, + 'maildir_path' => $maildirPath, + ]); + } + + public function mailboxChangePassword(string $username, string $email, string $password): array + { + return $this->send('email.mailbox_change_password', [ + 'username' => $username, + 'email' => $email, + 'password' => $password, + ]); + } + + public function mailboxSetQuota(string $username, string $email, int $quotaBytes): array + { + return $this->send('email.mailbox_set_quota', [ + 'username' => $username, + 'email' => $email, + 'quota_bytes' => $quotaBytes, + ]); + } + + public function mailboxGetQuotaUsage(string $username, string $email): array + { + return $this->send('email.mailbox_quota_usage', [ + 'username' => $username, + 'email' => $email, + ]); + } + + public function mailboxToggle(string $username, string $email, bool $active): array + { + return $this->send('email.mailbox_toggle', [ + 'username' => $username, + 'email' => $email, + 'active' => $active, + ]); + } + + // Email sync operations + public function emailSyncVirtualUsers(string $domain): array + { + return $this->send('email.sync_virtual_users', ['domain' => $domain]); + } + + public function emailSyncMaps(array $domains, array $mailboxes, array $aliases): array + { + return $this->send('email.sync_maps', [ + 'domains' => $domains, + 'mailboxes' => $mailboxes, + 'aliases' => $aliases, + ]); + } + + public function emailReloadServices(): array + { + return $this->send('email.reload_services'); + } + + // Server Import operations (cPanel/DirectAdmin migration) + public function importDiscover(int $importId, string $sourceType, string $importMethod, ?string $backupPath, ?string $remoteHost, ?int $remotePort, ?string $remoteUser, ?string $remotePassword): array + { + return $this->send('import.discover', [ + 'import_id' => $importId, + 'source_type' => $sourceType, + 'import_method' => $importMethod, + 'backup_path' => $backupPath, + 'remote_host' => $remoteHost, + 'remote_port' => $remotePort, + 'remote_user' => $remoteUser, + 'remote_password' => $remotePassword, + ]); + } + + public function importStart(int $importId): array + { + return $this->send('import.start', ['import_id' => $importId]); + } + + // SSL Certificate operations + public function sslCheck(string $domain, string $username): array + { + return $this->send('ssl.check', [ + 'domain' => $domain, + 'username' => $username, + ]); + } + + public function sslIssue(string $domain, string $username, ?string $email = null, bool $includeWww = true): array + { + return $this->send('ssl.issue', [ + 'domain' => $domain, + 'username' => $username, + 'email' => $email, + 'include_www' => $includeWww, + ]); + } + + public function sslInstall(string $domain, string $username, string $certificate, string $privateKey, ?string $caBundle = null): array + { + return $this->send('ssl.install', [ + 'domain' => $domain, + 'username' => $username, + 'certificate' => $certificate, + 'private_key' => $privateKey, + 'ca_bundle' => $caBundle, + ]); + } + + public function sslRenew(string $domain, string $username): array + { + return $this->send('ssl.renew', [ + 'domain' => $domain, + 'username' => $username, + ]); + } + + public function sslGenerateSelfSigned(string $domain, string $username, int $days = 365): array + { + return $this->send('ssl.generate_self_signed', [ + 'domain' => $domain, + 'username' => $username, + 'days' => $days, + ]); + } + + // Server config export/import + + /** + * Export server configuration (nginx vhosts, DNS zones, SSL certs, maildir). + * + * @param string $outputPath Path to save the export archive + * @param array $options Export options (include_nginx, include_dns, include_ssl, include_maildir) + */ + public function serverExportConfig(string $outputPath = '/tmp/jabali-config-export.tar.gz', array $options = []): array + { + return $this->send('server.export_config', array_merge([ + 'output_path' => $outputPath, + ], $options)); + } + + /** + * Import server configuration from export archive. + * + * @param string $archivePath Path to the export archive + * @param bool $importNginx Whether to import nginx configs + * @param bool $importDns Whether to import DNS zones + * @param bool $dryRun Preview what would be imported without making changes + */ + public function serverImportConfig(string $archivePath, bool $importNginx = true, bool $importDns = true, bool $dryRun = false): array + { + return $this->send('server.import_config', [ + 'archive_path' => $archivePath, + 'import_nginx' => $importNginx, + 'import_dns' => $importDns, + 'dry_run' => $dryRun, + ]); + } + + // Backup operations + + /** + * Create a backup for a user. + * + * @param string $username System username + * @param string $outputPath Path to save the backup archive + * @param array $options Backup options (domains, databases, mailboxes, include_files, include_databases, include_mailboxes, include_dns) + */ + public function backupCreate(string $username, string $outputPath, array $options = []): array + { + return $this->send('backup.create', array_merge([ + 'username' => $username, + 'output_path' => $outputPath, + ], $options)); + } + + /** + * Create a server-wide backup (all users). + * + * @param string $outputPath Path to save the backup archive + * @param array $options Backup options (users, include_files, include_databases, include_mailboxes, include_dns) + */ + public function backupCreateServer(string $outputPath, array $options = []): array + { + return $this->send('backup.create_server', array_merge([ + 'output_path' => $outputPath, + ], $options)); + } + + /** + * Dirvish-style incremental backup directly to remote. + * Rsyncs user files directly to remote with --link-dest for hard links. + * + * @param array $destination Remote destination config (type, host, username, etc.) + * @param array $options Backup options (users, include_files, include_databases, include_mailboxes, include_dns) + */ + public function backupIncrementalDirect(array $destination, array $options = []): array + { + return $this->send('backup.incremental_direct', array_merge([ + 'destination' => $destination, + ], $options)); + } + + /** + * Restore a backup for a user. + * + * @param string $username System username + * @param string $backupPath Path to the backup archive + * @param array $options Restore options (restore_files, restore_databases, restore_mailboxes, restore_dns, selected_domains, selected_databases, selected_mailboxes) + */ + public function backupRestore(string $username, string $backupPath, array $options = []): array + { + return $this->send('backup.restore', array_merge([ + 'username' => $username, + 'backup_path' => $backupPath, + ], $options)); + } + + /** + * List backups for a user. + * + * @param string $username System username + * @param string $path Directory to list backups from + */ + public function backupList(string $username, string $path = ''): array + { + return $this->send('backup.list', [ + 'username' => $username, + 'path' => $path, + ]); + } + + /** + * Delete a backup file. + * + * @param string $username System username + * @param string $backupPath Path to the backup file + */ + public function backupDelete(string $username, string $backupPath): array + { + return $this->send('backup.delete', [ + 'username' => $username, + 'backup_path' => $backupPath, + ]); + } + + /** + * Delete a server backup file (runs as root). + * + * @param string $backupPath Path to the backup file + */ + public function backupDeleteServer(string $backupPath): array + { + return $this->send('backup.delete_server', [ + 'backup_path' => $backupPath, + ]); + } + + /** + * Verify backup integrity. + * + * @param string $backupPath Path to the backup archive + */ + public function backupVerify(string $backupPath): array + { + return $this->send('backup.verify', [ + 'backup_path' => $backupPath, + ]); + } + + /** + * Get backup manifest/info. + * + * @param string $backupPath Path to the backup archive + */ + public function backupGetInfo(string $backupPath): array + { + return $this->send('backup.get_info', [ + 'backup_path' => $backupPath, + ]); + } + + /** + * Upload backup to remote destination. + * + * @param string $localPath Local path to the backup file + * @param array $destination Remote destination config (type, host, port, username, password, path, etc.) + * @param string $backupType 'full' or 'incremental' - incremental uses rsync with hard links + */ + public function backupUploadRemote(string $localPath, array $destination, string $backupType = 'full'): array + { + return $this->send('backup.upload_remote', [ + 'local_path' => $localPath, + 'destination' => $destination, + 'backup_type' => $backupType, + ]); + } + + /** + * Download backup from remote destination. + * + * @param string $remotePath Remote path to the backup file + * @param string $localPath Local path to save the backup + * @param array $destination Remote destination config + */ + public function backupDownloadRemote(string $remotePath, string $localPath, array $destination): array + { + return $this->send('backup.download_remote', [ + 'remote_path' => $remotePath, + 'local_path' => $localPath, + 'destination' => $destination, + ]); + } + + /** + * List backups on remote destination. + * + * @param array $destination Remote destination config + * @param string $path Path to list (optional) + */ + public function backupListRemote(array $destination, string $path = ''): array + { + return $this->send('backup.list_remote', [ + 'destination' => $destination, + 'path' => $path, + ]); + } + + /** + * Delete backup from remote destination. + * + * @param string $remotePath Remote path to the backup file + * @param array $destination Remote destination config + */ + public function backupDeleteRemote(string $remotePath, array $destination): array + { + return $this->send('backup.delete_remote', [ + 'remote_path' => $remotePath, + 'destination' => $destination, + ]); + } + + /** + * Test remote destination connection. + * + * @param array $destination Remote destination config (type, host, port, username, password, path, etc.) + */ + public function backupTestDestination(array $destination): array + { + return $this->send('backup.test_destination', [ + 'destination' => $destination, + ]); + } + + // ============ CRON JOB OPERATIONS ============ + + /** + * List cron jobs for a user. + */ + public function cronList(string $username): array + { + return $this->send('cron.list', [ + 'username' => $username, + ]); + } + + /** + * Create a cron job. + */ + public function cronCreate(string $username, string $schedule, string $command, string $comment = ''): array + { + return $this->send('cron.create', [ + 'username' => $username, + 'schedule' => $schedule, + 'command' => $command, + 'comment' => $comment, + ]); + } + + /** + * Delete a cron job. + */ + public function cronDelete(string $username, string $command, string $schedule = ''): array + { + return $this->send('cron.delete', [ + 'username' => $username, + 'command' => $command, + 'schedule' => $schedule, + ]); + } + + /** + * Toggle a cron job on/off. + */ + public function cronToggle(string $username, string $command, bool $enable): array + { + return $this->send('cron.toggle', [ + 'username' => $username, + 'command' => $command, + 'enable' => $enable, + ]); + } + + /** + * Run a cron job immediately. + */ + public function cronRun(string $username, string $command): array + { + return $this->send('cron.run', [ + 'username' => $username, + 'command' => $command, + ]); + } + + /** + * Setup WordPress cron (creates cron job and modifies wp-config.php). + */ + public function cronWordPressSetup(string $username, string $domain, string $schedule = '*/5 * * * *', bool $disable = false): array + { + return $this->send('cron.wp_setup', [ + 'username' => $username, + 'domain' => $domain, + 'schedule' => $schedule, + 'disable' => $disable, + ]); + } + + // ============ SERVER METRICS OPERATIONS ============ + + /** + * Get server metrics overview (CPU, memory, disk, load). + */ + public function metricsOverview(): array + { + return $this->cachedMetrics('agent.metrics.overview', 5, fn (): array => $this->send('metrics.overview', [])); + } + + /** + * Get CPU metrics. + */ + public function metricsCpu(): array + { + return $this->cachedMetrics('agent.metrics.cpu', 5, fn (): array => $this->send('metrics.cpu', [])); + } + + /** + * Get memory metrics. + */ + public function metricsMemory(): array + { + return $this->cachedMetrics('agent.metrics.memory', 5, fn (): array => $this->send('metrics.memory', [])); + } + + /** + * Get disk metrics. + */ + public function metricsDisk(): array + { + return $this->cachedMetrics('agent.metrics.disk', 10, fn (): array => $this->send('metrics.disk', [])); + } + + /** + * Get network metrics. + */ + public function metricsNetwork(): array + { + return $this->cachedMetrics('agent.metrics.network', 5, fn (): array => $this->send('metrics.network', [])); + } + + /** + * Get top processes. + */ + public function metricsProcesses(int $limit = 15, string $sortBy = 'cpu'): array + { + return $this->send('metrics.processes', [ + 'limit' => $limit, + 'sort' => $sortBy, + ]); + } + + // ============ DISK QUOTA OPERATIONS ============ + + /** + * Get quota system status. + */ + public function quotaStatus(string $mount = '/home'): array + { + return $this->send('quota.status', ['mount' => $mount]); + } + + /** + * Enable quota system on filesystem. + */ + public function quotaEnable(string $mount = '/home'): array + { + return $this->send('quota.enable', ['mount' => $mount]); + } + + /** + * Set disk quota for a user. + */ + public function quotaSet(string $username, int $softMb, int $hardMb = 0, string $mount = '/home'): array + { + return $this->send('quota.set', [ + 'username' => $username, + 'soft_mb' => $softMb, + 'hard_mb' => $hardMb ?: $softMb, + 'mount' => $mount, + ]); + } + + /** + * Get disk quota for a user. + */ + public function quotaGet(string $username, string $mount = '/home'): array + { + return $this->send('quota.get', [ + 'username' => $username, + 'mount' => $mount, + ]); + } + + /** + * Get quota report for all users. + */ + public function quotaReport(string $mount = '/home'): array + { + return $this->send('quota.report', ['mount' => $mount]); + } + + /** + * List all IP addresses on the server. + */ + public function ipList(): array + { + return $this->send('ip.list'); + } + + /** + * Add an IP address to an interface. + */ + public function ipAdd(string $ip, int $cidr, string $interface): array + { + return $this->send('ip.add', [ + 'ip' => $ip, + 'cidr' => $cidr, + 'interface' => $interface, + ]); + } + + /** + * Remove an IP address from an interface. + */ + public function ipRemove(string $ip, int $cidr, string $interface): array + { + return $this->send('ip.remove', [ + 'ip' => $ip, + 'cidr' => $cidr, + 'interface' => $interface, + ]); + } + + /** + * Get detailed information about an IP address. + */ + public function ipInfo(string $ip): array + { + return $this->send('ip.info', ['ip' => $ip]); + } + + /** + * Get light status for Fail2ban (installed/running/version). + */ + public function fail2banStatusLight(): array + { + return $this->send('fail2ban.status_light'); + } + + /** + * Get light status for ClamAV (installed/running/version). + */ + public function clamavStatusLight(): array + { + return $this->send('clamav.status_light'); + } + + /** + * Install a security scanner tool. + */ + public function scannerInstall(string $tool): array + { + return $this->send('scanner.install', ['tool' => $tool], 120); + } + + /** + * Uninstall a security scanner tool. + */ + public function scannerUninstall(string $tool): array + { + return $this->send('scanner.uninstall', ['tool' => $tool], 60); + } + + /** + * Get status of security scanner tools. + */ + public function scannerStatus(?string $tool = null): array + { + return $this->send('scanner.status', ['tool' => $tool]); + } + + /** + * Run Lynis security audit. + */ + public function scannerRunLynis(): array + { + return $this->send('scanner.run_lynis', [], 300); + } + + /** + * Run Nikto web server scan. + */ + public function scannerRunNikto(string $target): array + { + return $this->send('scanner.run_nikto', ['target' => $target], 300); + } + + /** + * Start Lynis scan in background. + */ + public function scannerStartLynis(): array + { + return $this->send('scanner.start_lynis', []); + } + + /** + * Get current scan status and output. + */ + public function scannerGetScanStatus(string $scanner = 'lynis'): array + { + return $this->send('scanner.get_scan_status', ['scanner' => $scanner]); + } + + // Mail queue operations + public function mailQueueList(): array + { + return $this->send('mail.queue_list'); + } + + public function mailQueueRetry(string $id): array + { + return $this->send('mail.queue_retry', ['id' => $id]); + } + + public function mailQueueDelete(string $id): array + { + return $this->send('mail.queue_delete', ['id' => $id]); + } + + // Server updates + public function updatesList(bool $refresh = false): array + { + return $this->send('updates.list', ['refresh' => $refresh]); + } + + public function updatesRun(): array + { + return $this->send('updates.run'); + } + + // WAF / Geo + public function wafApplySettings(bool $enabled, string $paranoia, bool $auditLog, array $whitelistRules = []): array + { + return $this->send('waf.apply', [ + 'enabled' => $enabled, + 'paranoia' => $paranoia, + 'audit_log' => $auditLog, + 'whitelist_rules' => $whitelistRules, + ]); + } + + public function wafAuditLogList(int $limit = 200): array + { + return $this->send('waf.audit_log', [ + 'limit' => $limit, + ]); + } + + public function geoApplyRules(array $rules): array + { + return $this->send('geo.apply_rules', [ + 'rules' => $rules, + ]); + } + + public function geoUpdateDatabase(string $accountId, string $licenseKey, string $editionIds = 'GeoLite2-Country'): array + { + return $this->send('geo.update_database', [ + 'account_id' => $accountId, + 'license_key' => $licenseKey, + 'edition_ids' => $editionIds, + ]); + } + + public function geoUploadDatabase(string $edition, string $content): array + { + return $this->send('geo.upload_database', [ + 'edition' => $edition, + 'content' => $content, + ]); + } + + public function databasePersistTuning(string $name, string $value): array + { + return $this->send('database.persist_tuning', [ + 'name' => $name, + 'value' => $value, + ]); + } + + /** + * @param array $names + */ + public function databaseGetVariables(array $names): array + { + return $this->send('database.get_variables', [ + 'names' => $names, + ]); + } + + public function databaseSetGlobal(string $name, string $value): array + { + return $this->send('database.set_global', [ + 'name' => $name, + 'value' => $value, + ]); + } +} diff --git a/app/Services/JabaliSshKey.php b/app/Services/JabaliSshKey.php new file mode 100644 index 0000000..4276ec7 --- /dev/null +++ b/app/Services/JabaliSshKey.php @@ -0,0 +1,153 @@ +&1', + escapeshellarg($keyPath) + ); + + exec($command, $output, $returnCode); + + if ($returnCode !== 0) { + Log::error('Failed to generate SSH key', ['output' => implode("\n", $output)]); + + return false; + } + + // Set proper permissions + chmod($keyPath, 0600); + chmod($keyPath.'.pub', 0644); + + Log::info('Jabali system SSH key generated'); + + return true; + } catch (Exception $e) { + Log::error('Failed to generate SSH key: '.$e->getMessage()); + + return false; + } + } + + /** + * Get the fingerprint of the public key + */ + public static function getFingerprint(): ?string + { + $pubKeyPath = self::getPublicKeyPath(); + + if (! file_exists($pubKeyPath)) { + return null; + } + + exec('ssh-keygen -lf '.escapeshellarg($pubKeyPath).' 2>&1', $output, $returnCode); + + if ($returnCode !== 0) { + return null; + } + + return $output[0] ?? null; + } +} diff --git a/app/Services/Migration/CpanelApiService.php b/app/Services/Migration/CpanelApiService.php new file mode 100644 index 0000000..f16057b --- /dev/null +++ b/app/Services/Migration/CpanelApiService.php @@ -0,0 +1,1306 @@ +hostname = rtrim(trim($hostname), '/'); + $this->username = trim($username); + $this->apiToken = trim($apiToken); + $this->port = $port; + $this->ssl = $ssl; + } + + /** + * Get the base URL for API calls + */ + private function getBaseUrl(): string + { + $protocol = $this->ssl ? 'https' : 'http'; + + return "{$protocol}://{$this->hostname}:{$this->port}"; + } + + /** + * Make a UAPI request to cPanel + */ + public function uapi(string $module, string $function, array $params = []): array + { + return $this->uapiWithTimeout($module, $function, $params, 30); + } + + /** + * Make a UAPI request to cPanel with custom timeout + */ + public function uapiWithTimeout(string $module, string $function, array $params = [], int $timeout = 30): array + { + $url = $this->getBaseUrl()."/execute/{$module}/{$function}"; + + Log::info('cPanel UAPI request', ['url' => $url, 'module' => $module, 'function' => $function, 'timeout' => $timeout]); + + try { + $response = Http::withHeaders([ + 'Authorization' => "cpanel {$this->username}:{$this->apiToken}", + ]) + ->timeout($timeout) + ->connectTimeout(10) + ->withoutVerifying() + ->get($url, $params); + + Log::info('cPanel UAPI response status', ['status' => $response->status(), 'module' => $module]); + + if (! $response->successful()) { + throw new Exception('API request failed with status: '.$response->status()); + } + + $data = $response->json(); + + Log::info('cPanel UAPI response data', ['module' => $module, 'function' => $function, 'data' => $data]); + + if (isset($data['status']) && $data['status'] === 0) { + throw new Exception($data['errors'][0] ?? 'Unknown API error'); + } + + return $data; + } catch (Exception $e) { + Log::error('cPanel API error: '.$e->getMessage(), [ + 'module' => $module, + 'function' => $function, + ]); + throw $e; + } + } + + /** + * Make an API2 request to cPanel (legacy API) + */ + public function api2(string $module, string $function, array $params = []): array + { + $url = $this->getBaseUrl().'/json-api/cpanel'; + + $queryParams = array_merge([ + 'cpanel_jsonapi_user' => $this->username, + 'cpanel_jsonapi_apiversion' => '2', + 'cpanel_jsonapi_module' => $module, + 'cpanel_jsonapi_func' => $function, + ], $params); + + try { + $response = Http::withHeaders([ + 'Authorization' => "cpanel {$this->username}:{$this->apiToken}", + ]) + ->timeout(120) + ->withoutVerifying() + ->get($url, $queryParams); + + if (! $response->successful()) { + throw new Exception('API request failed with status: '.$response->status()); + } + + return $response->json(); + } catch (Exception $e) { + Log::error('cPanel API2 error: '.$e->getMessage(), [ + 'module' => $module, + 'function' => $function, + ]); + throw $e; + } + } + + /** + * Test the connection to cPanel + */ + public function testConnection(): array + { + try { + $result = $this->uapi('ResourceUsage', 'get_usages'); + + return [ + 'success' => true, + 'message' => 'Connection successful', + 'data' => $result['result']['data'] ?? [], + ]; + } catch (Exception $e) { + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + } + + /** + * Get account information + */ + public function getAccountInfo(): array + { + try { + $stats = $this->uapi('StatsBar', 'get_stats', [ + 'display' => 'diskusage|bandwidthusage|addondomains|subdomains|parkeddomains|sqldatabases|emailaccounts', + ]); + + return [ + 'success' => true, + 'stats' => $stats['result']['data'] ?? [], + ]; + } catch (Exception $e) { + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + } + + /** + * List all domains (main, addon, subdomains, parked) + */ + public function listDomains(): array + { + try { + $result = $this->uapi('DomainInfo', 'list_domains'); + + // Log raw response for debugging + Log::info('cPanel listDomains raw response', ['result' => $result]); + + // Handle different response structures + $data = $result['result']['data'] ?? $result['data'] ?? $result; + + return [ + 'success' => true, + 'main_domain' => $data['main_domain'] ?? '', + 'addon_domains' => $data['addon_domains'] ?? [], + 'sub_domains' => $data['sub_domains'] ?? [], + 'parked_domains' => $data['parked_domains'] ?? [], + ]; + } catch (Exception $e) { + Log::error('cPanel listDomains failed', ['error' => $e->getMessage()]); + + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + } + + /** + * List MySQL databases + */ + public function listDatabases(): array + { + try { + $result = $this->uapi('Mysql', 'list_databases'); + + // Handle different response structures + $data = $result['result']['data'] ?? $result['data'] ?? []; + + return [ + 'success' => true, + 'databases' => $data, + ]; + } catch (Exception $e) { + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + } + + /** + * List MySQL users + */ + public function listDatabaseUsers(): array + { + try { + $result = $this->uapi('Mysql', 'list_users'); + $data = $result['result']['data'] ?? $result['data'] ?? []; + + return [ + 'success' => true, + 'users' => $data, + ]; + } catch (Exception $e) { + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + } + + /** + * List email accounts + */ + public function listEmailAccounts(): array + { + try { + $result = $this->uapi('Email', 'list_pops_with_disk'); + $data = $result['result']['data'] ?? $result['data'] ?? []; + + return [ + 'success' => true, + 'accounts' => $data, + ]; + } catch (Exception $e) { + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + } + + /** + * List email forwarders + */ + public function listForwarders(): array + { + try { + $result = $this->uapi('Email', 'list_forwarders'); + $data = $result['result']['data'] ?? $result['data'] ?? []; + + return [ + 'success' => true, + 'forwarders' => $data, + ]; + } catch (Exception $e) { + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + } + + /** + * List SSL certificates + */ + public function listSslCertificates(): array + { + try { + $result = $this->uapi('SSL', 'list_certs'); + $data = $result['result']['data'] ?? $result['data'] ?? []; + + return [ + 'success' => true, + 'certificates' => $data, + ]; + } catch (Exception $e) { + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + } + + /** + * List cron jobs + */ + public function listCronJobs(): array + { + try { + $result = $this->uapi('Cron', 'list_cron'); + $data = $result['result']['data'] ?? $result['data'] ?? []; + + return [ + 'success' => true, + 'cron_jobs' => $data, + ]; + } catch (Exception $e) { + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + } + + /** + * Create a full backup to homedir + */ + public function createBackup(): array + { + try { + $result = $this->uapi('Backup', 'fullbackup_to_homedir'); + + return [ + 'success' => true, + 'message' => 'Backup initiated', + 'pid' => $result['result']['data']['pid'] ?? null, + 'data' => $result['result']['data'] ?? [], + ]; + } catch (Exception $e) { + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + } + + /** + * List available backups in homedir using API2 (more reliable) + */ + public function listBackups(): array + { + try { + // Use API2 Backups/listfullbackups as it's more reliable + $result = $this->api2('Backups', 'listfullbackups', []); + $backups = $result['cpanelresult']['data'] ?? []; + + // Format the backups consistently + $formattedBackups = []; + foreach ($backups as $backup) { + $formattedBackups[] = [ + 'file' => $backup['file'] ?? '', + 'status' => $backup['status'] ?? 'unknown', + 'time' => $backup['time'] ?? 0, + 'localtime' => $backup['localtime'] ?? '', + 'path' => "/home/{$this->username}/".$backup['file'], + ]; + } + + return [ + 'success' => true, + 'backups' => $formattedBackups, + ]; + } catch (Exception $e) { + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + } + + /** + * Get the cPanel File Manager URL for downloading a backup + */ + public function getBackupDownloadUrl(string $filename): string + { + $protocol = $this->ssl ? 'https' : 'http'; + + return "{$protocol}://{$this->hostname}:{$this->port}/cpsess0/frontend/jupiter/filemanager/index.html"; + } + + /** + * Get direct instructions for downloading a backup + */ + public function getDownloadInstructions(string $filename): array + { + $protocol = $this->ssl ? 'https' : 'http'; + $loginUrl = "{$protocol}://{$this->hostname}:{$this->port}"; + + return [ + 'login_url' => $loginUrl, + 'username' => $this->username, + 'steps' => [ + "1. Log in to cPanel at {$loginUrl}", + '2. Go to File Manager', + "3. Navigate to the home directory (/home/{$this->username})", + "4. Right-click on '{$filename}' and select 'Download'", + '5. Once downloaded, upload the file using the form below', + ], + 'backup_path' => "/home/{$this->username}/{$filename}", + ]; + } + + /** + * Check if a backup is currently in progress + */ + public function getBackupStatus(): array + { + try { + // Use API2 Fileman/listfiles as UAPI list_files returns empty on some cPanel versions + $result = $this->api2('Fileman', 'listfiles', [ + 'dir' => "/home/{$this->username}", + 'showdotfiles' => 0, + ]); + + $files = $result['cpanelresult']['data'] ?? []; + $backupFiles = []; + $inProgress = false; + + foreach ($files as $file) { + $name = $file['file'] ?? $file['name'] ?? ''; + // cPanel backup files are named like: backup-MM.DD.YYYY_HH-mm-ss_username.tar.gz + if (preg_match('/^backup-\d+\.\d+\.\d+_\d+-\d+-\d+_.*\.tar\.gz$/', $name)) { + $backupFiles[] = [ + 'name' => $name, + 'size' => $file['size'] ?? 0, + 'mtime' => $file['mtime'] ?? 0, + 'path' => "/home/{$this->username}/{$name}", + ]; + } + // Check for in-progress backup indicator + if (str_contains($name, 'backup') && str_ends_with($name, '.log')) { + $inProgress = true; + } + } + + // Sort by modification time, newest first + usort($backupFiles, fn ($a, $b) => ($b['mtime'] ?? 0) - ($a['mtime'] ?? 0)); + + return [ + 'success' => true, + 'in_progress' => $inProgress, + 'backups' => $backupFiles, + ]; + } catch (Exception $e) { + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + } + + /** + * List files in a directory + */ + public function listFiles(string $dir = '/'): array + { + try { + $result = $this->uapi('Fileman', 'list_files', [ + 'dir' => $dir, + 'include_mime' => 0, + 'include_permissions' => 1, + 'include_hash' => 0, + 'include_content' => 0, + ]); + + return [ + 'success' => true, + 'files' => $result['result']['data'] ?? [], + ]; + } catch (Exception $e) { + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + } + + /** + * Download a file from cPanel to a local path (streaming) + */ + public function downloadFileToPath(string $remotePath, string $localPath, ?callable $progressCallback = null): array + { + $url = $this->getBaseUrl().'/download?file='.urlencode($remotePath); + + Log::info('cPanel download starting', ['remote' => $remotePath, 'local' => $localPath]); + + try { + // First, get the file size + $files = $this->listFiles(dirname($remotePath)); + $fileSize = 0; + if ($files['success']) { + foreach ($files['files'] as $file) { + if (($file['file'] ?? $file['name'] ?? '') === basename($remotePath)) { + $fileSize = (int) ($file['size'] ?? 0); + break; + } + } + } + + // Create a stream context for downloading + $context = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'header' => "Authorization: cpanel {$this->username}:{$this->apiToken}\r\n", + 'timeout' => 3600, // 1 hour timeout for large files + 'ignore_errors' => true, + ], + 'ssl' => [ + 'verify_peer' => false, + 'verify_peer_name' => false, + ], + ]); + + // Open remote file + $remoteStream = @fopen($url, 'rb', false, $context); + if (! $remoteStream) { + throw new Exception("Failed to open remote file: $remotePath"); + } + + // Ensure local directory exists + $localDir = dirname($localPath); + if (! is_dir($localDir)) { + mkdir($localDir, 0755, true); + } + + // Open local file for writing + $localStream = fopen($localPath, 'wb'); + if (! $localStream) { + fclose($remoteStream); + throw new Exception("Failed to open local file for writing: $localPath"); + } + + // Download in chunks + $downloaded = 0; + $chunkSize = 1024 * 1024; // 1MB chunks + + while (! feof($remoteStream)) { + $chunk = fread($remoteStream, $chunkSize); + if ($chunk === false) { + break; + } + fwrite($localStream, $chunk); + $downloaded += strlen($chunk); + + if ($progressCallback && $fileSize > 0) { + $progressCallback($downloaded, $fileSize); + } + } + + fclose($remoteStream); + fclose($localStream); + + // Verify file was downloaded + if (! file_exists($localPath) || filesize($localPath) === 0) { + throw new Exception('Download failed - file is empty or missing'); + } + + Log::info('cPanel download completed', [ + 'remote' => $remotePath, + 'local' => $localPath, + 'size' => filesize($localPath), + ]); + + return [ + 'success' => true, + 'path' => $localPath, + 'size' => filesize($localPath), + ]; + } catch (Exception $e) { + Log::error('cPanel download error: '.$e->getMessage(), [ + 'remote' => $remotePath, + 'local' => $localPath, + ]); + + // Clean up partial download + if (file_exists($localPath)) { + @unlink($localPath); + } + + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + } + + /** + * Get homedir path + */ + public function getHomedir(): string + { + return "/home/{$this->username}"; + } + + /** + * Get the cPanel username + */ + public function getUsername(): string + { + return $this->username; + } + + /** + * Import an SSH public key to cPanel + * Uses API2 SSH::importkey function + * + * @param string $keyName Name for the key in cPanel + * @param string $publicKey The SSH public key content + */ + public function importSshKey(string $keyName, string $publicKey): array + { + try { + Log::info('Importing SSH key to cPanel', ['key_name' => $keyName, 'key_length' => strlen($publicKey)]); + + // Use API2 SSH::importkey + $result = $this->api2('SSH', 'importkey', [ + 'key' => $publicKey, + 'name' => $keyName, + ]); + + Log::info('cPanel SSH import response', ['result' => $result]); + + $data = $result['cpanelresult']['data'][0] ?? []; + $apiError = $result['cpanelresult']['error'] ?? ''; + + // Check for "already exists" which is OK (can appear in different places) + if (str_contains($apiError, 'already exists') || str_contains($data['reason'] ?? '', 'already exists')) { + Log::info('SSH key already exists on cPanel - treating as success'); + + return [ + 'success' => true, + 'message' => 'SSH key already exists', + ]; + } + + // Check for API-level error + if ($apiError) { + throw new Exception($apiError); + } + + // Check for success via event.result (cPanel API2 pattern) + $eventResult = $result['cpanelresult']['event']['result'] ?? null; + if ($eventResult == 1) { + return [ + 'success' => true, + 'message' => 'SSH key imported successfully', + ]; + } + + // Legacy check for data[0].result + if (isset($data['result']) && $data['result'] == 1) { + return [ + 'success' => true, + 'message' => 'SSH key imported successfully', + ]; + } + + // Check for alternative error locations + $errorMsg = ($data['reason'] ?? '') + ?: ($data['error'] ?? '') + ?: ($result['cpanelresult']['event']['reason'] ?? '') + ?: 'Failed to import SSH key (unknown reason)'; + + throw new Exception($errorMsg); + } catch (Exception $e) { + Log::error('cPanel SSH key import failed: '.$e->getMessage()); + + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + } + + /** + * Delete an SSH key from cPanel + * Uses API2 SSH::delkey function + * + * @param string $keyName Name of the key to delete + * @param string $keyType Type of key: 'key' for private, 'key.pub' for public + */ + public function deleteSshKey(string $keyName, string $keyType = 'key'): array + { + try { + Log::info('Deleting SSH key from cPanel', ['key_name' => $keyName, 'type' => $keyType]); + + $result = $this->api2('SSH', 'delkey', [ + 'key' => $keyName, + 'pub' => $keyType === 'key.pub' ? 1 : 0, + ]); + + Log::info('cPanel SSH delete response', ['result' => $result]); + + $apiError = $result['cpanelresult']['error'] ?? ''; + $eventResult = $result['cpanelresult']['event']['result'] ?? null; + + // Check for success + if ($eventResult == 1 && empty($apiError)) { + return [ + 'success' => true, + 'message' => 'SSH key deleted', + ]; + } + + // Key not found is OK + if (str_contains($apiError, 'does not exist') || str_contains($apiError, 'not found')) { + return [ + 'success' => true, + 'message' => 'Key does not exist', + ]; + } + + if ($apiError) { + throw new Exception($apiError); + } + + return [ + 'success' => true, + 'message' => 'SSH key deleted', + ]; + } catch (Exception $e) { + Log::error('cPanel SSH key delete failed: '.$e->getMessage()); + + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + } + + /** + * Import an SSH private key to cPanel (for outgoing connections) + * Uses API2 SSH::importkey function + * + * @param string $keyName Name for the key in cPanel + * @param string $privateKey The SSH private key content + * @param string $passphrase Optional passphrase for the key + */ + public function importSshPrivateKey(string $keyName, string $privateKey, string $passphrase = ''): array + { + try { + Log::info('Importing SSH private key to cPanel', ['key_name' => $keyName, 'key_length' => strlen($privateKey)]); + + // Use API2 SSH::importkey - private keys are detected by content + $params = [ + 'key' => $privateKey, + 'name' => $keyName, + ]; + + if (! empty($passphrase)) { + $params['pass'] = $passphrase; + } + + $result = $this->api2('SSH', 'importkey', $params); + + Log::info('cPanel SSH private key import response', ['result' => $result]); + + $data = $result['cpanelresult']['data'][0] ?? []; + $apiError = $result['cpanelresult']['error'] ?? ''; + + // Check for "already exists" which is OK + if (str_contains($apiError, 'already exists') || str_contains($data['reason'] ?? '', 'already exists')) { + Log::info('SSH private key already exists on cPanel - treating as success'); + + return [ + 'success' => true, + 'message' => 'SSH key already exists', + ]; + } + + // Check for API-level error + if ($apiError) { + throw new Exception($apiError); + } + + // Check for success via event.result (cPanel API2 pattern) + $eventResult = $result['cpanelresult']['event']['result'] ?? null; + if ($eventResult == 1) { + return [ + 'success' => true, + 'message' => 'SSH private key imported successfully', + ]; + } + + // Legacy check for data[0].result + if (isset($data['result']) && $data['result'] == 1) { + return [ + 'success' => true, + 'message' => 'SSH private key imported successfully', + ]; + } + + // Check for alternative error locations + $errorMsg = ($data['reason'] ?? '') + ?: ($data['error'] ?? '') + ?: ($result['cpanelresult']['event']['reason'] ?? '') + ?: 'Failed to import SSH private key (unknown reason)'; + + throw new Exception($errorMsg); + } catch (Exception $e) { + Log::error('cPanel SSH private key import failed: '.$e->getMessage()); + + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + } + + /** + * Authorize an SSH key in cPanel (make it usable) + * Uses API2 SSH::authkey function + */ + public function authorizeSshKey(string $keyName): array + { + try { + Log::info('Authorizing SSH key in cPanel', ['key_name' => $keyName]); + + $result = $this->api2('SSH', 'authkey', [ + 'key' => $keyName, + 'action' => 'authorize', + ]); + + Log::info('cPanel SSH authkey response', ['result' => $result]); + + $data = $result['cpanelresult']['data'][0] ?? []; + $apiError = $result['cpanelresult']['error'] ?? ''; + + // Check for "already authorized" which is OK + if (str_contains($apiError, 'already authorized') || str_contains($data['reason'] ?? '', 'already authorized')) { + return [ + 'success' => true, + 'message' => 'SSH key already authorized', + ]; + } + + // Check for API-level error first + if ($apiError) { + throw new Exception($apiError); + } + + // Check for success via event.result (cPanel API2 pattern) + $eventResult = $result['cpanelresult']['event']['result'] ?? null; + if ($eventResult == 1) { + return [ + 'success' => true, + 'message' => 'SSH key authorized successfully', + ]; + } + + // Legacy check for data[0].result + if (isset($data['result']) && $data['result'] == 1) { + return [ + 'success' => true, + 'message' => 'SSH key authorized successfully', + ]; + } + + $errorMsg = $data['reason'] ?? 'Failed to authorize SSH key'; + throw new Exception($errorMsg); + } catch (Exception $e) { + Log::error('cPanel SSH key authorization failed: '.$e->getMessage()); + + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + } + + /** + * Create a full backup and upload to remote server via SCP with key authentication + * The SSH key must be previously imported to cPanel using importSshKey() + * + * @param string $remoteHost The remote server hostname/IP + * @param string $remoteUser The SSH username on the remote server + * @param string $remotePath The destination path on the remote server + * @param string $keyName Name of the SSH key stored in cPanel + * @param int $remotePort SSH port (default 22) + */ + public function createBackupToScpWithKey( + string $remoteHost, + string $remoteUser, + string $remotePath, + string $keyName, + int $remotePort = 22 + ): array { + try { + $params = [ + 'host' => $remoteHost, + 'username' => $remoteUser, + 'directory' => $remotePath, + 'key_name' => $keyName, + 'port' => $remotePort, + 'key_passphrase' => '', // Empty passphrase for keys generated without one + ]; + + Log::info('cPanel backup to SCP with key initiated', [ + 'host' => $remoteHost, + 'user' => $remoteUser, + 'path' => $remotePath, + 'key_name' => $keyName, + 'port' => $remotePort, + ]); + + // Use longer timeout for backup operations + $result = $this->uapiWithTimeout('Backup', 'fullbackup_to_scp_with_key', $params, 120); + + return [ + 'success' => true, + 'message' => 'Backup initiated with SCP transfer', + 'pid' => $result['result']['data']['pid'] ?? null, + 'data' => $result['result']['data'] ?? [], + ]; + } catch (Exception $e) { + Log::error('cPanel backup to SCP with key failed: '.$e->getMessage()); + + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + } + + /** + * Legacy method - kept for backward compatibility + * + * @deprecated Use createBackupToScpWithKey instead + */ + public function createBackupToScp( + string $remoteHost, + string $remoteUser, + string $remotePath, + string $privateKey, + string $passphrase = '', + int $remotePort = 22 + ): array { + // This method is deprecated - the key should be imported first + Log::warning('createBackupToScp is deprecated, use createBackupToScpWithKey instead'); + + return [ + 'success' => false, + 'message' => 'This method is deprecated. Import the SSH key first using importSshKey(), then use createBackupToScpWithKey()', + ]; + } + + /** + * Create a full backup and upload to remote server via SCP with password authentication + */ + public function createBackupToScpWithPassword( + string $remoteHost, + string $remoteUser, + string $remotePath, + string $password, + int $remotePort = 22 + ): array { + try { + $params = [ + 'host' => $remoteHost, + 'user' => $remoteUser, + 'directory' => $remotePath, + 'password' => $password, + 'port' => $remotePort, + ]; + + Log::info('cPanel backup to SCP (password) initiated', [ + 'host' => $remoteHost, + 'user' => $remoteUser, + 'path' => $remotePath, + 'port' => $remotePort, + ]); + + // Use longer timeout for backup operations (120 seconds) + $result = $this->uapiWithTimeout('Backup', 'fullbackup_to_scp_with_password', $params, 120); + + return [ + 'success' => true, + 'message' => 'Backup initiated with SCP transfer', + 'pid' => $result['result']['data']['pid'] ?? null, + 'data' => $result['result']['data'] ?? [], + ]; + } catch (Exception $e) { + Log::error('cPanel backup to SCP (password) failed: '.$e->getMessage()); + + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + } + + /** + * Export a database + */ + public function exportDatabase(string $database): array + { + try { + // Use mysqldump via cPanel's backup API + $result = $this->uapi('Mysql', 'dump_database', [ + 'dbname' => $database, + ]); + + return [ + 'success' => true, + 'data' => $result['result']['data'] ?? '', + ]; + } catch (Exception $e) { + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + } + + /** + * Get SSL certificate for a domain + */ + public function getSslCertificate(string $domain): array + { + try { + $result = $this->uapi('SSL', 'fetch_best_for_domain', [ + 'domain' => $domain, + ]); + + return [ + 'success' => true, + 'certificate' => $result['result']['data'] ?? [], + ]; + } catch (Exception $e) { + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + } + + /** + * Download a file from cPanel + */ + public function downloadFile(string $path): ?string + { + $url = $this->getBaseUrl().'/download?file='.urlencode($path); + + try { + $response = Http::withHeaders([ + 'Authorization' => "cpanel {$this->username}:{$this->apiToken}", + ]) + ->timeout(300) + ->withoutVerifying() + ->get($url); + + if ($response->successful()) { + return $response->body(); + } + + return null; + } catch (Exception $e) { + Log::error('cPanel download error: '.$e->getMessage()); + + return null; + } + } + + /** + * Delete/revoke the current API token from cPanel + * This should be called after migration is complete for security + */ + public function revokeApiToken(): array + { + try { + Log::info('Attempting to revoke cPanel API token'); + + // First, list all tokens to find the current one + $listResult = $this->uapi('Tokens', 'list'); + $tokens = $listResult['result']['data'] ?? []; + + Log::info('Found API tokens', ['count' => count($tokens)]); + + // The current token should be one of these - we'll try to identify and revoke it + // cPanel doesn't directly tell us which token we're using, but we can revoke by name + // If the token was created with a specific name, we can target it + + // Try to revoke all tokens (user should create a new one if needed) + // Or we can try to identify the token by checking which one works + $revoked = false; + foreach ($tokens as $token) { + $tokenName = $token['name'] ?? ''; + if (empty($tokenName)) { + continue; + } + + // Try to revoke this token + try { + $revokeResult = $this->uapi('Tokens', 'revoke', [ + 'name' => $tokenName, + ]); + + if (($revokeResult['result']['status'] ?? 0) == 1) { + Log::info('Revoked API token', ['name' => $tokenName]); + $revoked = true; + // After revoking the current token, subsequent API calls will fail + // So we should stop here + break; + } + } catch (Exception $e) { + // If we get an auth error, we probably just revoked our own token + if (str_contains($e->getMessage(), 'Authorization') || str_contains($e->getMessage(), '401')) { + Log::info('Token likely revoked (auth failed)', ['name' => $tokenName]); + $revoked = true; + break; + } + Log::warning('Failed to revoke token', ['name' => $tokenName, 'error' => $e->getMessage()]); + } + } + + if ($revoked) { + return [ + 'success' => true, + 'message' => 'API token revoked successfully', + ]; + } + + return [ + 'success' => false, + 'message' => 'Could not identify token to revoke', + ]; + } catch (Exception $e) { + // Auth failure after revoke is actually success + if (str_contains($e->getMessage(), 'Authorization') || str_contains($e->getMessage(), '401')) { + return [ + 'success' => true, + 'message' => 'API token revoked (connection closed)', + ]; + } + + Log::error('Failed to revoke API token: '.$e->getMessage()); + + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + } + + /** + * Revoke a specific API token by name + */ + public function revokeApiTokenByName(string $tokenName): array + { + try { + Log::info('Revoking cPanel API token by name', ['name' => $tokenName]); + + $result = $this->uapi('Tokens', 'revoke', [ + 'name' => $tokenName, + ]); + + if (($result['result']['status'] ?? 0) == 1) { + return [ + 'success' => true, + 'message' => 'API token revoked successfully', + ]; + } + + $error = $result['result']['errors'][0] ?? 'Unknown error'; + throw new Exception($error); + } catch (Exception $e) { + // Auth failure after revoke means it worked + if (str_contains($e->getMessage(), 'Authorization') || str_contains($e->getMessage(), '401')) { + return [ + 'success' => true, + 'message' => 'API token revoked', + ]; + } + + Log::error('Failed to revoke API token: '.$e->getMessage()); + + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + } + + /** + * Get a comprehensive migration summary + */ + public function getMigrationSummary(): array + { + $summary = [ + 'success' => true, + 'domains' => [ + 'main' => '', + 'addon' => [], + 'sub' => [], + 'parked' => [], + ], + 'databases' => [], + 'email_accounts' => [], + 'forwarders' => [], + 'ssl_certificates' => [], + 'cron_jobs' => [], + 'errors' => [], + ]; + + // Get domains + try { + $domains = $this->listDomains(); + if ($domains['success']) { + $summary['domains'] = [ + 'main' => $domains['main_domain'] ?? '', + 'addon' => $domains['addon_domains'] ?? [], + 'sub' => $domains['sub_domains'] ?? [], + 'parked' => $domains['parked_domains'] ?? [], + ]; + } else { + $summary['errors'][] = 'Domains: '.($domains['message'] ?? 'Unknown error'); + } + } catch (Exception $e) { + Log::warning('cPanel migration - failed to list domains: '.$e->getMessage()); + $summary['errors'][] = 'Domains: '.$e->getMessage(); + } + + // Get databases + try { + $databases = $this->listDatabases(); + if ($databases['success']) { + $summary['databases'] = $databases['databases'] ?? []; + } else { + $summary['errors'][] = 'Databases: '.($databases['message'] ?? 'Unknown error'); + } + } catch (Exception $e) { + Log::warning('cPanel migration - failed to list databases: '.$e->getMessage()); + $summary['errors'][] = 'Databases: '.$e->getMessage(); + } + + // Get email accounts + try { + $emails = $this->listEmailAccounts(); + if ($emails['success']) { + $summary['email_accounts'] = $emails['accounts'] ?? []; + } else { + $summary['errors'][] = 'Email: '.($emails['message'] ?? 'Unknown error'); + } + } catch (Exception $e) { + Log::warning('cPanel migration - failed to list email accounts: '.$e->getMessage()); + $summary['errors'][] = 'Email: '.$e->getMessage(); + } + + // Get forwarders + try { + $forwarders = $this->listForwarders(); + if ($forwarders['success']) { + $summary['forwarders'] = $forwarders['forwarders'] ?? []; + } + } catch (Exception $e) { + Log::warning('cPanel migration - failed to list forwarders: '.$e->getMessage()); + } + + // Get SSL certificates + try { + $ssl = $this->listSslCertificates(); + if ($ssl['success']) { + $summary['ssl_certificates'] = $ssl['certificates'] ?? []; + } + } catch (Exception $e) { + Log::warning('cPanel migration - failed to list SSL certificates: '.$e->getMessage()); + } + + // Get cron jobs + try { + $cron = $this->listCronJobs(); + if ($cron['success']) { + $summary['cron_jobs'] = $cron['cron_jobs'] ?? []; + } + } catch (Exception $e) { + Log::warning('cPanel migration - failed to list cron jobs: '.$e->getMessage()); + } + + // Log summary for debugging + Log::info('cPanel migration summary', [ + 'domains_main' => $summary['domains']['main'], + 'domains_addon_count' => count($summary['domains']['addon']), + 'databases_count' => count($summary['databases']), + 'email_accounts_count' => count($summary['email_accounts']), + 'errors' => $summary['errors'], + ]); + + return $summary; + } +} diff --git a/app/Services/Migration/MigrationDnsSyncService.php b/app/Services/Migration/MigrationDnsSyncService.php new file mode 100644 index 0000000..4683ded --- /dev/null +++ b/app/Services/Migration/MigrationDnsSyncService.php @@ -0,0 +1,291 @@ +>|null $domainNames + */ + public function syncDomainsForUser(User $user, ?array $domainNames = null): void + { + $query = Domain::query()->where('user_id', $user->id); + + if ($domainNames !== null) { + $normalized = $this->normalizeDomainNames($domainNames); + if ($normalized === []) { + return; + } + + $query->whereIn('domain', $normalized); + } + + /** @var EloquentCollection $domains */ + $domains = $query->get(); + + foreach ($domains as $domain) { + $this->syncDomain($domain); + } + } + + public function syncDomain(Domain $domain): void + { + try { + $records = $this->ensureDnsRecords($domain); + if ($records->isEmpty()) { + return; + } + + $settings = DnsSetting::getAll(); + $hostname = $this->getServerHostname(); + $serverIp = $this->getServerIp(); + $serverIpv6 = $settings['default_ipv6'] ?? null; + + $this->agent->send('dns.sync_zone', [ + 'domain' => $domain->domain, + 'records' => $this->formatRecords($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) { + Log::warning("Failed to sync DNS zone for {$domain->domain}: {$e->getMessage()}"); + } + } + + /** + * @return Collection + */ + protected function ensureDnsRecords(Domain $domain): Collection + { + $records = $domain->dnsRecords()->get(); + $defaultRecords = $this->getDefaultRecords($domain); + + foreach ($defaultRecords as $record) { + if (! $this->shouldCreateDefaultRecord($records, $record)) { + continue; + } + + $domain->dnsRecords()->create($record); + } + + return $domain->dnsRecords()->get(); + } + + /** + * @return array> + */ + protected function getDefaultRecords(Domain $domain): array + { + $settings = DnsSetting::getAll(); + $defaultIp = $domain->ip_address ?: ($settings['default_ip'] ?? $this->getServerIp()); + $defaultIpv6 = $domain->ipv6_address ?: ($settings['default_ipv6'] ?? null); + $defaultTtl = (int) ($settings['default_ttl'] ?? 3600); + $hostname = $this->getServerHostname(); + $ns1 = $settings['ns1'] ?? "ns1.{$hostname}"; + $ns2 = $settings['ns2'] ?? "ns2.{$hostname}"; + + $records = [ + ['name' => '@', 'type' => 'NS', 'content' => $ns1, 'ttl' => $defaultTtl], + ['name' => '@', 'type' => 'NS', 'content' => $ns2, 'ttl' => $defaultTtl], + ['name' => '@', 'type' => 'A', 'content' => $defaultIp, 'ttl' => $defaultTtl], + ['name' => 'www', 'type' => 'A', 'content' => $defaultIp, 'ttl' => $defaultTtl], + ['name' => 'mail', 'type' => 'A', 'content' => $defaultIp, 'ttl' => $defaultTtl], + ['name' => '@', 'type' => 'MX', 'content' => "mail.{$domain->domain}", 'ttl' => $defaultTtl, 'priority' => 10], + ['name' => '@', 'type' => 'TXT', 'content' => 'v=spf1 mx a ~all', 'ttl' => $defaultTtl], + ['name' => '_dmarc', 'type' => 'TXT', 'content' => "v=DMARC1; p=none; rua=mailto:postmaster@{$domain->domain}", 'ttl' => $defaultTtl], + ]; + + if (! empty($defaultIpv6)) { + $records[] = ['name' => '@', 'type' => 'AAAA', 'content' => $defaultIpv6, 'ttl' => $defaultTtl]; + $records[] = ['name' => 'www', 'type' => 'AAAA', 'content' => $defaultIpv6, 'ttl' => $defaultTtl]; + $records[] = ['name' => 'mail', 'type' => 'AAAA', 'content' => $defaultIpv6, 'ttl' => $defaultTtl]; + } + + $records = $this->appendNameserverRecords( + $records, + $domain->domain, + $ns1, + $ns2, + $defaultIp, + $defaultIpv6, + $defaultTtl + ); + + return $records; + } + + /** + * @param Collection $records + * @param array $record + */ + protected function shouldCreateDefaultRecord(Collection $records, array $record): bool + { + $name = $record['name'] ?? ''; + $type = $record['type'] ?? ''; + + if ($type === 'TXT' && $name === '@') { + return ! $records->contains(function (DnsRecord $existing): bool { + return $existing->type === 'TXT' + && $existing->name === '@' + && str_contains(strtolower($existing->content), 'v=spf1'); + }); + } + + if ($type === 'TXT' && $name === '_dmarc') { + return ! $records->contains(function (DnsRecord $existing): bool { + return $existing->type === 'TXT' + && $existing->name === '_dmarc'; + }); + } + + return ! $records->contains(function (DnsRecord $existing) use ($name, $type): bool { + return $existing->type === $type + && $existing->name === $name; + }); + } + + /** + * @param array> $records + * @return array> + */ + 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]; + } + + if ($ipv6 && ! in_array($label, $existingAAAA, true)) { + $records[] = ['name' => $label, 'type' => 'AAAA', 'content' => $ipv6, 'ttl' => $ttl]; + } + } + + return $records; + } + + /** + * @param array $nameservers + * @return array + */ + 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)); + } + + /** + * @param Collection $records + * @return array> + */ + protected function formatRecords(Collection $records): array + { + return $records->map(static function (DnsRecord $record): array { + return [ + 'name' => $record->name, + 'type' => $record->type, + 'content' => $record->content, + 'ttl' => $record->ttl, + 'priority' => $record->priority, + ]; + })->all(); + } + + /** + * @param array> $domains + * @return array + */ + protected function normalizeDomainNames(array $domains): array + { + $names = []; + + foreach ($domains as $domain) { + if (is_array($domain)) { + $name = $domain['name'] ?? $domain['domain'] ?? null; + } else { + $name = $domain; + } + + if (! is_string($name) || $name === '') { + continue; + } + + $names[] = strtolower(trim($name)); + } + + return array_values(array_unique($names)); + } + + protected function getServerHostname(): string + { + return gethostname() ?: 'localhost'; + } + + protected function getServerIp(): string + { + $ip = trim(shell_exec("hostname -I | awk '{print $1}'") ?? ''); + + return $ip !== '' ? $ip : '127.0.0.1'; + } +} diff --git a/app/Services/Migration/WhmApiService.php b/app/Services/Migration/WhmApiService.php new file mode 100644 index 0000000..fa79122 --- /dev/null +++ b/app/Services/Migration/WhmApiService.php @@ -0,0 +1,962 @@ +hostname = rtrim(trim($hostname), '/'); + $this->username = trim($username); + $this->apiToken = trim($apiToken); + $this->port = $port; + $this->ssl = $ssl; + } + + /** + * Get the base URL for API calls + */ + private function getBaseUrl(): string + { + $protocol = $this->ssl ? 'https' : 'http'; + + return "{$protocol}://{$this->hostname}:{$this->port}"; + } + + /** + * Make a WHMAPI1 request + */ + public function whmapi(string $function, array $params = [], int $timeout = 120): array + { + $url = $this->getBaseUrl()."/json-api/{$function}"; + $params['api.version'] = 1; + + Log::info('WHM API request', ['function' => $function, 'url' => $url]); + + try { + $response = Http::withHeaders([ + 'Authorization' => "whm {$this->username}:{$this->apiToken}", + ]) + ->timeout($timeout) + ->connectTimeout(10) + ->withoutVerifying() + ->get($url, $params); + + if (! $response->successful()) { + throw new Exception('WHM API request failed: '.$response->status()); + } + + $data = $response->json(); + + // WHM API returns metadata.result = 1 on success + if (($data['metadata']['result'] ?? 0) !== 1) { + $reason = $data['metadata']['reason'] ?? 'Unknown error'; + throw new Exception("WHM API error: {$reason}"); + } + + return $data; + } catch (Exception $e) { + Log::error('WHM API error', ['function' => $function, 'error' => $e->getMessage()]); + throw $e; + } + } + + /** + * Test connection to WHM + */ + public function testConnection(): array + { + try { + $result = $this->whmapi('version'); + + return [ + 'success' => true, + 'version' => $result['data']['version'] ?? 'Unknown', + ]; + } catch (Exception $e) { + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + } + + /** + * List all cPanel accounts on the WHM server + */ + public function listAccounts(): array + { + try { + $result = $this->whmapi('listaccts'); + $accounts = $result['data']['acct'] ?? []; + + return [ + 'success' => true, + 'accounts' => array_map(fn ($acct) => [ + 'user' => $acct['user'] ?? '', + 'domain' => $acct['domain'] ?? '', + 'email' => $acct['email'] ?? '', + 'diskused' => $acct['diskused'] ?? '0M', + 'disklimit' => $acct['disklimit'] ?? 'unlimited', + 'plan' => $acct['plan'] ?? '', + 'startdate' => $acct['startdate'] ?? '', + 'suspended' => ($acct['suspended'] ?? 0) == 1, + 'ip' => $acct['ip'] ?? '', + 'shell' => $acct['shell'] ?? '', + 'owner' => $acct['owner'] ?? '', + ], $accounts), + ]; + } catch (Exception $e) { + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + } + + /** + * Get summary for a specific cPanel account + */ + public function getAccountSummary(string $user): array + { + try { + $result = $this->whmapi('accountsummary', ['user' => $user]); + $acct = $result['data']['acct'][0] ?? []; + + return [ + 'success' => true, + 'account' => [ + 'user' => $acct['user'] ?? '', + 'domain' => $acct['domain'] ?? '', + 'email' => $acct['email'] ?? '', + 'diskused' => $acct['diskused'] ?? '0M', + 'disklimit' => $acct['disklimit'] ?? 'unlimited', + 'plan' => $acct['plan'] ?? '', + 'suspended' => ($acct['suspended'] ?? 0) == 1, + 'ip' => $acct['ip'] ?? '', + 'partition' => $acct['partition'] ?? '', + 'homedir' => $acct['homedir'] ?? "/home/{$user}", + ], + ]; + } catch (Exception $e) { + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + } + + /** + * Make an API2 request through WHM for a specific user + * Uses /json-api/cpanel endpoint with cpanel_jsonapi_apiversion=2 + */ + public function api2(string $user, string $module, string $function, array $params = [], int $timeout = 120): array + { + $url = $this->getBaseUrl().'/json-api/cpanel'; + + // Build query params with correct WHM API2 proxy parameter names + $queryParams = [ + 'cpanel_jsonapi_user' => $user, + 'cpanel_jsonapi_module' => $module, + 'cpanel_jsonapi_func' => $function, + 'cpanel_jsonapi_apiversion' => 2, + ]; + + // Add function-specific parameters + foreach ($params as $key => $value) { + $queryParams[$key] = $value; + } + + Log::info('WHM API2 request', [ + 'user' => $user, + 'module' => $module, + 'function' => $function, + ]); + + try { + $response = Http::withHeaders([ + 'Authorization' => "whm {$this->username}:{$this->apiToken}", + ]) + ->timeout($timeout) + ->connectTimeout(10) + ->withoutVerifying() + ->get($url, $queryParams); + + if (! $response->successful()) { + throw new Exception('WHM API2 request failed: '.$response->status()); + } + + $data = $response->json(); + + Log::info('WHM API2 response', ['user' => $user, 'module' => $module, 'function' => $function]); + + // Return data - let calling function handle cpanelresult errors + // (some errors like "already exists" should be handled gracefully) + return $data; + } catch (Exception $e) { + Log::error('WHM API2 error', ['user' => $user, 'module' => $module, 'function' => $function, 'error' => $e->getMessage()]); + throw $e; + } + } + + /** + * Make a UAPI request through WHM for a specific user + * Uses /json-api/cpanel endpoint with cpanel_jsonapi_apiversion=3 + */ + public function uapi(string $user, string $module, string $function, array $params = [], int $timeout = 120): array + { + // WHM UAPI proxy endpoint is /json-api/cpanel with apiversion=3 + $url = $this->getBaseUrl().'/json-api/cpanel'; + + // Build query params with correct WHM UAPI proxy parameter names + $queryParams = [ + 'cpanel_jsonapi_user' => $user, + 'cpanel_jsonapi_module' => $module, + 'cpanel_jsonapi_func' => $function, + 'cpanel_jsonapi_apiversion' => 3, + ]; + + // Add function-specific parameters + foreach ($params as $key => $value) { + $queryParams[$key] = $value; + } + + Log::info('WHM UAPI request', [ + 'user' => $user, + 'module' => $module, + 'function' => $function, + 'url' => $url, + 'params' => array_keys($queryParams), + ]); + + try { + $response = Http::withHeaders([ + 'Authorization' => "whm {$this->username}:{$this->apiToken}", + ]) + ->timeout($timeout) + ->connectTimeout(10) + ->withoutVerifying() + ->get($url, $queryParams); + + if (! $response->successful()) { + throw new Exception('WHM UAPI request failed: '.$response->status()); + } + + $data = $response->json(); + + Log::info('WHM UAPI response', ['user' => $user, 'module' => $module, 'function' => $function, 'data' => $data]); + + // UAPI through WHM returns result.status = 1 on success + // But the structure may be wrapped differently + if (isset($data['result']['status'])) { + if (($data['result']['status'] ?? 0) !== 1) { + $errors = $data['result']['errors'] ?? []; + $errorMsg = is_array($errors) ? ($errors[0] ?? 'Unknown error') : $errors; + throw new Exception("UAPI error: {$errorMsg}"); + } + } elseif (isset($data['cpanelresult']['error'])) { + // Legacy response format + throw new Exception('UAPI error: '.$data['cpanelresult']['error']); + } elseif (isset($data['error'])) { + throw new Exception('UAPI error: '.$data['error']); + } + + return $data; + } catch (Exception $e) { + Log::error('WHM UAPI error', ['user' => $user, 'module' => $module, 'function' => $function, 'error' => $e->getMessage()]); + throw $e; + } + } + + /** + * Create a full backup for a specific user via WHM + * Uses UAPI through WHM proxy + */ + public function createBackupForUser(string $user): array + { + try { + // Use UAPI Backup::fullbackup_to_homedir via WHM proxy + $result = $this->uapi($user, 'Backup', 'fullbackup_to_homedir', [], 300); + + Log::info('WHM createBackupForUser response', ['user' => $user, 'result' => $result]); + + // Extract data from potentially different response structures + $data = $result['result']['data'] ?? $result['cpanelresult']['data'] ?? $result['data'] ?? []; + + // Handle array or object data + if (is_array($data) && isset($data[0])) { + $data = $data[0]; + } + + return [ + 'success' => true, + 'message' => 'Backup initiated', + 'pid' => $data['pid'] ?? $data['backup_id'] ?? null, + 'data' => $data, + ]; + } catch (Exception $e) { + Log::error('WHM createBackupForUser error', ['user' => $user, 'error' => $e->getMessage()]); + + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + } + + /** + * List backup files for a user via WHM UAPI + */ + public function listBackupsForUser(string $user): array + { + try { + // Get account info to find homedir + $acctResult = $this->whmapi('accountsummary', ['user' => $user]); + $acct = $acctResult['data']['acct'][0] ?? []; + $homedir = $acct['homedir'] ?? "/home/{$user}"; + + // Use UAPI Backup::list_backups via WHM proxy + $result = $this->uapi($user, 'Backup', 'list_backups'); + + // Extract backups from potentially different response structures + $backups = $result['result']['data'] ?? $result['cpanelresult']['data'] ?? $result['data'] ?? []; + + // Format the backups + $formattedBackups = []; + foreach ($backups as $backup) { + $file = $backup['file'] ?? $backup['backupID'] ?? $backup['backup'] ?? ''; + if (empty($file)) { + continue; + } + + $formattedBackups[] = [ + 'file' => $file, + 'status' => $backup['status'] ?? 'complete', + 'time' => $backup['mtime'] ?? $backup['time'] ?? 0, + 'localtime' => $backup['localtime'] ?? '', + 'path' => "{$homedir}/{$file}", + ]; + } + + return [ + 'success' => true, + 'homedir' => $homedir, + 'backups' => $formattedBackups, + ]; + } catch (Exception $e) { + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + } + + /** + * Check backup status for a user by looking for backup files + */ + public function getBackupStatusForUser(string $user): array + { + try { + // Get account info first + $acctResult = $this->whmapi('accountsummary', ['user' => $user]); + $acct = $acctResult['data']['acct'][0] ?? []; + $homedir = $acct['homedir'] ?? "/home/{$user}"; + + // Use UAPI Fileman::list_files to check homedir via WHM proxy + $result = $this->uapi($user, 'Fileman', 'list_files', [ + 'dir' => $homedir, + 'include_mime' => 0, + 'include_permissions' => 0, + 'include_hash' => 0, + 'include_content' => 0, + ]); + + // Extract from potentially different response structures + $files = $result['result']['data'] ?? $result['cpanelresult']['data'] ?? $result['data'] ?? []; + + $backupFiles = []; + $inProgress = false; + + foreach ($files as $file) { + $name = $file['file'] ?? $file['name'] ?? $file['fullpath'] ?? ''; + + // Extract just the filename if full path + if (str_contains($name, '/')) { + $name = basename($name); + } + + // cPanel backup files: backup-MM.DD.YYYY_HH-mm-ss_username.tar.gz + if (preg_match('/^backup-\d+\.\d+\.\d+_\d+-\d+-\d+_.*\.tar\.gz$/', $name)) { + $backupFiles[] = [ + 'name' => $name, + 'size' => (int) ($file['size'] ?? 0), + 'mtime' => (int) ($file['mtime'] ?? $file['ctime'] ?? 0), + 'path' => "{$homedir}/{$name}", + ]; + } + + // Check for in-progress backup indicator + if (str_contains($name, 'backup') && str_ends_with($name, '.log')) { + $inProgress = true; + } + } + + // Sort by modification time, newest first + usort($backupFiles, fn ($a, $b) => ($b['mtime'] ?? 0) - ($a['mtime'] ?? 0)); + + return [ + 'success' => true, + 'in_progress' => $inProgress, + 'backups' => $backupFiles, + 'homedir' => $homedir, + ]; + } catch (Exception $e) { + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + } + + /** + * Get migration summary for a specific user via WHM UAPI + */ + public function getUserMigrationSummary(string $user): array + { + $summary = [ + 'success' => true, + 'user' => $user, + 'domains' => [ + 'main' => '', + 'addon' => [], + 'sub' => [], + 'parked' => [], + ], + 'databases' => [], + 'email_accounts' => [], + 'email_forwarders' => [], + 'ssl_certificates' => [], + 'errors' => [], + ]; + + // Get domains + try { + $result = $this->uapi($user, 'DomainInfo', 'list_domains'); + + // Extract from potentially different response structures + $data = $result['result']['data'] ?? $result['cpanelresult']['data'] ?? $result['data'] ?? []; + + // Handle array or single object + if (is_array($data) && isset($data[0]) && is_array($data[0])) { + $data = $data[0]; + } + + $summary['domains'] = [ + 'main' => $data['main_domain'] ?? '', + 'addon' => $data['addon_domains'] ?? [], + 'sub' => $data['sub_domains'] ?? [], + 'parked' => $data['parked_domains'] ?? $data['alias_domains'] ?? [], + ]; + } catch (Exception $e) { + Log::warning("WHM migration - failed to list domains for {$user}: ".$e->getMessage()); + $summary['errors'][] = 'Domains: '.$e->getMessage(); + } + + // Get databases + try { + $result = $this->uapi($user, 'Mysql', 'list_databases'); + + $summary['databases'] = $result['result']['data'] ?? $result['cpanelresult']['data'] ?? $result['data'] ?? []; + } catch (Exception $e) { + Log::warning("WHM migration - failed to list databases for {$user}: ".$e->getMessage()); + $summary['errors'][] = 'Databases: '.$e->getMessage(); + } + + // Get email accounts + try { + $result = $this->uapi($user, 'Email', 'list_pops_with_disk'); + + $summary['email_accounts'] = $result['result']['data'] ?? $result['cpanelresult']['data'] ?? $result['data'] ?? []; + } catch (Exception $e) { + Log::warning("WHM migration - failed to list email accounts for {$user}: ".$e->getMessage()); + $summary['errors'][] = 'Email: '.$e->getMessage(); + } + + // Get email forwarders + try { + $result = $this->uapi($user, 'Email', 'list_forwarders'); + + $summary['email_forwarders'] = $result['result']['data'] ?? $result['cpanelresult']['data'] ?? $result['data'] ?? []; + } catch (Exception $e) { + Log::warning("WHM migration - failed to list email forwarders for {$user}: ".$e->getMessage()); + // Forwarders are optional, don't add to errors + } + + // Get SSL certificates + try { + $result = $this->uapi($user, 'SSL', 'list_certs'); + + $summary['ssl_certificates'] = $result['result']['data'] ?? $result['cpanelresult']['data'] ?? $result['data'] ?? []; + } catch (Exception $e) { + Log::warning("WHM migration - failed to list SSL certificates for {$user}: ".$e->getMessage()); + } + + return $summary; + } + + /** + * Get WHM server hostname + */ + public function getHostname(): string + { + return $this->hostname; + } + + /** + * Get the authenticated WHM username + */ + public function getUsername(): string + { + return $this->username; + } + + /** + * Get WHM version information + */ + public function getVersion(): array + { + try { + $result = $this->whmapi('version'); + + return [ + 'success' => true, + 'version' => $result['data']['version'] ?? 'Unknown', + ]; + } catch (Exception $e) { + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + } + + /** + * Download a file from a cPanel user's homedir via WHM + * Uses cPanel session-based download for reliability + */ + public function downloadFileFromUser(string $user, string $remotePath, string $localPath, ?callable $progressCallback = null): array + { + Log::info('WHM download starting', ['user' => $user, 'remote' => $remotePath, 'local' => $localPath]); + + try { + // Step 1: Create a cPanel session for the user + $sessionResult = $this->whmapi('create_user_session', [ + 'user' => $user, + 'service' => 'cpaneld', + ]); + + $sessionUrl = $sessionResult['data']['url'] ?? null; + if (! $sessionUrl) { + throw new Exception('Failed to create cPanel session'); + } + + Log::info('WHM session created', ['user' => $user, 'session_url' => $sessionUrl]); + + // Extract the session token and base URL from the session URL + // Session URL format: https://hostname:2083/cpsess1234567890/... + if (preg_match('#^(https?://[^/]+)(/.*)$#', $sessionUrl, $matches)) { + $baseUrl = $matches[1]; + $sessionPath = $matches[2]; + + // Extract session token from path (cpsessXXXXXX) + if (preg_match('#/(cpsess[^/]+)/#', $sessionPath, $sessMatches)) { + $sessionToken = $sessMatches[1]; + } else { + throw new Exception('Could not extract session token from URL'); + } + } else { + throw new Exception('Invalid session URL format'); + } + + // Step 2: Build the download URL using the session + // cPanel download URL format: /cpsessXXXX/download?file=/path/to/file + $downloadUrl = "{$baseUrl}/{$sessionToken}/download?skipencode=1&file=".urlencode($remotePath); + + Log::info('WHM download URL', ['url' => $downloadUrl]); + + // Step 3: Download using curl for better reliability + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $downloadUrl, + CURLOPT_RETURNTRANSFER => false, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => false, + CURLOPT_TIMEOUT => 3600, + CURLOPT_CONNECTTIMEOUT => 30, + ]); + + // Ensure local directory exists + $localDir = dirname($localPath); + if (! is_dir($localDir)) { + mkdir($localDir, 0755, true); + } + + // Open local file for writing + $fp = fopen($localPath, 'wb'); + if (! $fp) { + curl_close($ch); + throw new Exception("Failed to open local file for writing: {$localPath}"); + } + + curl_setopt($ch, CURLOPT_FILE, $fp); + + // Execute download + $result = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + curl_close($ch); + fclose($fp); + + // Check for errors + if ($result === false || ! empty($error)) { + @unlink($localPath); + throw new Exception("cURL error: {$error}"); + } + + if ($httpCode !== 200) { + // Read what was downloaded to check for error message + $content = file_get_contents($localPath); + @unlink($localPath); + + if (str_contains($content, ' $user, + 'remote' => $remotePath, + 'local' => $localPath, + 'size' => $fileSize, + ]); + + return [ + 'success' => true, + 'path' => $localPath, + 'size' => $fileSize, + ]; + } catch (Exception $e) { + Log::error('WHM download error: '.$e->getMessage(), [ + 'user' => $user, + 'remote' => $remotePath, + 'local' => $localPath, + ]); + + // Clean up partial download + if (file_exists($localPath)) { + @unlink($localPath); + } + + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + } + + /** + * Import an SSH private key to a cPanel user account via WHM + * Uses API2 SSH::importkey through WHM proxy + */ + public function importSshPrivateKey(string $user, string $keyName, string $privateKey, string $passphrase = ''): array + { + try { + Log::info('WHM: Importing SSH private key to cPanel user', ['user' => $user, 'key_name' => $keyName]); + + $params = [ + 'key' => $privateKey, + 'name' => $keyName, + ]; + + if (! empty($passphrase)) { + $params['pass'] = $passphrase; + } + + $result = $this->api2($user, 'SSH', 'importkey', $params); + + Log::info('WHM SSH private key import response', ['user' => $user, 'result' => $result]); + + $data = $result['cpanelresult']['data'][0] ?? []; + $apiError = $result['cpanelresult']['error'] ?? ''; + + // Check for "already exists" which is OK - extract actual key name if different + $reasonText = $apiError ?: ($data['reason'] ?? ''); + if (str_contains($reasonText, 'already exists')) { + // Extract the actual key name if provided (format: "already exists as keyname") + $actualKeyName = $keyName; + if (preg_match('/already exists as ([^\s.]+)/', $reasonText, $matches)) { + $actualKeyName = $matches[1]; + } + + return [ + 'success' => true, + 'message' => 'SSH key already exists', + 'actual_key_name' => $actualKeyName, + ]; + } + + // Check for API-level error + if ($apiError) { + throw new Exception($apiError); + } + + // Check for success + $eventResult = $result['cpanelresult']['event']['result'] ?? null; + if ($eventResult == 1 || (isset($data['result']) && $data['result'] == 1)) { + return [ + 'success' => true, + 'message' => 'SSH private key imported successfully', + ]; + } + + $errorMsg = $data['reason'] ?? $data['error'] ?? 'Failed to import SSH key'; + throw new Exception($errorMsg); + } catch (Exception $e) { + Log::error('WHM SSH private key import failed', ['user' => $user, 'error' => $e->getMessage()]); + + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + } + + /** + * Authorize an SSH key for a cPanel user via WHM + * Uses API2 SSH::authkey through WHM proxy + */ + public function authorizeSshKey(string $user, string $keyName): array + { + try { + Log::info('WHM: Authorizing SSH key for cPanel user', ['user' => $user, 'key_name' => $keyName]); + + $result = $this->api2($user, 'SSH', 'authkey', [ + 'key' => $keyName, + 'action' => 'authorize', + ]); + + Log::info('WHM SSH authkey response', ['user' => $user, 'result' => $result]); + + $data = $result['cpanelresult']['data'][0] ?? []; + $apiError = $result['cpanelresult']['error'] ?? ''; + + // Check for "already authorized" which is OK + if (str_contains($apiError, 'already authorized') || str_contains($data['reason'] ?? '', 'already authorized')) { + return [ + 'success' => true, + 'message' => 'SSH key already authorized', + ]; + } + + // Check for API error + if ($apiError) { + throw new Exception($apiError); + } + + // Check for success + $eventResult = $result['cpanelresult']['event']['result'] ?? null; + if ($eventResult == 1 || (isset($data['result']) && $data['result'] == 1)) { + return [ + 'success' => true, + 'message' => 'SSH key authorized successfully', + ]; + } + + $errorMsg = $data['reason'] ?? 'Failed to authorize SSH key'; + throw new Exception($errorMsg); + } catch (Exception $e) { + Log::error('WHM SSH key authorization failed', ['user' => $user, 'error' => $e->getMessage()]); + + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + } + + /** + * Create a full backup and upload to remote server via SCP with key authentication + * Uses UAPI Backup::fullbackup_to_scp_with_key through WHM proxy + */ + public function createBackupToScpWithKey( + string $user, + string $remoteHost, + string $remoteUser, + string $remotePath, + string $keyName, + int $remotePort = 22 + ): array { + try { + $params = [ + 'host' => $remoteHost, + 'username' => $remoteUser, + 'directory' => $remotePath, + 'key_name' => $keyName, + 'port' => $remotePort, + 'key_passphrase' => '', + ]; + + Log::info('WHM: Initiating backup to SCP for user', [ + 'cpanel_user' => $user, + 'host' => $remoteHost, + 'remote_user' => $remoteUser, + 'path' => $remotePath, + 'key_name' => $keyName, + ]); + + $result = $this->uapi($user, 'Backup', 'fullbackup_to_scp_with_key', $params, 120); + + // Extract data from response + $data = $result['result']['data'] ?? $result['cpanelresult']['data'] ?? []; + + return [ + 'success' => true, + 'message' => 'Backup initiated with SCP transfer', + 'pid' => $data['pid'] ?? null, + 'data' => $data, + ]; + } catch (Exception $e) { + Log::error('WHM backup to SCP failed', ['user' => $user, 'error' => $e->getMessage()]); + + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + } + + /** + * Convert API migration summary data to agent-compatible format + */ + public function convertApiDataToAgentFormat(array $apiData): array + { + $result = [ + 'domains' => [], + 'databases' => [], + 'mailboxes' => [], + 'forwarders' => [], + 'ssl_certificates' => [], + ]; + + // Convert domains + $domains = $apiData['domains'] ?? []; + if (! empty($domains['main'])) { + $result['domains'][] = ['name' => $domains['main'], 'type' => 'main']; + } + foreach ($domains['addon'] ?? [] as $domain) { + $result['domains'][] = ['name' => $domain, 'type' => 'addon']; + } + foreach ($domains['sub'] ?? [] as $domain) { + $result['domains'][] = ['name' => $domain, 'type' => 'sub']; + } + foreach ($domains['parked'] ?? [] as $domain) { + $result['domains'][] = ['name' => $domain, 'type' => 'parked']; + } + + // Convert databases + foreach ($apiData['databases'] ?? [] as $db) { + $dbName = is_array($db) ? ($db['database'] ?? $db['name'] ?? '') : $db; + if ($dbName) { + $result['databases'][] = ['name' => $dbName, 'file' => "mysql/{$dbName}.sql"]; + } + } + + // Convert email accounts to mailboxes format + foreach ($apiData['email_accounts'] ?? [] as $email) { + $emailAddr = is_array($email) ? ($email['email'] ?? '') : $email; + if ($emailAddr && str_contains($emailAddr, '@')) { + [$localPart, $domain] = explode('@', $emailAddr, 2); + $result['mailboxes'][] = [ + 'email' => $emailAddr, + 'local_part' => $localPart, + 'domain' => $domain, + ]; + } + } + + // Convert email forwarders + // cPanel forwarder format: {'dest' => 'dest@example.com', 'forward' => 'source@domain.com', 'html_dest' => '...'} + foreach ($apiData['email_forwarders'] ?? [] as $forwarder) { + if (is_array($forwarder)) { + $source = $forwarder['forward'] ?? $forwarder['source'] ?? ''; + $dest = $forwarder['dest'] ?? $forwarder['destination'] ?? ''; + + if ($source && str_contains($source, '@') && $dest) { + [$localPart, $domain] = explode('@', $source, 2); + $result['forwarders'][] = [ + 'email' => $source, + 'local_part' => $localPart, + 'domain' => $domain, + 'destinations' => $dest, // Will be parsed in the restore function + ]; + } + } + } + + // Convert SSL certificates + foreach ($apiData['ssl_certificates'] ?? [] as $cert) { + if (is_array($cert)) { + $domain = $cert['domain'] ?? $cert['friendly_name'] ?? null; + if (! $domain && ! empty($cert['domains'])) { + $domains = is_array($cert['domains']) ? $cert['domains'] : explode(',', $cert['domains']); + $domain = trim($domains[0] ?? ''); + } + if ($domain) { + $result['ssl_certificates'][] = [ + 'domain' => $domain, + 'has_key' => true, + 'has_cert' => true, + ]; + } + } elseif (is_string($cert) && ! empty($cert)) { + $result['ssl_certificates'][] = [ + 'domain' => $cert, + 'has_key' => true, + 'has_cert' => true, + ]; + } + } + + return $result; + } +} diff --git a/app/Services/Migration/WhmMigrationStatusStore.php b/app/Services/Migration/WhmMigrationStatusStore.php new file mode 100644 index 0000000..40abad1 --- /dev/null +++ b/app/Services/Migration/WhmMigrationStatusStore.php @@ -0,0 +1,149 @@ + + */ + public function get(): array + { + $state = Cache::get($this->cacheKey); + + return is_array($state) ? $state : []; + } + + /** + * @param array $state + */ + public function put(array $state): void + { + Cache::put($this->cacheKey, $state, now()->addHours($this->ttlHours)); + } + + public function clear(): void + { + Cache::forget($this->cacheKey); + } + + /** + * @param array $accounts + * @return array + */ + public function initialize(array $accounts): array + { + $status = []; + + foreach ($accounts as $user) { + $status[$user] = [ + 'status' => 'pending', + 'log' => [], + 'progress' => 0, + ]; + } + + $state = [ + 'isMigrating' => true, + 'selectedAccounts' => array_values($accounts), + 'migrationStatus' => $status, + 'startedAt' => now()->toDateTimeString(), + 'completedAt' => null, + ]; + + $this->put($state); + + return $state; + } + + /** + * @return array + */ + public function setMigrating(bool $isMigrating): array + { + $state = $this->get(); + $state['isMigrating'] = $isMigrating; + + if (! $isMigrating) { + $state['completedAt'] = now()->toDateTimeString(); + } + + $this->put($state); + + return $state; + } + + /** + * @return array + */ + public function updateAccountStatus(string $user, string $status, string $message, string $logStatus = 'info'): array + { + $state = $this->get(); + $state = $this->ensureAccount($state, $user); + $state['migrationStatus'][$user]['status'] = $status; + + $this->appendLog($state, $user, $message, $logStatus); + $this->put($state); + + return $state; + } + + /** + * @return array + */ + public function addAccountLog(string $user, string $message, string $status = 'info'): array + { + $state = $this->get(); + $state = $this->ensureAccount($state, $user); + + $this->appendLog($state, $user, $message, $status); + $this->put($state); + + return $state; + } + + /** + * @param array $state + * @return array + */ + protected function ensureAccount(array $state, string $user): array + { + $state['migrationStatus'] ??= []; + $state['selectedAccounts'] ??= []; + + if (! isset($state['migrationStatus'][$user])) { + $state['migrationStatus'][$user] = [ + 'status' => 'pending', + 'log' => [], + 'progress' => 0, + ]; + } + + if (! in_array($user, $state['selectedAccounts'], true)) { + $state['selectedAccounts'][] = $user; + } + + return $state; + } + + /** + * @param array $state + */ + protected function appendLog(array &$state, string $user, string $message, string $status): void + { + $state['migrationStatus'][$user]['log'][] = [ + 'message' => $message, + 'status' => $status, + 'time' => now()->format('H:i:s'), + ]; + } +} diff --git a/app/Services/SysstatMetrics.php b/app/Services/SysstatMetrics.php new file mode 100644 index 0000000..ad761d2 --- /dev/null +++ b/app/Services/SysstatMetrics.php @@ -0,0 +1,606 @@ +, load: array, iowait: array, memory: array, swap: array} + */ + public function history(int $points, int $intervalSeconds, string $labelFormat): array + { + if ($points <= 0 || $intervalSeconds <= 0) { + return []; + } + + $timezone = $this->systemTimezone(); + $end = CarbonImmutable::now($timezone); + if ($intervalSeconds < 60) { + $second = intdiv($end->second, $intervalSeconds) * $intervalSeconds; + $end = $end->setTime($end->hour, $end->minute, $second); + } else { + $end = $end->second(0); + if ($intervalSeconds >= 3600) { + $end = $end->minute(0); + } + if ($intervalSeconds >= 86400) { + $end = $end->hour(0)->minute(0); + } + } + $start = $end->subSeconds(($points - 1) * $intervalSeconds); + $endBucket = intdiv($end->getTimestamp(), $intervalSeconds); + $cacheKey = sprintf('sysstat.history.%d.%d.%s.%d', $points, $intervalSeconds, $labelFormat, $endBucket); + $ttl = $this->cacheTtl($intervalSeconds); + + return Cache::remember($cacheKey, $ttl, function () use ($start, $end, $points, $intervalSeconds, $labelFormat): array { + $samples = $this->readSamples($start, $end, $this->coreOptions()); + if (empty($samples)) { + return []; + } + + return $this->resample($samples, $start, $points, $intervalSeconds, $labelFormat); + }); + } + + /** + * @return array{load1: float, load5: float, load15: float, iowait: float, memory: float, swap: float}|null + */ + public function latest(): ?array + { + $timezone = $this->systemTimezone(); + $end = CarbonImmutable::now($timezone); + $start = $end->subMinutes(15); + $bucket = intdiv($end->getTimestamp(), 10); + $cacheKey = sprintf('sysstat.latest.%d', $bucket); + + return Cache::remember($cacheKey, now()->addSeconds(10), function () use ($start, $end): ?array { + $samples = $this->readSamples($start, $end, $this->coreOptions()); + if (empty($samples)) { + return null; + } + + $last = end($samples); + if (! is_array($last)) { + return null; + } + + return [ + 'load1' => (float) ($last['load1'] ?? 0), + 'load5' => (float) ($last['load5'] ?? 0), + 'load15' => (float) ($last['load15'] ?? 0), + 'iowait' => (float) ($last['iowait'] ?? 0), + 'memory' => (float) ($last['memory'] ?? 0), + 'swap' => (float) ($last['swap'] ?? 0), + ]; + }); + } + + public function timezoneName(): string + { + return $this->systemTimezone()->getName(); + } + + /** + * @return array + */ + private function readSamples(CarbonImmutable $start, CarbonImmutable $end, array $options): array + { + $samples = []; + $current = $start->startOfDay(); + $lastDay = $end->startOfDay(); + $startTimestamp = $start->getTimestamp(); + $endTimestamp = $end->getTimestamp(); + $useDailyCache = ($endTimestamp - $startTimestamp) > 21600; + + while ($current <= $lastDay) { + $fileLong = sprintf('/var/log/sysstat/sa%s', $current->format('Ymd')); + $fileShort = sprintf('/var/log/sysstat/sa%s', $current->format('d')); + $file = is_readable($fileLong) ? $fileLong : $fileShort; + if (! is_readable($file)) { + $current = $current->addDay(); + + continue; + } + + $dayStart = $current->isSameDay($start) ? $start : $current->startOfDay(); + $dayEnd = $current->isSameDay($end) ? $end : $current->endOfDay(); + if ($useDailyCache) { + $statistics = $this->readSadfCsvDay($file, $current); + } else { + $statistics = $this->readSadfCsv($file, $dayStart, $dayEnd, $options); + } + + foreach ($statistics as $parsed) { + if ($parsed['timestamp'] < $startTimestamp || $parsed['timestamp'] > $endTimestamp) { + continue; + } + $samples[] = $parsed; + } + + $current = $current->addDay(); + } + + usort($samples, static fn (array $a, array $b): int => $a['timestamp'] <=> $b['timestamp']); + + return $samples; + } + + /** + * @return array> + */ + private function readSadf(string $file, CarbonImmutable $start, CarbonImmutable $end, array $options): array + { + $args = array_merge( + [ + 'sadf', + '-j', + '-T', + $file, + '--', + ], + $options, + [ + '-s', + $start->format('H:i:s'), + '-e', + $end->format('H:i:s'), + ], + ); + $process = new Process($args); + $process->setTimeout(8); + $process->setIdleTimeout(5); + $process->run(); + + $output = $this->cleanSadfOutput($process->getOutput()); + if ($output === null) { + return []; + } + + $payload = json_decode($output, true); + if (! is_array($payload)) { + return []; + } + + $stats = $payload['sysstat']['hosts'][0]['statistics'] ?? []; + if (! is_array($stats)) { + return []; + } + + return $stats; + } + + /** + * @return array + */ + private function readSadfCsv(string $file, CarbonImmutable $start, CarbonImmutable $end, array $options): array + { + $datasets = [ + 'queue' => $this->readSadfCsvDataset($file, $start, $end, ['-q']), + 'cpu' => $this->readSadfCsvDataset($file, $start, $end, ['-u']), + 'memory' => $this->readSadfCsvDataset($file, $start, $end, ['-r']), + 'swap' => $this->readSadfCsvDataset($file, $start, $end, ['-S']), + ]; + + $bucket = []; + foreach ($datasets['queue'] as $row) { + $timestamp = $row['timestamp']; + $this->appendSample($bucket, $timestamp, [ + 'load1' => $this->getCsvFloat($row, ['ldavg-1']) ?? 0.0, + 'load5' => $this->getCsvFloat($row, ['ldavg-5']) ?? 0.0, + 'load15' => $this->getCsvFloat($row, ['ldavg-15']) ?? 0.0, + ]); + } + foreach ($datasets['cpu'] as $row) { + $timestamp = $row['timestamp']; + $this->appendSample($bucket, $timestamp, [ + 'iowait' => $this->getCsvFloat($row, ['%iowait', 'iowait']) ?? 0.0, + ]); + } + foreach ($datasets['memory'] as $row) { + $timestamp = $row['timestamp']; + $memory = $this->getCsvFloat($row, ['%memused', '%memused_percent', 'memused-percent', 'memused_percent']); + $this->appendSample($bucket, $timestamp, [ + 'memory' => $memory ?? 0.0, + ]); + } + foreach ($datasets['swap'] as $row) { + $timestamp = $row['timestamp']; + $swap = $this->getCsvFloat($row, ['%swpused', 'swpused-percent', 'swpused_percent']); + $this->appendSample($bucket, $timestamp, [ + 'swap' => $swap ?? 0.0, + ]); + } + + return array_values($bucket); + } + + /** + * @return array + */ + private function readSadfCsvDay(string $file, CarbonImmutable $day): array + { + $mtime = @filemtime($file) ?: 0; + $cacheKey = sprintf('sysstat.sadf.csv.day.%s.%d', md5($file), $mtime); + + return Cache::remember($cacheKey, now()->addMinutes(10), function () use ($file, $day): array { + $dayStart = $day->startOfDay(); + $dayEnd = $day->endOfDay(); + + return $this->readSadfCsv($file, $dayStart, $dayEnd, $this->coreOptions()); + }); + } + + /** + * @return array> + */ + private function readSadfCsvDataset(string $file, CarbonImmutable $start, CarbonImmutable $end, array $options): array + { + $args = array_merge( + [ + 'sadf', + '-d', + $file, + '--', + ], + $options, + ); + $process = new Process($args); + $process->setTimeout(8); + $process->setIdleTimeout(5); + $process->run(); + + $output = $process->getOutput(); + if ($output === '') { + return []; + } + + $lines = preg_split('/\\r\\n|\\r|\\n/', trim($output)) ?: []; + $headers = []; + $rows = []; + + foreach ($lines as $line) { + $line = trim($line); + if ($line === '') { + continue; + } + if (str_starts_with($line, '#')) { + $headerLine = ltrim($line, "# \t"); + $headers = str_getcsv($headerLine, ';'); + continue; + } + if ($headers === []) { + continue; + } + $values = str_getcsv($line, ';'); + if (count($values) < count($headers)) { + continue; + } + $row = array_combine($headers, array_slice($values, 0, count($headers))); + if (! is_array($row)) { + continue; + } + $timestampValue = $this->parseCsvTimestamp($row['timestamp'] ?? null); + if ($timestampValue === null) { + continue; + } + $row['timestamp'] = $timestampValue; + if ($timestampValue < $start->getTimestamp() || $timestampValue > $end->getTimestamp()) { + continue; + } + $rows[] = $row; + } + + return $rows; + } + + /** + * @param array $bucket + */ + private function appendSample(array &$bucket, int $timestamp, array $values): void + { + if (! isset($bucket[$timestamp])) { + $bucket[$timestamp] = [ + 'timestamp' => $timestamp, + 'load1' => 0.0, + 'load5' => 0.0, + 'load15' => 0.0, + 'iowait' => 0.0, + 'memory' => 0.0, + 'swap' => 0.0, + ]; + } + + foreach ($values as $key => $value) { + if ($value !== null) { + $bucket[$timestamp][$key] = (float) $value; + } + } + } + + private function parseCsvTimestamp(string|null $value): ?int + { + if ($value === null || $value === '') { + return null; + } + + try { + return CarbonImmutable::parse($value, $this->systemTimezone())->getTimestamp(); + } catch (\Throwable) { + return null; + } + } + + private function getCsvFloat(array $row, array $keys): ?float + { + foreach ($keys as $key) { + if (! array_key_exists($key, $row)) { + continue; + } + $value = str_replace(',', '.', (string) $row[$key]); + if ($value === '') { + continue; + } + if (is_numeric($value)) { + return (float) $value; + } + } + + return null; + } + + private function cleanSadfOutput(string $output): ?string + { + $start = strpos($output, '{'); + if ($start === false) { + return null; + } + + $trimmed = substr($output, $start); + $trimmed = ltrim($trimmed); + + return $trimmed === '' ? null : $trimmed; + } + + /** + * @return array> + */ + private function readSadfDay(string $file, CarbonImmutable $day, array $options): array + { + $mtime = @filemtime($file) ?: 0; + $optionsKey = implode(',', $options); + $cacheKey = sprintf('sysstat.sadf.day.%s.%d.%s', md5($file), $mtime, md5($optionsKey)); + + return Cache::remember($cacheKey, now()->addMinutes(10), function () use ($file, $day, $options): array { + $dayStart = $day->startOfDay(); + $dayEnd = $day->endOfDay(); + + return $this->readSadf($file, $dayStart, $dayEnd, $options); + }); + } + + /** + * @return array + */ + private function coreOptions(): array + { + return ['-q', '-u', '-r', '-S']; + } + + private function cacheTtl(int $intervalSeconds): \DateInterval|\DateTimeInterface|int + { + if ($intervalSeconds <= 10) { + return 10; + } + + return max(30, min(300, $intervalSeconds)); + } + + /** + * @param array $stat + * @return array{timestamp: int, load1: float, load5: float, load15: float, iowait: float, memory: float, swap: float}|null + */ + private function parseSample(array $stat): ?array + { + $timestamp = $this->parseTimestamp($stat['timestamp'] ?? []); + if ($timestamp === null) { + return null; + } + + $queue = $stat['queue'] ?? []; + $load1 = $this->getFloat($queue, ['ldavg-1', 'ldavg_1']) ?? 0.0; + $load5 = $this->getFloat($queue, ['ldavg-5', 'ldavg_5']) ?? 0.0; + $load15 = $this->getFloat($queue, ['ldavg-15', 'ldavg_15']) ?? 0.0; + + $cpuLoad = $stat['cpu-load'] ?? $stat['cpu-load-all'] ?? []; + $iowait = $this->extractCpuMetric($cpuLoad, 'iowait'); + + $memory = $stat['memory'] ?? []; + $memPercent = $this->getFloat($memory, ['memused-percent', 'memused_percent', 'memused']); + if ($memPercent === null) { + $memPercent = $this->percentFromTotals($memory, 'kbmemused', 'kbmemfree', 'kbmemtotal'); + } + + $swap = $stat['swap'] ?? $stat['memory'] ?? []; + $swapPercent = $this->getFloat($swap, ['swpused-percent', 'swpused_percent', 'swpused']); + if ($swapPercent === null) { + $swapPercent = $this->percentFromTotals($swap, 'kbswpused', 'kbswpfree', 'kbswptotal'); + } + + return [ + 'timestamp' => $timestamp->getTimestamp(), + 'load1' => (float) $load1, + 'load5' => (float) $load5, + 'load15' => (float) $load15, + 'iowait' => (float) ($iowait ?? 0.0), + 'memory' => (float) ($memPercent ?? 0.0), + 'swap' => (float) ($swapPercent ?? 0.0), + ]; + } + + /** + * @return array{labels: array, load: array, iowait: array, memory: array, swap: array} + */ + private function resample(array $samples, CarbonImmutable $start, int $points, int $intervalSeconds, string $labelFormat): array + { + $labels = []; + $loadSeries = []; + $ioWaitSeries = []; + $memorySeries = []; + $swapSeries = []; + + $index = 0; + $current = null; + $first = $samples[0] ?? null; + $count = count($samples); + + for ($i = 0; $i < $points; $i++) { + $bucketTime = $start->addSeconds($i * $intervalSeconds); + while ($index < $count && $samples[$index]['timestamp'] <= $bucketTime->getTimestamp()) { + $current = $samples[$index]; + $index++; + } + + $sample = $current ?? $first; + $labels[] = $bucketTime->format($labelFormat); + $loadSeries[] = $sample ? round((float) $sample['load1'], 3) : 0.0; + $ioWaitSeries[] = $sample ? round((float) $sample['iowait'], 2) : 0.0; + $memorySeries[] = $sample ? round((float) $sample['memory'], 1) : 0.0; + $swapSeries[] = $sample ? round((float) $sample['swap'], 1) : 0.0; + } + + return [ + 'labels' => $labels, + 'load' => $loadSeries, + 'iowait' => $ioWaitSeries, + 'memory' => $memorySeries, + 'swap' => $swapSeries, + ]; + } + + /** + * @param array $timestamp + */ + private function parseTimestamp(array $timestamp): ?CarbonImmutable + { + $date = $timestamp['date'] ?? null; + $time = $timestamp['time'] ?? null; + if (! is_string($date) || ! is_string($time)) { + return null; + } + + $value = CarbonImmutable::createFromFormat('Y-m-d H:i:s', $date.' '.$time, $this->systemTimezone()); + if ($value === false) { + return null; + } + + return $value; + } + + private function systemTimezone(): DateTimeZone + { + static $timezone = null; + + if ($timezone instanceof DateTimeZone) { + return $timezone; + } + + $name = getenv('TZ') ?: null; + if (! $name && is_file('/etc/timezone')) { + $name = trim((string) file_get_contents('/etc/timezone')); + } + if (! $name && is_link('/etc/localtime')) { + $target = readlink('/etc/localtime'); + if (is_string($target) && str_contains($target, '/zoneinfo/')) { + $name = substr($target, strpos($target, '/zoneinfo/') + 10); + } + } + if (! $name) { + $name = config('app.timezone') ?: date_default_timezone_get(); + } + + try { + $timezone = new DateTimeZone($name); + } catch (\Exception $e) { + $timezone = new DateTimeZone('UTC'); + } + + return $timezone; + } + + /** + * @param array $source + */ + private function getFloat(array $source, array $keys): ?float + { + foreach ($keys as $key) { + if (array_key_exists($key, $source) && is_numeric($source[$key])) { + return (float) $source[$key]; + } + } + + return null; + } + + /** + * @param array $source + */ + private function extractCpuMetric(mixed $source, string $metric): ?float + { + if (is_array($source) && array_key_exists($metric, $source) && is_numeric($source[$metric])) { + return (float) $source[$metric]; + } + + if (is_array($source)) { + $entries = $source; + if (array_values($entries) === $entries) { + foreach ($entries as $entry) { + if (! is_array($entry)) { + continue; + } + $cpu = $entry['cpu'] ?? $entry['cpu-load'] ?? null; + if ($cpu === 'all' || $cpu === 0 || $cpu === '0') { + if (isset($entry[$metric]) && is_numeric($entry[$metric])) { + return (float) $entry[$metric]; + } + } + } + foreach ($entries as $entry) { + if (is_array($entry) && isset($entry[$metric]) && is_numeric($entry[$metric])) { + return (float) $entry[$metric]; + } + } + } + } + + return null; + } + + /** + * @param array $source + */ + private function percentFromTotals(array $source, string $usedKey, string $freeKey, string $totalKey): ?float + { + $used = $source[$usedKey] ?? null; + $free = $source[$freeKey] ?? null; + $total = $source[$totalKey] ?? null; + + if (is_numeric($total) && (float) $total > 0) { + $usedValue = is_numeric($used) ? (float) $used : null; + if ($usedValue === null && is_numeric($free)) { + $usedValue = (float) $total - (float) $free; + } + if ($usedValue !== null) { + return round(($usedValue / (float) $total) * 100, 2); + } + } + + return null; + } +} diff --git a/app/Services/System/GeoBlockService.php b/app/Services/System/GeoBlockService.php new file mode 100644 index 0000000..29f5a22 --- /dev/null +++ b/app/Services/System/GeoBlockService.php @@ -0,0 +1,30 @@ +where('is_active', true) + ->get(['country_code', 'action']) + ->map(static function ($rule): array { + return [ + 'country_code' => strtoupper((string) $rule->country_code), + 'action' => $rule->action, + 'is_active' => true, + ]; + }) + ->values() + ->toArray(); + + $agent = new AgentClient; + $agent->geoApplyRules($rules); + } +} diff --git a/app/Services/System/LinuxUserService.php b/app/Services/System/LinuxUserService.php new file mode 100644 index 0000000..6e243e6 --- /dev/null +++ b/app/Services/System/LinuxUserService.php @@ -0,0 +1,74 @@ +agent = $agent ?? new AgentClient; + } + + /** + * Create a Linux system user + */ + public function createUser(User $user, ?string $password = null): bool + { + $response = $this->agent->createUser($user->username, $password); + + if ($response['success'] ?? false) { + $user->update([ + 'home_directory' => $response['home_directory'] ?? "/home/{$user->username}", + ]); + + return true; + } + + throw new Exception($response['error'] ?? 'Failed to create user'); + } + + /** + * Delete a Linux system user + * + * @param array $domains + * @return array + */ + public function deleteUser(string $username, bool $removeHome = false, array $domains = []): array + { + return $this->agent->deleteUser($username, $removeHome, $domains); + } + + /** + * Check if a Linux user exists + */ + public function userExists(string $username): bool + { + return $this->agent->userExists($username); + } + + /** + * Set password for Linux user + */ + public function setPassword(string $username, string $password): bool + { + $response = $this->agent->setUserPassword($username, $password); + + return $response['success'] ?? false; + } + + /** + * Validate username format + */ + public static function isValidUsername(string $username): bool + { + return preg_match('/^[a-z][a-z0-9_]{0,31}$/', $username) === 1; + } +} diff --git a/app/Services/System/MailRoutingSyncService.php b/app/Services/System/MailRoutingSyncService.php new file mode 100644 index 0000000..0886d50 --- /dev/null +++ b/app/Services/System/MailRoutingSyncService.php @@ -0,0 +1,68 @@ +where('is_active', true) + ->with('domain') + ->get() + ->map(fn ($domain) => $domain->domain?->domain) + ->filter() + ->unique() + ->values() + ->toArray(); + + $mailboxes = Mailbox::query() + ->where('is_active', true) + ->with('emailDomain.domain') + ->get() + ->map(function (Mailbox $mailbox): array { + return [ + 'email' => $mailbox->email, + 'path' => $mailbox->maildir_path, + ]; + }) + ->toArray(); + + $aliases = EmailForwarder::query() + ->where('is_active', true) + ->with('emailDomain.domain') + ->get() + ->map(function (EmailForwarder $forwarder): array { + return [ + 'source' => $forwarder->email, + 'destinations' => $forwarder->destinations ?? [], + ]; + }) + ->toArray(); + + $catchAll = EmailDomain::query() + ->where('catch_all_enabled', true) + ->with('domain') + ->get() + ->map(function (EmailDomain $domain): array { + return [ + 'source' => '@'.$domain->domain->domain, + 'destinations' => $domain->catch_all_address ? [$domain->catch_all_address] : [], + ]; + }) + ->filter(fn (array $entry) => ! empty($entry['destinations'])) + ->toArray(); + + $aliases = array_merge($aliases, $catchAll); + + $agent = new AgentClient; + $agent->emailSyncMaps($domains, $mailboxes, $aliases); + } +} diff --git a/app/View/Components/AppLayout.php b/app/View/Components/AppLayout.php new file mode 100644 index 0000000..de0d46f --- /dev/null +++ b/app/View/Components/AppLayout.php @@ -0,0 +1,17 @@ +handleCommand(new ArgvInput); + +exit($status); diff --git a/bin/jabali b/bin/jabali new file mode 100755 index 0000000..41f4e9d --- /dev/null +++ b/bin/jabali @@ -0,0 +1,3280 @@ +#!/usr/bin/env php +make(Illuminate\Contracts\Console\Kernel::class); +$kernel->bootstrap(); + +// Parse arguments +$args = $_SERVER['argv']; +array_shift($args); // Remove script name + +$command = $args[0] ?? ''; +$subcommand = $args[1] ?? ''; +$options = parseOptions(array_slice($args, 2)); + +// Route commands +switch ($command) { + case '': + case 'list': + case '--help': + case '-h': + showHelp(); + break; + case '--help-full': + case 'help-full': + showHelpFull(); + break; + case 'user': + handleUser($subcommand, $options); + break; + case 'domain': + handleDomain($subcommand, $options); + break; + case 'service': + handleService($subcommand, $options); + break; + case 'wp': + case 'wordpress': + handleWordPress($subcommand, $options); + break; + case 'db': + case 'database': + handleDatabase($subcommand, $options); + break; + case 'mail': + case 'email': + handleEmail($subcommand, $options); + break; + case 'backup': + handleBackup($subcommand, $options); + break; + case 'cpanel': + handleCpanel($subcommand, $options); + break; + case 'system': + handleSystem($subcommand, $options); + break; + case 'agent': + handleAgent($subcommand, $options); + break; + case 'php': + handlePhp($subcommand, $options); + break; + case 'firewall': + case 'fw': + handleFirewall($subcommand, $options); + break; + case 'ssl': + handleSsl($subcommand, $options); + break; + case '--version': + case '-v': + echo "Jabali CLI v" . VERSION . "\n"; + break; + default: + error("Unknown command: $command"); + echo "Run 'jabali --help' for usage information.\n"; + exit(1); +} + +exit(0); + +// ============ HELPER FUNCTIONS ============ + +function parseOptions(array $args): array +{ + $options = ['_args' => []]; + foreach ($args as $arg) { + if (strpos($arg, '--') === 0) { + $arg = substr($arg, 2); + if (strpos($arg, '=') !== false) { + [$key, $value] = explode('=', $arg, 2); + $options[$key] = $value; + } else { + $options[$arg] = true; + } + } elseif (strpos($arg, '-') === 0) { + $options[substr($arg, 1)] = true; + } else { + $options['_args'][] = $arg; + } + } + return $options; +} + +function showHelp(): void +{ + echo "\n" . C_YELLOW . "░░░░░██╗░█████╗░██████╗░░█████╗░██╗░░░░░██╗" . C_RESET . "\n"; + echo C_YELLOW . "░░░░░██║██╔══██╗██╔══██╗██╔══██╗██║░░░░░██║" . C_RESET . "\n"; + echo C_YELLOW . "░░░░░██║███████║██████╦╝███████║██║░░░░░██║" . C_RESET . "\n"; + echo C_YELLOW . "██╗░░██║██╔══██║██╔══██╗██╔══██║██║░░░░░██║" . C_RESET . "\n"; + echo C_YELLOW . "╚█████╔╝██║░░██║██████╦╝██║░░██║███████╗██║" . C_RESET . "\n"; + echo C_YELLOW . "░╚════╝░╚═╝░░╚═╝╚═════╝░╚═╝░░╚═╝╚══════╝╚═╝" . C_RESET . "\n\n"; + echo " " . C_GREEN . C_BOLD . "Jabali CLI" . C_RESET . " v" . VERSION . " - " . C_CYAN . "Modern Web Hosting Control Panel" . C_RESET . "\n\n"; + echo C_YELLOW . "Usage:" . C_RESET . " jabali [options]\n\n"; + + echo C_YELLOW . C_BOLD . "User Management:" . C_RESET . "\n"; + echo " " . C_GREEN . "user list" . C_RESET . " List all users\n"; + echo " " . C_GREEN . "user create " . C_RESET . " Create a new user\n"; + echo " " . C_GREEN . "user show " . C_RESET . " Show user details\n"; + echo " " . C_GREEN . "user delete " . C_RESET . " Delete a user\n"; + echo " " . C_GREEN . "user password " . C_RESET . " Change user password\n"; + echo " " . C_GREEN . "user suspend " . C_RESET . " Suspend a user\n"; + echo " " . C_GREEN . "user unsuspend " . C_RESET . " Unsuspend a user\n\n"; + + echo C_YELLOW . C_BOLD . "Domain Management:" . C_RESET . "\n"; + echo " " . C_GREEN . "domain list [--user=]" . C_RESET . " List domains\n"; + echo " " . C_GREEN . "domain create " . C_RESET . " Create a domain\n"; + echo " " . C_GREEN . "domain show " . C_RESET . " Show domain details\n"; + echo " " . C_GREEN . "domain delete " . C_RESET . " Delete a domain\n"; + echo " " . C_GREEN . "domain enable " . C_RESET . " Enable a domain\n"; + echo " " . C_GREEN . "domain disable " . C_RESET . " Disable a domain\n\n"; + + echo C_DIM . "More commands: service, wp, db, mail, backup, cpanel, system, agent, php, firewall, ssl\n" . C_RESET; + echo C_DIM . "Run " . C_RESET . C_CYAN . "jabali --help-full" . C_RESET . C_DIM . " for the full command list.\n\n" . C_RESET; + + echo C_YELLOW . C_BOLD . "Options:" . C_RESET . "\n"; + echo " " . C_GREEN . "-h, --help" . C_RESET . " Show this help\n"; + echo " " . C_GREEN . "--help-full" . C_RESET . " Show all commands\n"; + echo " " . C_GREEN . "-v, --version" . C_RESET . " Show version\n"; + echo " " . C_GREEN . "-y, --yes" . C_RESET . " Auto-confirm prompts\n"; + echo " " . C_GREEN . "-q, --quiet" . C_RESET . " Quiet mode\n\n"; +} + +function showHelpFull(): void +{ + echo "\n" . C_YELLOW . "░░░░░██╗░█████╗░██████╗░░█████╗░██╗░░░░░██╗" . C_RESET . "\n"; + echo C_YELLOW . "░░░░░██║██╔══██╗██╔══██╗██╔══██╗██║░░░░░██║" . C_RESET . "\n"; + echo C_YELLOW . "░░░░░██║███████║██████╦╝███████║██║░░░░░██║" . C_RESET . "\n"; + echo C_YELLOW . "██╗░░██║██╔══██║██╔══██╗██╔══██║██║░░░░░██║" . C_RESET . "\n"; + echo C_YELLOW . "╚█████╔╝██║░░██║██████╦╝██║░░██║███████╗██║" . C_RESET . "\n"; + echo C_YELLOW . "░╚════╝░╚═╝░░╚═╝╚═════╝░╚═╝░░╚═╝╚══════╝╚═╝" . C_RESET . "\n\n"; + echo " " . C_GREEN . C_BOLD . "Jabali CLI" . C_RESET . " v" . VERSION . " - " . C_CYAN . "Full Command Reference" . C_RESET . "\n\n"; + echo C_YELLOW . "Usage:" . C_RESET . " jabali [options]\n\n"; + + // Two-column layout helper + $col1Width = 40; + $printRow = function($left, $right) use ($col1Width) { + $leftClean = preg_replace('/\e\[[0-9;]*m/', '', $left); + $padding = max(1, $col1Width - strlen($leftClean)); + echo " " . $left . str_repeat(' ', $padding) . $right . "\n"; + }; + + echo C_YELLOW . C_BOLD . "User Management" . C_RESET . " " . C_YELLOW . C_BOLD . "Domain Management" . C_RESET . "\n"; + $printRow(C_GREEN . "user list" . C_RESET . " - List users", C_GREEN . "domain list" . C_RESET . " - List domains"); + $printRow(C_GREEN . "user create " . C_RESET . " - Create user", C_GREEN . "domain create " . C_RESET . " - Create domain"); + $printRow(C_GREEN . "user show " . C_RESET . " - Show details", C_GREEN . "domain show " . C_RESET . " - Show details"); + $printRow(C_GREEN . "user delete " . C_RESET . " - Delete user", C_GREEN . "domain delete " . C_RESET . " - Delete domain"); + $printRow(C_GREEN . "user password " . C_RESET . " - Set password", C_GREEN . "domain enable " . C_RESET . " - Enable domain"); + $printRow(C_GREEN . "user suspend " . C_RESET . " - Suspend", C_GREEN . "domain disable " . C_RESET . " - Disable domain"); + $printRow(C_GREEN . "user unsuspend " . C_RESET . " - Unsuspend", ""); + echo "\n"; + + echo C_YELLOW . C_BOLD . "Database Management" . C_RESET . " " . C_YELLOW . C_BOLD . "Email Management" . C_RESET . "\n"; + $printRow(C_GREEN . "db list" . C_RESET . " - List databases", C_GREEN . "mail list" . C_RESET . " - List mailboxes"); + $printRow(C_GREEN . "db create " . C_RESET . " - Create database", C_GREEN . "mail create " . C_RESET . " - Create mailbox"); + $printRow(C_GREEN . "db delete " . C_RESET . " - Delete database", C_GREEN . "mail delete " . C_RESET . " - Delete mailbox"); + $printRow(C_GREEN . "db users" . C_RESET . " - List db users", C_GREEN . "mail password " . C_RESET . " - Set password"); + $printRow(C_GREEN . "db user-create " . C_RESET . " - Create db user", C_GREEN . "mail quota " . C_RESET . " - Set quota"); + $printRow(C_GREEN . "db user-delete " . C_RESET . " - Delete db user", C_GREEN . "mail domains" . C_RESET . " - List mail domains"); + echo "\n"; + + echo C_YELLOW . C_BOLD . "Service Management" . C_RESET . " " . C_YELLOW . C_BOLD . "WordPress Management" . C_RESET . "\n"; + $printRow(C_GREEN . "service list" . C_RESET . " - List services", C_GREEN . "wp list " . C_RESET . " - List WP sites"); + $printRow(C_GREEN . "service status " . C_RESET . " - Show status", C_GREEN . "wp install " . C_RESET . " - Install WP"); + $printRow(C_GREEN . "service start " . C_RESET . " - Start service", C_GREEN . "wp scan " . C_RESET . " - Scan for WP sites"); + $printRow(C_GREEN . "service stop " . C_RESET . " - Stop service", C_GREEN . "wp import " . C_RESET . " - Import WP"); + $printRow(C_GREEN . "service restart " . C_RESET . " - Restart", C_GREEN . "wp delete " . C_RESET . " - Delete WP site"); + $printRow(C_GREEN . "service enable " . C_RESET . " - Enable on boot", C_GREEN . "wp update " . C_RESET . " - Update WP"); + $printRow(C_GREEN . "service disable " . C_RESET . " - Disable on boot", ""); + echo "\n"; + + echo C_YELLOW . C_BOLD . "System Management" . C_RESET . " " . C_YELLOW . C_BOLD . "Agent Management" . C_RESET . "\n"; + $printRow(C_GREEN . "system info" . C_RESET . " - System information", C_GREEN . "agent status" . C_RESET . " - Agent status"); + $printRow(C_GREEN . "system status" . C_RESET . " - Services status", C_GREEN . "agent start" . C_RESET . " - Start agent"); + $printRow(C_GREEN . "system hostname [name]" . C_RESET . " - Get/set hostname", C_GREEN . "agent stop" . C_RESET . " - Stop agent"); + $printRow(C_GREEN . "system disk" . C_RESET . " - Disk usage", C_GREEN . "agent restart" . C_RESET . " - Restart agent"); + $printRow(C_GREEN . "system memory" . C_RESET . " - Memory usage", C_GREEN . "agent ping" . C_RESET . " - Ping agent"); + $printRow("", C_GREEN . "agent log [--lines=N]" . C_RESET . " - View logs"); + echo "\n"; + + echo C_YELLOW . C_BOLD . "PHP Management" . C_RESET . " " . C_YELLOW . C_BOLD . "Firewall Management" . C_RESET . "\n"; + $printRow(C_GREEN . "php list" . C_RESET . " - List PHP versions", C_GREEN . "firewall status" . C_RESET . " - Show status"); + $printRow(C_GREEN . "php install " . C_RESET . " - Install PHP", C_GREEN . "firewall enable" . C_RESET . " - Enable firewall"); + $printRow(C_GREEN . "php uninstall " . C_RESET . " - Uninstall PHP", C_GREEN . "firewall disable" . C_RESET . " - Disable firewall"); + $printRow(C_GREEN . "php default [ver]" . C_RESET . " - Get/set default", C_GREEN . "firewall rules" . C_RESET . " - List rules"); + $printRow(C_GREEN . "php status" . C_RESET . " - PHP-FPM status", C_GREEN . "firewall allow " . C_RESET . " - Allow port"); + $printRow("", C_GREEN . "firewall deny " . C_RESET . " - Deny port"); + $printRow("", C_GREEN . "firewall delete " . C_RESET . " - Delete rule"); + echo "\n"; + + echo C_YELLOW . C_BOLD . "Backup Management" . C_RESET . " " . C_YELLOW . C_BOLD . "SSL Management" . C_RESET . "\n"; + $printRow(C_GREEN . "backup list [--user=]" . C_RESET . " - List backups", C_GREEN . "ssl check" . C_RESET . " - Check/issue/renew certs"); + $printRow(C_GREEN . "backup create " . C_RESET . " - Create user backup", C_GREEN . "ssl issue " . C_RESET . " - Issue certificate"); + $printRow(C_GREEN . "backup restore " . C_RESET . " - Restore backup", C_GREEN . "ssl renew " . C_RESET . " - Renew certificate"); + $printRow(C_GREEN . "backup info " . C_RESET . " - Show backup info", C_GREEN . "ssl status " . C_RESET . " - Show cert status"); + $printRow(C_GREEN . "backup verify " . C_RESET . " - Verify backup", C_GREEN . "ssl list" . C_RESET . " - List all certificates"); + $printRow(C_GREEN . "backup server" . C_RESET . " - Create server backup", ""); + $printRow(C_GREEN . "backup history" . C_RESET . " - Show backup history", ""); + $printRow(C_GREEN . "backup schedules" . C_RESET . " - List schedules", ""); + $printRow(C_GREEN . "backup destinations" . C_RESET . " - List destinations", ""); + $printRow(C_GREEN . "backup help" . C_RESET . " - Show all backup cmds", ""); + echo "\n"; + + echo C_YELLOW . C_BOLD . "Migration & Restore" . C_RESET . "\n"; + $printRow(C_GREEN . "cpanel analyze " . C_RESET . " - Analyze backup", C_GREEN . "cpanel restore " . C_RESET . " - Restore backup"); + $printRow(C_GREEN . "cpanel fix-permissions " . C_RESET . " - Fix backup perms", ""); + echo "\n"; + + echo C_YELLOW . C_BOLD . "Options" . C_RESET . "\n"; + echo " " . C_GREEN . "-h, --help" . C_RESET . " Show basic help " . C_GREEN . "-v, --version" . C_RESET . " Show version\n"; + echo " " . C_GREEN . "--help-full" . C_RESET . " Show full help " . C_GREEN . "-y, --yes" . C_RESET . " Auto-confirm\n"; + echo " " . C_GREEN . "-q, --quiet" . C_RESET . " Quiet mode\n"; + echo "\n"; +} + +function success(string $message): void +{ + echo C_GREEN . "✓ " . C_RESET . $message . "\n"; +} + +function error(string $message): void +{ + echo C_RED . "✗ " . C_RESET . $message . "\n"; +} + +function info(string $message): void +{ + echo C_CYAN . "ℹ " . C_RESET . $message . "\n"; +} + +function warning(string $message): void +{ + echo C_YELLOW . "⚠ " . C_RESET . $message . "\n"; +} + +function confirm(string $message, array $options): bool +{ + if (isset($options['y']) || isset($options['yes'])) { + return true; + } + echo $message . " [y/N]: "; + $handle = fopen("php://stdin", "r"); + $line = fgets($handle); + fclose($handle); + return strtolower(trim($line)) === 'y'; +} + +function prompt(string $message, bool $hidden = false): string +{ + echo $message . ": "; + if ($hidden) { + system('stty -echo'); + } + $handle = fopen("php://stdin", "r"); + $line = trim(fgets($handle)); + fclose($handle); + if ($hidden) { + system('stty echo'); + echo "\n"; + } + return $line; +} + +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); +} + +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; +} + +function promptPassword(string $message = "Password", bool $allowAutoGenerate = false): string +{ + $hint = $allowAutoGenerate ? " (enter to auto-generate)" : ""; + while (true) { + $password = prompt($message . $hint, true); + if ($allowAutoGenerate && $password === '') { + $password = generateSecurePassword(); + echo C_GREEN . "Generated password: " . C_RESET . $password . "\n"; + return $password; + } + if ($error = validatePassword($password)) { + error($error); + continue; + } + return $password; + } +} + +function agentSend(string $action, array $params = []): array +{ + return agentSendWithTimeout($action, $params, 60); +} + +function agentSendWithTimeout(string $action, array $params = [], int $timeoutSeconds = 60): array +{ + if (!file_exists(AGENT_SOCKET)) { + return ['success' => false, 'error' => 'Agent socket not found. Is the agent running?']; + } + + $socket = @socket_create(AF_UNIX, SOCK_STREAM, 0); + if (!$socket) { + return ['success' => false, 'error' => 'Failed to create socket']; + } + + socket_set_option($socket, SOL_SOCKET, SO_RCVTIMEO, ['sec' => $timeoutSeconds, 'usec' => 0]); + socket_set_option($socket, SOL_SOCKET, SO_SNDTIMEO, ['sec' => $timeoutSeconds, 'usec' => 0]); + + if (!@socket_connect($socket, AGENT_SOCKET)) { + socket_close($socket); + return ['success' => false, 'error' => 'Failed to connect to agent']; + } + + $request = json_encode(['action' => $action, 'params' => $params]); + socket_write($socket, $request, strlen($request)); + + $response = ''; + while ($buf = socket_read($socket, 8192)) { + $response .= $buf; + } + socket_close($socket); + + return json_decode($response, true) ?: ['success' => false, 'error' => 'Invalid response from agent']; +} + +function table(array $headers, array $rows): void +{ + if (empty($rows)) { + echo C_DIM . "No data to display." . C_RESET . "\n"; + return; + } + + // Calculate column widths + $widths = []; + foreach ($headers as $i => $header) { + $widths[$i] = strlen($header); + } + foreach ($rows as $row) { + foreach ($row as $i => $cell) { + $widths[$i] = max($widths[$i] ?? 0, strlen((string)$cell)); + } + } + + // Print header + echo C_BOLD; + foreach ($headers as $i => $header) { + echo str_pad($header, $widths[$i] + 2); + } + echo C_RESET . "\n"; + + // Print separator + foreach ($widths as $width) { + echo str_repeat('─', $width + 2); + } + echo "\n"; + + // Print rows + foreach ($rows as $row) { + foreach ($row as $i => $cell) { + echo str_pad((string)$cell, $widths[$i] + 2); + } + echo "\n"; + } +} + +// ============ USER COMMANDS ============ + +function handleUser(string $subcommand, array $options): void +{ + switch ($subcommand) { + case 'list': + $users = App\Models\User::all(); + $rows = []; + foreach ($users as $user) { + $rows[] = [ + $user->id, + $user->username ?? $user->name, + $user->email, + $user->is_admin ? 'Admin' : 'User', + $user->suspended ? C_RED . 'Suspended' . C_RESET : C_GREEN . 'Active' . C_RESET, + $user->created_at->format('Y-m-d'), + ]; + } + table(['ID', 'Username', 'Email', 'Role', 'Status', 'Created'], $rows); + break; + + case 'create': + $username = $options['_args'][0] ?? prompt("Username"); + if (!$username) { + error("Username is required"); + exit(1); + } + $email = $options['email'] ?? prompt("Email"); + if (isset($options['password'])) { + $password = $options['password']; + if ($err = validatePassword($password)) { error($err); exit(1); } + } else { + $password = promptPassword("Password"); + } + + // Create system user via agent + $result = agentSend('user.create', ['username' => $username, 'password' => $password]); + if (!($result['success'] ?? false)) { + error("Failed to create system user: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + + // Create database user + $user = App\Models\User::create([ + 'name' => $username, + 'username' => $username, + 'email' => $email, + 'password' => bcrypt($password), + ]); + + success("User '$username' created successfully (ID: {$user->id})"); + break; + + case 'show': + $username = $options['_args'][0] ?? null; + if (!$username) { + error("Username is required"); + exit(1); + } + $user = App\Models\User::where('username', $username)->orWhere('name', $username)->first(); + if (!$user) { + error("User not found: $username"); + exit(1); + } + echo "\n" . C_BOLD . "User Details" . C_RESET . "\n"; + echo str_repeat('─', 40) . "\n"; + echo C_CYAN . "ID:" . C_RESET . " {$user->id}\n"; + echo C_CYAN . "Username:" . C_RESET . " " . ($user->username ?? $user->name) . "\n"; + echo C_CYAN . "Email:" . C_RESET . " {$user->email}\n"; + echo C_CYAN . "Role:" . C_RESET . " " . ($user->is_admin ? 'Admin' : 'User') . "\n"; + echo C_CYAN . "Status:" . C_RESET . " " . ($user->suspended ? 'Suspended' : 'Active') . "\n"; + echo C_CYAN . "Created:" . C_RESET . " {$user->created_at}\n"; + $domainCount = App\Models\Domain::where('user_id', $user->id)->count(); + echo C_CYAN . "Domains:" . C_RESET . " $domainCount\n"; + break; + + case 'delete': + $username = $options['_args'][0] ?? null; + if (!$username) { + error("Username is required"); + exit(1); + } + $user = App\Models\User::where('username', $username)->orWhere('name', $username)->first(); + if (!$user) { + error("User not found: $username"); + exit(1); + } + if (!confirm("Are you sure you want to delete user '$username'? This cannot be undone.", $options)) { + info("Operation cancelled"); + exit(0); + } + + // Delete system user + $result = agentSend('user.delete', ['username' => $username]); + if (!($result['success'] ?? false)) { + warning("Could not delete system user: " . ($result['error'] ?? 'Unknown error')); + } + + $user->delete(); + success("User '$username' deleted"); + break; + + case 'password': + $username = $options['_args'][0] ?? null; + if (!$username) { + error("Username is required"); + exit(1); + } + $user = App\Models\User::where('username', $username)->orWhere('name', $username)->first(); + if (!$user) { + error("User not found: $username"); + exit(1); + } + if (isset($options['password'])) { + $password = $options['password']; + if ($err = validatePassword($password)) { error($err); exit(1); } + } else { + $password = promptPassword("New password", true); + } + $user->password = bcrypt($password); + $user->save(); + + // Update system user password + agentSend('user.password', ['username' => $username, 'password' => $password]); + + success("Password updated for '$username'"); + break; + + case 'suspend': + $username = $options['_args'][0] ?? null; + if (!$username) { + error("Username is required"); + exit(1); + } + $user = App\Models\User::where('username', $username)->orWhere('name', $username)->first(); + if (!$user) { + error("User not found: $username"); + exit(1); + } + $user->suspended = true; + $user->save(); + success("User '$username' suspended"); + break; + + case 'unsuspend': + $username = $options['_args'][0] ?? null; + if (!$username) { + error("Username is required"); + exit(1); + } + $user = App\Models\User::where('username', $username)->orWhere('name', $username)->first(); + if (!$user) { + error("User not found: $username"); + exit(1); + } + $user->suspended = false; + $user->save(); + success("User '$username' unsuspended"); + break; + + default: + error("Unknown user command: $subcommand"); + echo "Run 'jabali user --help' for available commands.\n"; + exit(1); + } +} + +// ============ DOMAIN COMMANDS ============ + +function handleDomain(string $subcommand, array $options): void +{ + switch ($subcommand) { + case 'list': + $query = App\Models\Domain::with('user'); + if (isset($options['user'])) { + $user = App\Models\User::where('username', $options['user'])->orWhere('name', $options['user'])->first(); + if ($user) { + $query->where('user_id', $user->id); + } + } + $domains = $query->get(); + $rows = []; + foreach ($domains as $domain) { + $rows[] = [ + $domain->id, + $domain->domain, + $domain->user->username ?? $domain->user->name ?? 'N/A', + $domain->is_active ? C_GREEN . 'Active' . C_RESET : C_RED . 'Inactive' . C_RESET, + $domain->ssl_enabled ? C_GREEN . 'SSL' . C_RESET : C_DIM . 'No SSL' . C_RESET, + $domain->created_at->format('Y-m-d'), + ]; + } + table(['ID', 'Domain', 'User', 'Status', 'SSL', 'Created'], $rows); + break; + + case 'create': + $domain = $options['_args'][0] ?? prompt("Domain name"); + if (!$domain) { + error("Domain name is required"); + exit(1); + } + $username = $options['user'] ?? prompt("Username"); + $user = App\Models\User::where('username', $username)->orWhere('name', $username)->first(); + if (!$user) { + error("User not found: $username"); + exit(1); + } + + $result = agentSend('domain.create', [ + 'username' => $username, + 'domain' => $domain, + ]); + + if ($result['success'] ?? false) { + $domainModel = App\Models\Domain::create([ + 'user_id' => $user->id, + 'domain' => $domain, + 'is_active' => true, + ]); + success("Domain '$domain' created (ID: {$domainModel->id})"); + } else { + error("Failed to create domain: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + break; + + case 'show': + $domain = $options['_args'][0] ?? null; + if (!$domain) { + error("Domain name is required"); + exit(1); + } + $domainModel = App\Models\Domain::where('domain', $domain)->with('user')->first(); + if (!$domainModel) { + error("Domain not found: $domain"); + exit(1); + } + echo "\n" . C_BOLD . "Domain Details" . C_RESET . "\n"; + echo str_repeat('─', 40) . "\n"; + echo C_CYAN . "ID:" . C_RESET . " {$domainModel->id}\n"; + echo C_CYAN . "Domain:" . C_RESET . " {$domainModel->domain}\n"; + echo C_CYAN . "User:" . C_RESET . " " . ($domainModel->user->username ?? $domainModel->user->name) . "\n"; + echo C_CYAN . "Status:" . C_RESET . " " . ($domainModel->is_active ? 'Active' : 'Inactive') . "\n"; + echo C_CYAN . "SSL:" . C_RESET . " " . ($domainModel->ssl_enabled ? 'Enabled' : 'Disabled') . "\n"; + echo C_CYAN . "Created:" . C_RESET . " {$domainModel->created_at}\n"; + break; + + case 'delete': + $domain = $options['_args'][0] ?? null; + if (!$domain) { + error("Domain name is required"); + exit(1); + } + $domainModel = App\Models\Domain::where('domain', $domain)->with('user')->first(); + if (!$domainModel) { + error("Domain not found: $domain"); + exit(1); + } + if (!confirm("Are you sure you want to delete domain '$domain'?", $options)) { + info("Operation cancelled"); + exit(0); + } + + $username = $domainModel->user->username ?? $domainModel->user->name; + $result = agentSend('domain.delete', ['username' => $username, 'domain' => $domain]); + + $domainModel->delete(); + success("Domain '$domain' deleted"); + break; + + case 'enable': + $domain = $options['_args'][0] ?? null; + if (!$domain) { + error("Domain name is required"); + exit(1); + } + $domainModel = App\Models\Domain::where('domain', $domain)->first(); + if (!$domainModel) { + error("Domain not found: $domain"); + exit(1); + } + $domainModel->is_active = true; + $domainModel->save(); + success("Domain '$domain' enabled"); + break; + + case 'disable': + $domain = $options['_args'][0] ?? null; + if (!$domain) { + error("Domain name is required"); + exit(1); + } + $domainModel = App\Models\Domain::where('domain', $domain)->first(); + if (!$domainModel) { + error("Domain not found: $domain"); + exit(1); + } + $domainModel->is_active = false; + $domainModel->save(); + success("Domain '$domain' disabled"); + break; + + default: + error("Unknown domain command: $subcommand"); + exit(1); + } +} + +// ============ SERVICE COMMANDS ============ + +function handleService(string $subcommand, array $options): void +{ + $services = [ + 'nginx', 'mariadb', 'redis-server', 'postfix', 'dovecot', + 'rspamd', 'clamav-daemon', 'named', 'opendkim', 'fail2ban', + 'ssh', 'cron' + ]; + + // Add PHP-FPM versions + exec('ls /lib/systemd/system/php*-fpm.service 2>/dev/null', $phpServices); + foreach ($phpServices as $svc) { + if (preg_match('/php[\d.]+-fpm/', basename($svc, '.service'), $m)) { + $services[] = $m[0]; + } + } + + switch ($subcommand) { + case 'list': + $result = agentSend('service.list', ['services' => $services]); + if (!($result['success'] ?? false)) { + error("Failed to get service list: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + $rows = []; + foreach ($result['services'] ?? [] as $name => $status) { + $rows[] = [ + $name, + $status['is_active'] ? C_GREEN . 'Running' . C_RESET : C_RED . 'Stopped' . C_RESET, + $status['is_enabled'] ? C_GREEN . 'Enabled' . C_RESET : C_DIM . 'Disabled' . C_RESET, + ]; + } + table(['Service', 'Status', 'Boot'], $rows); + break; + + case 'status': + $service = $options['_args'][0] ?? null; + if (!$service) { + error("Service name is required"); + exit(1); + } + $result = agentSend('service.list', ['services' => [$service]]); + if ($result['success'] ?? false) { + $status = $result['services'][$service] ?? null; + if ($status) { + echo C_BOLD . $service . C_RESET . ": "; + echo ($status['is_active'] ? C_GREEN . 'Running' . C_RESET : C_RED . 'Stopped' . C_RESET); + echo " (Boot: " . ($status['is_enabled'] ? 'Enabled' : 'Disabled') . ")\n"; + } else { + error("Service not found: $service"); + } + } + break; + + case 'start': + $service = $options['_args'][0] ?? null; + if (!$service) { + error("Service name is required"); + exit(1); + } + $result = agentSend('service.start', ['service' => $service]); + if ($result['success'] ?? false) { + success("Service '$service' started"); + } else { + error("Failed to start '$service': " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + break; + + case 'stop': + $service = $options['_args'][0] ?? null; + if (!$service) { + error("Service name is required"); + exit(1); + } + if (!confirm("Are you sure you want to stop '$service'?", $options)) { + info("Operation cancelled"); + exit(0); + } + $result = agentSend('service.stop', ['service' => $service]); + if ($result['success'] ?? false) { + success("Service '$service' stopped"); + } else { + error("Failed to stop '$service': " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + break; + + case 'restart': + $service = $options['_args'][0] ?? null; + if (!$service) { + error("Service name is required"); + exit(1); + } + $result = agentSend('service.restart', ['service' => $service]); + if ($result['success'] ?? false) { + success("Service '$service' restarted"); + } else { + error("Failed to restart '$service': " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + break; + + case 'enable': + $service = $options['_args'][0] ?? null; + if (!$service) { + error("Service name is required"); + exit(1); + } + $result = agentSend('service.enable', ['service' => $service]); + if ($result['success'] ?? false) { + success("Service '$service' enabled on boot"); + } else { + error("Failed to enable '$service': " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + break; + + case 'disable': + $service = $options['_args'][0] ?? null; + if (!$service) { + error("Service name is required"); + exit(1); + } + $result = agentSend('service.disable', ['service' => $service]); + if ($result['success'] ?? false) { + success("Service '$service' disabled on boot"); + } else { + error("Failed to disable '$service': " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + break; + + default: + error("Unknown service command: $subcommand"); + exit(1); + } +} + +// ============ WORDPRESS COMMANDS ============ + +function handleWordPress(string $subcommand, array $options): void +{ + switch ($subcommand) { + case 'list': + $username = $options['_args'][0] ?? null; + if (!$username) { + error("Username is required"); + exit(1); + } + $result = agentSend('wp.list', ['username' => $username]); + if (!($result['success'] ?? false)) { + error("Failed to list WordPress sites: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + $rows = []; + foreach ($result['sites'] ?? [] as $site) { + $rows[] = [ + $site['id'], + $site['domain'] ?? 'N/A', + $site['version'] ?? 'N/A', + $site['url'] ?? 'N/A', + ]; + } + table(['ID', 'Domain', 'Version', 'URL'], $rows); + break; + + case 'install': + $username = $options['_args'][0] ?? null; + $domain = $options['_args'][1] ?? null; + if (!$username || !$domain) { + error("Usage: jabali wp install "); + exit(1); + } + $title = $options['title'] ?? 'My WordPress Site'; + $adminUser = $options['admin'] ?? 'admin'; + $adminEmail = $options['email'] ?? prompt("Admin email"); + $adminPass = $options['password'] ?? null; + + info("Installing WordPress for $username on $domain..."); + $result = agentSend('wp.install', [ + 'username' => $username, + 'domain' => $domain, + 'title' => $title, + 'admin_user' => $adminUser, + 'admin_email' => $adminEmail, + 'admin_password' => $adminPass, + ]); + + if ($result['success'] ?? false) { + success("WordPress installed successfully!"); + echo C_CYAN . "URL:" . C_RESET . " https://$domain\n"; + echo C_CYAN . "Admin:" . C_RESET . " https://$domain/wp-admin/\n"; + echo C_CYAN . "Username:" . C_RESET . " $adminUser\n"; + if (isset($result['admin_password'])) { + echo C_CYAN . "Password:" . C_RESET . " {$result['admin_password']}\n"; + } + } else { + error("Failed to install WordPress: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + break; + + case 'scan': + $username = $options['_args'][0] ?? null; + if (!$username) { + error("Username is required"); + exit(1); + } + info("Scanning for WordPress installations..."); + $result = agentSend('wp.scan', ['username' => $username]); + if (!($result['success'] ?? false)) { + error("Failed to scan: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + $found = $result['found'] ?? []; + if (empty($found)) { + info("No untracked WordPress installations found."); + } else { + success("Found " . count($found) . " WordPress installation(s):"); + $rows = []; + foreach ($found as $site) { + $rows[] = [ + $site['path'], + $site['version'] ?? 'N/A', + $site['site_url'] ?? 'N/A', + ]; + } + table(['Path', 'Version', 'URL'], $rows); + } + break; + + case 'import': + $username = $options['_args'][0] ?? null; + $path = $options['_args'][1] ?? null; + if (!$username || !$path) { + error("Usage: jabali wp import "); + exit(1); + } + $result = agentSend('wp.import', ['username' => $username, 'path' => $path]); + if ($result['success'] ?? false) { + success("WordPress site imported: " . ($result['site_id'] ?? '')); + } else { + error("Failed to import: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + break; + + case 'delete': + $username = $options['_args'][0] ?? null; + $siteId = $options['_args'][1] ?? null; + if (!$username || !$siteId) { + error("Usage: jabali wp delete "); + exit(1); + } + if (!confirm("Are you sure you want to delete this WordPress site?", $options)) { + info("Operation cancelled"); + exit(0); + } + $result = agentSend('wp.delete', [ + 'username' => $username, + 'site_id' => $siteId, + 'delete_files' => isset($options['files']), + 'delete_database' => isset($options['database']), + ]); + if ($result['success'] ?? false) { + success("WordPress site deleted"); + } else { + error("Failed to delete: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + break; + + case 'update': + $username = $options['_args'][0] ?? null; + $siteId = $options['_args'][1] ?? null; + if (!$username || !$siteId) { + error("Usage: jabali wp update "); + exit(1); + } + info("Updating WordPress..."); + $result = agentSend('wp.update', ['username' => $username, 'site_id' => $siteId]); + if ($result['success'] ?? false) { + success("WordPress updated to " . ($result['new_version'] ?? 'latest')); + } else { + error("Failed to update: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + break; + + default: + error("Unknown WordPress command: $subcommand"); + exit(1); + } +} + +// ============ DATABASE COMMANDS ============ + +function handleDatabase(string $subcommand, array $options): void +{ + switch ($subcommand) { + case 'list': + $username = $options['user'] ?? null; + $result = agentSend('mysql.list_databases', ['username' => $username ?? 'admin']); + if (!($result['success'] ?? false)) { + error("Failed to list databases: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + $rows = []; + foreach ($result['databases'] ?? [] as $db) { + $rows[] = [ + is_array($db) ? ($db['name'] ?? $db) : $db, + ]; + } + table(['Database'], $rows); + break; + + case 'create': + $dbName = $options['_args'][0] ?? prompt("Database name"); + if (!$dbName) { + error("Database name is required"); + exit(1); + } + $username = $options['user'] ?? 'admin'; + $result = agentSend('mysql.create_database', [ + 'username' => $username, + 'database' => $dbName, + ]); + if ($result['success'] ?? false) { + success("Database '$dbName' created"); + } else { + error("Failed to create database: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + break; + + case 'delete': + $dbName = $options['_args'][0] ?? null; + if (!$dbName) { + error("Database name is required"); + exit(1); + } + if (!confirm("Are you sure you want to delete database '$dbName'?", $options)) { + info("Operation cancelled"); + exit(0); + } + $result = agentSend('mysql.delete_database', ['database' => $dbName]); + if ($result['success'] ?? false) { + success("Database '$dbName' deleted"); + } else { + error("Failed to delete database: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + break; + + case 'users': + $username = $options['user'] ?? 'admin'; + $result = agentSend('mysql.list_users', ['username' => $username]); + if (!($result['success'] ?? false)) { + error("Failed to list users: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + $rows = []; + foreach ($result['users'] ?? [] as $user) { + $rows[] = [ + is_array($user) ? ($user['user'] ?? $user['name'] ?? $user) : $user, + is_array($user) ? ($user['host'] ?? 'localhost') : 'localhost', + ]; + } + table(['User', 'Host'], $rows); + break; + + case 'user-create': + $dbUser = $options['_args'][0] ?? prompt("Username"); + if (isset($options['password'])) { + $password = $options['password']; + if ($err = validatePassword($password)) { error($err); exit(1); } + } else { + $password = promptPassword("Password"); + } + $host = $options['host'] ?? 'localhost'; + $result = agentSend('mysql.create_user', [ + 'username' => $dbUser, + 'password' => $password, + 'host' => $host, + ]); + if ($result['success'] ?? false) { + success("Database user '$dbUser' created"); + } else { + error("Failed to create user: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + break; + + case 'user-delete': + $dbUser = $options['_args'][0] ?? null; + if (!$dbUser) { + error("Username is required"); + exit(1); + } + $host = $options['host'] ?? 'localhost'; + if (!confirm("Are you sure you want to delete database user '$dbUser'?", $options)) { + info("Operation cancelled"); + exit(0); + } + $result = agentSend('mysql.delete_user', ['username' => $dbUser, 'host' => $host]); + if ($result['success'] ?? false) { + success("Database user '$dbUser' deleted"); + } else { + error("Failed to delete user: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + break; + + default: + error("Unknown database command: $subcommand"); + exit(1); + } +} + +// ============ EMAIL COMMANDS ============ + +function handleEmail(string $subcommand, array $options): void +{ + switch ($subcommand) { + case 'list': + $query = App\Models\Mailbox::query(); + if (isset($options['domain'])) { + $query->where('domain', $options['domain']); + } + $mailboxes = $query->get(); + $rows = []; + foreach ($mailboxes as $mb) { + $rows[] = [ + $mb->id, + $mb->local_part . '@' . $mb->domain, + $mb->quota_mb . ' MB', + $mb->is_active ? C_GREEN . 'Active' . C_RESET : C_RED . 'Inactive' . C_RESET, + ]; + } + table(['ID', 'Email', 'Quota', 'Status'], $rows); + break; + + case 'create': + $email = $options['_args'][0] ?? prompt("Email address"); + if (!$email || !strpos($email, '@')) { + error("Valid email address is required"); + exit(1); + } + [$localPart, $domain] = explode('@', $email); + if (isset($options['password'])) { + $password = $options['password']; + if ($err = validatePassword($password)) { error($err); exit(1); } + } else { + $password = promptPassword("Password"); + } + $quota = $options['quota'] ?? 1024; + + $domainModel = App\Models\Domain::where('domain', $domain)->first(); + if (!$domainModel) { + error("Domain not found: $domain"); + exit(1); + } + + $result = agentSend('email.mailbox_create', [ + 'username' => $domainModel->user->username ?? $domainModel->user->name, + 'domain' => $domain, + 'local_part' => $localPart, + 'password' => $password, + 'quota_mb' => (int)$quota, + ]); + + if ($result['success'] ?? false) { + App\Models\Mailbox::create([ + 'domain_id' => $domainModel->id, + 'local_part' => $localPart, + 'domain' => $domain, + 'password_hash' => $result['password_hash'] ?? '', + 'quota_mb' => (int)$quota, + 'is_active' => true, + ]); + success("Mailbox '$email' created"); + } else { + error("Failed to create mailbox: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + break; + + case 'delete': + $email = $options['_args'][0] ?? null; + if (!$email) { + error("Email address is required"); + exit(1); + } + [$localPart, $domain] = explode('@', $email); + $mailbox = App\Models\Mailbox::where('local_part', $localPart)->where('domain', $domain)->first(); + if (!$mailbox) { + error("Mailbox not found: $email"); + exit(1); + } + if (!confirm("Are you sure you want to delete mailbox '$email'?", $options)) { + info("Operation cancelled"); + exit(0); + } + + $domainModel = App\Models\Domain::where('domain', $domain)->first(); + $username = $domainModel ? ($domainModel->user->username ?? $domainModel->user->name) : 'admin'; + + agentSend('email.mailbox_delete', [ + 'username' => $username, + 'domain' => $domain, + 'local_part' => $localPart, + ]); + + $mailbox->delete(); + success("Mailbox '$email' deleted"); + break; + + case 'password': + $email = $options['_args'][0] ?? null; + if (!$email) { + error("Email address is required"); + exit(1); + } + [$localPart, $domain] = explode('@', $email); + $mailbox = App\Models\Mailbox::where('local_part', $localPart)->where('domain', $domain)->first(); + if (!$mailbox) { + error("Mailbox not found: $email"); + exit(1); + } + if (isset($options['password'])) { + $password = $options['password']; + if ($err = validatePassword($password)) { error($err); exit(1); } + } else { + $password = promptPassword("New password", true); + } + + $domainModel = App\Models\Domain::where('domain', $domain)->first(); + $username = $domainModel ? ($domainModel->user->username ?? $domainModel->user->name) : 'admin'; + + $result = agentSend('email.mailbox_change_password', [ + 'username' => $username, + 'domain' => $domain, + 'local_part' => $localPart, + 'password' => $password, + ]); + + if ($result['success'] ?? false) { + if (isset($result['password_hash'])) { + $mailbox->password_hash = $result['password_hash']; + $mailbox->save(); + } + success("Password updated for '$email'"); + } else { + error("Failed to update password: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + break; + + case 'quota': + $email = $options['_args'][0] ?? null; + $quota = $options['_args'][1] ?? null; + if (!$email || !$quota) { + error("Usage: jabali mail quota "); + exit(1); + } + [$localPart, $domain] = explode('@', $email); + $mailbox = App\Models\Mailbox::where('local_part', $localPart)->where('domain', $domain)->first(); + if (!$mailbox) { + error("Mailbox not found: $email"); + exit(1); + } + + $domainModel = App\Models\Domain::where('domain', $domain)->first(); + $username = $domainModel ? ($domainModel->user->username ?? $domainModel->user->name) : 'admin'; + + $result = agentSend('email.mailbox_set_quota', [ + 'username' => $username, + 'domain' => $domain, + 'local_part' => $localPart, + 'quota_mb' => (int)$quota, + ]); + + if ($result['success'] ?? false) { + $mailbox->quota_mb = (int)$quota; + $mailbox->save(); + success("Quota set to {$quota}MB for '$email'"); + } else { + error("Failed to set quota: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + break; + + case 'domains': + $domains = App\Models\Domain::where('mail_enabled', true)->get(); + $rows = []; + foreach ($domains as $d) { + $mailboxCount = App\Models\Mailbox::where('domain', $d->domain)->count(); + $rows[] = [ + $d->domain, + $mailboxCount, + $d->dkim_enabled ? C_GREEN . 'Yes' . C_RESET : C_DIM . 'No' . C_RESET, + ]; + } + table(['Domain', 'Mailboxes', 'DKIM'], $rows); + break; + + default: + error("Unknown email command: $subcommand"); + exit(1); + } +} + +// ============ BACKUP COMMANDS ============ + +function handleBackup(string $subcommand, array $options): void +{ + $backupDir = '/var/backups/jabali'; + + switch ($subcommand) { + case 'list': + case 'user-list': + $username = $options['user'] ?? $options['username'] ?? $options['_args'][0] ?? null; + + if ($username !== null && !is_string($username)) { + error("Username is required"); + exit(1); + } + if (is_string($username)) { + $username = trim($username); + if ($username === '') { + $username = null; + } + } + if ($subcommand === 'user-list' && $username === null) { + error("Username is required"); + exit(1); + } + + if ($subcommand === 'user-list' || $username !== null) { + if (!$username) { + error("Username is required"); + exit(1); + } + + $path = $options['path'] ?? ''; + $result = agentSend('backup.list', [ + 'username' => $username, + 'path' => $path, + ]); + + if (!($result['success'] ?? false)) { + error("Failed to list backups: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + + $backups = $result['backups'] ?? []; + if (empty($backups)) { + info("No backups found for $username."); + exit(0); + } + + $rows = []; + foreach ($backups as $backup) { + $manifest = $backup['manifest'] ?? []; + $createdAt = $backup['created_at'] ?? null; + + $rows[] = [ + $backup['name'] ?? '-', + $backup['type'] ?? '-', + formatBytes((int) ($backup['size'] ?? 0)), + is_array($manifest['domains'] ?? null) ? count($manifest['domains']) : '-', + is_array($manifest['databases'] ?? null) ? count($manifest['databases']) : '-', + is_array($manifest['mailboxes'] ?? null) ? count($manifest['mailboxes']) : '-', + $createdAt ? date('Y-m-d H:i', strtotime($createdAt)) : '-', + ]; + } + + table(['Name', 'Type', 'Size', 'Domains', 'DBs', 'Mailboxes', 'Created'], $rows); + break; + } + + if (!is_dir($backupDir)) { + info("No local backups found."); + exit(0); + } + + $files = array_merge( + glob("$backupDir/*.tar.gz") ?: [], + glob("$backupDir/*", GLOB_ONLYDIR) ?: [] + ); + $files = array_filter($files, fn($f) => basename($f) !== '.' && basename($f) !== '..'); + if (empty($files)) { + info("No local backups found."); + exit(0); + } + + $rows = []; + foreach ($files as $file) { + if (is_dir($file)) { + $size = trim(shell_exec("du -sh " . escapeshellarg($file) . " 2>/dev/null | cut -f1") ?: '0'); + $rows[] = [ + basename($file) . '/', + $size, + date('Y-m-d H:i', filemtime($file)), + 'server', + ]; + } else { + $rows[] = [ + basename($file), + formatBytes((int) filesize($file)), + date('Y-m-d H:i', filemtime($file)), + 'user', + ]; + } + } + table(['Filename', 'Size', 'Created', 'Type'], $rows); + break; + + case 'create': + $username = $options['_args'][0] ?? $options['user'] ?? $options['username'] ?? null; + if (!is_string($username)) { + error("Username is required"); + exit(1); + } + $username = trim($username); + if ($username === '') { + error("Username is required"); + exit(1); + } + $exists = agentSend('user.exists', ['username' => $username]); + if (!($exists['success'] ?? false) || !($exists['exists'] ?? false)) { + error("User not found: $username"); + exit(1); + } + + $backupType = $options['type'] ?? 'full'; + if (!in_array($backupType, ['full', 'incremental'], true)) { + error("Invalid backup type: $backupType (use full or incremental)"); + exit(1); + } + + $timestamp = date('Y-m-d_His'); + $outputPath = $options['output'] ?? $options['path'] ?? null; + if ($outputPath !== null && !is_string($outputPath)) { + error("Output path is required"); + exit(1); + } + if (is_string($outputPath)) { + $outputPath = trim($outputPath); + if ($outputPath === '') { + $outputPath = null; + } + } + if ($outputPath === null) { + $outputPath = $backupType === 'incremental' + ? "/home/$username/backups/{$username}_{$timestamp}" + : "/home/$username/backups/{$username}_{$timestamp}.tar.gz"; + } + + $params = [ + 'username' => $username, + 'output_path' => $outputPath, + 'backup_type' => $backupType, + 'include_files' => !isset($options['no-files']), + 'include_databases' => !isset($options['no-databases']), + 'include_mailboxes' => !isset($options['no-mailboxes']), + 'include_dns' => !isset($options['no-dns']), + 'include_ssl' => !isset($options['no-ssl']), + 'domains' => parseListOption($options['domains'] ?? null), + 'databases' => parseListOption($options['databases'] ?? null), + 'mailboxes' => parseListOption($options['mailboxes'] ?? null), + ]; + + if (!empty($options['incremental-base'])) { + $params['incremental_base'] = $options['incremental-base']; + } + + info("Creating backup for $username..."); + + $timeout = (int) ($options['timeout'] ?? 7200); + $result = agentSendWithTimeout('backup.create', $params, $timeout); + + if ($result['success'] ?? false) { + $size = formatBytes((int) ($result['size'] ?? 0)); + success("Backup created: {$result['path']} ($size)"); + if (!empty($result['checksum'])) { + info("Checksum: {$result['checksum']}"); + } + if (isset($result['domains'])) { + info("Domains: " . count($result['domains'])); + } + if (isset($result['databases'])) { + info("Databases: " . count($result['databases'])); + } + if (isset($result['mailboxes'])) { + info("Mailboxes: " . count($result['mailboxes'])); + } + } else { + error("Backup failed: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + break; + + case 'restore': + $backupPath = $options['_args'][0] ?? $options['file'] ?? null; + $username = $options['_args'][1] ?? $options['user'] ?? $options['username'] ?? null; + + if (!is_string($backupPath)) { + error("Backup path is required"); + exit(1); + } + $backupPath = trim($backupPath); + if ($backupPath === '') { + error("Backup path is required"); + exit(1); + } + + if ($username !== null && !is_string($username)) { + $username = null; + } + if (is_string($username)) { + $username = trim($username); + if ($username === '') { + $username = null; + } + } + + $backupPath = resolveBackupPath($backupPath, $backupDir); + if (!file_exists($backupPath) && $username !== null) { + $backupPath = resolveBackupPath($backupPath, "/home/$username/backups"); + } + + if (!file_exists($backupPath)) { + error("Backup not found: $backupPath"); + exit(1); + } + + if ($username === null) { + $infoResult = agentSend('backup.get_info', ['backup_path' => $backupPath]); + if ($infoResult['success'] ?? false) { + $manifest = $infoResult['manifest'] ?? []; + $username = $manifest['username'] ?? null; + } + } + + if ($username === null) { + error("Username is required for restore. Use --user=."); + exit(1); + } + + if (!confirm("Restore backup '$backupPath' for user '$username'?", $options)) { + info("Operation cancelled"); + exit(0); + } + + $params = [ + 'username' => $username, + 'backup_path' => $backupPath, + 'restore_files' => !isset($options['no-files']), + 'restore_databases' => !isset($options['no-databases']), + 'restore_mailboxes' => !isset($options['no-mailboxes']), + 'restore_dns' => !isset($options['no-dns']), + 'restore_ssl' => !isset($options['no-ssl']), + 'selected_domains' => parseListOption($options['domains'] ?? null), + 'selected_databases' => parseListOption($options['databases'] ?? null), + 'selected_mailboxes' => parseListOption($options['mailboxes'] ?? null), + ]; + + info("Restoring backup..."); + + $timeout = (int) ($options['timeout'] ?? 7200); + $result = agentSendWithTimeout('backup.restore', $params, $timeout); + + if ($result['success'] ?? false) { + success("Backup restored successfully"); + } else { + error("Restore failed: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + break; + + case 'info': + $backupPath = $options['_args'][0] ?? $options['file'] ?? null; + $username = $options['user'] ?? $options['username'] ?? null; + + if (!is_string($backupPath)) { + error("Backup path is required"); + exit(1); + } + $backupPath = trim($backupPath); + if ($backupPath === '') { + error("Backup path is required"); + exit(1); + } + + if ($username !== null && !is_string($username)) { + $username = null; + } + if (is_string($username)) { + $username = trim($username); + if ($username === '') { + $username = null; + } + } + + $backupPath = resolveBackupPath($backupPath, $backupDir); + if (!file_exists($backupPath) && $username !== null) { + $backupPath = resolveBackupPath($backupPath, "/home/$username/backups"); + } + + if (!file_exists($backupPath)) { + error("Backup not found: $backupPath"); + exit(1); + } + + $result = agentSend('backup.get_info', ['backup_path' => $backupPath]); + if (!($result['success'] ?? false)) { + error("Failed to read backup info: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + + $manifest = $result['manifest'] ?? null; + + echo "\n" . C_BOLD . "Backup Info" . C_RESET . "\n"; + echo str_repeat('─', 60) . "\n"; + echo C_CYAN . "Path:" . C_RESET . " $backupPath\n"; + echo C_CYAN . "Type:" . C_RESET . " " . ($result['type'] ?? '-') . "\n"; + echo C_CYAN . "Size:" . C_RESET . " " . formatBytes((int) ($result['size'] ?? 0)) . "\n"; + echo C_CYAN . "Modified:" . C_RESET . " " . ($result['modified_at'] ?? '-') . "\n"; + + if (is_array($manifest)) { + if (!empty($manifest['username'])) { + echo C_CYAN . "User:" . C_RESET . " {$manifest['username']}\n"; + } + if (!empty($manifest['backup_type'])) { + echo C_CYAN . "Backup Type:" . C_RESET . " {$manifest['backup_type']}\n"; + } + if (!empty($manifest['includes']) && is_array($manifest['includes'])) { + $includes = array_keys(array_filter($manifest['includes'], fn($value) => $value)); + echo C_CYAN . "Includes:" . C_RESET . " " . (empty($includes) ? '-' : implode(', ', $includes)) . "\n"; + } + if (isset($manifest['domains'])) { + echo C_CYAN . "Domains:" . C_RESET . " " . count($manifest['domains']) . "\n"; + } + if (isset($manifest['databases'])) { + echo C_CYAN . "Databases:" . C_RESET . " " . count($manifest['databases']) . "\n"; + } + if (isset($manifest['mailboxes'])) { + echo C_CYAN . "Mailboxes:" . C_RESET . " " . count($manifest['mailboxes']) . "\n"; + } + } + + echo "\n"; + break; + + case 'verify': + $backupPath = $options['_args'][0] ?? $options['file'] ?? null; + $username = $options['user'] ?? $options['username'] ?? null; + + if (!is_string($backupPath)) { + error("Backup path is required"); + exit(1); + } + $backupPath = trim($backupPath); + if ($backupPath === '') { + error("Backup path is required"); + exit(1); + } + + if ($username !== null && !is_string($username)) { + $username = null; + } + if (is_string($username)) { + $username = trim($username); + if ($username === '') { + $username = null; + } + } + + $backupPath = resolveBackupPath($backupPath, $backupDir); + if (!file_exists($backupPath) && $username !== null) { + $backupPath = resolveBackupPath($backupPath, "/home/$username/backups"); + } + + if (!file_exists($backupPath)) { + error("Backup not found: $backupPath"); + exit(1); + } + + $result = agentSend('backup.verify', ['backup_path' => $backupPath]); + if ($result['success'] ?? false) { + success("Backup verified successfully"); + if (!empty($result['checksum'])) { + info("Checksum: {$result['checksum']}"); + } + } else { + error("Backup verification failed"); + $issues = $result['issues'] ?? []; + foreach ($issues as $issue) { + echo " - $issue\n"; + } + exit(1); + } + break; + + case 'delete': + $file = $options['_args'][0] ?? null; + $username = $options['user'] ?? $options['username'] ?? null; + + if ($file === null) { + error("Backup file or ID is required"); + exit(1); + } + if (is_string($file)) { + $file = trim($file); + } + if ($file === '') { + error("Backup file or ID is required"); + exit(1); + } + if (!is_string($file) && !is_numeric($file)) { + error("Backup file or ID is required"); + exit(1); + } + + if ($username !== null && !is_string($username)) { + error("Username is required"); + exit(1); + } + if (is_string($username)) { + $username = trim($username); + if ($username === '') { + $username = null; + } + } + + if ($username !== null) { + $backupPath = resolveBackupPath($file, "/home/$username/backups"); + if (!file_exists($backupPath)) { + error("Backup file not found: $backupPath"); + exit(1); + } + if (!confirm("Are you sure you want to delete '$backupPath'?", $options)) { + info("Operation cancelled"); + exit(0); + } + + $result = agentSend('backup.delete', [ + 'username' => $username, + 'backup_path' => $backupPath, + ]); + + if ($result['success'] ?? false) { + success("Backup deleted: $backupPath"); + } else { + error("Delete failed: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + break; + } + + if (is_numeric($file)) { + $backup = App\Models\Backup::find($file); + if (!$backup) { + error("Backup ID not found: $file"); + exit(1); + } + if (!confirm("Delete backup '{$backup->name}'?", $options)) { + info("Operation cancelled"); + exit(0); + } + + if ($backup->local_path && file_exists($backup->local_path)) { + if (is_dir($backup->local_path)) { + exec("rm -rf " . escapeshellarg($backup->local_path)); + } else { + unlink($backup->local_path); + } + } + + if ($backup->remote_path && $backup->destination) { + info("Deleting remote backup..."); + try { + $config = array_merge($backup->destination->config ?? [], ['type' => $backup->destination->type]); + agentSend('backup.delete_remote', [ + 'remote_path' => $backup->remote_path, + 'destination' => $config, + ]); + } catch (Exception $e) { + warning("Failed to delete remote: " . $e->getMessage()); + } + } + + $backup->delete(); + success("Backup deleted: {$backup->name}"); + } else { + $file = resolveBackupPath($file, $backupDir); + if (!file_exists($file)) { + error("Backup file not found: $file"); + exit(1); + } + if (!confirm("Are you sure you want to delete '$file'?", $options)) { + info("Operation cancelled"); + exit(0); + } + if (is_dir($file)) { + exec("rm -rf " . escapeshellarg($file)); + } else { + unlink($file); + } + success("Backup deleted: $file"); + } + break; + + // ========== SERVER BACKUPS ========== + case 'server': + case 'server-backup': + $backupType = $options['type'] ?? 'full'; + $users = parseListOption($options['users'] ?? null); + $destId = $options['destination'] ?? $options['dest'] ?? null; + $includeFiles = !isset($options['no-files']); + $includeDatabases = !isset($options['no-databases']); + $includeMailboxes = !isset($options['no-mailboxes']); + $includeDns = !isset($options['no-dns']); + + info("Creating server backup ($backupType)..."); + + $timestamp = date('Y-m-d_His'); + $outputPath = "$backupDir/$timestamp"; + + // Get destination if specified + $destination = null; + if ($destId) { + $destination = App\Models\BackupDestination::find($destId); + if (!$destination) { + error("Destination not found: $destId"); + exit(1); + } + } + + // Create backup record + $backup = App\Models\Backup::create([ + 'name' => "CLI Server Backup - " . now()->format('M j, Y H:i'), + 'filename' => $timestamp, + 'type' => 'server', + 'status' => 'running', + 'local_path' => $outputPath, + 'destination_id' => $destination?->id, + 'include_files' => $includeFiles, + 'include_databases' => $includeDatabases, + 'include_mailboxes' => $includeMailboxes, + 'include_dns' => $includeDns, + 'users' => $users, + 'started_at' => now(), + 'metadata' => ['backup_type' => $backupType], + ]); + + // Dispatch the job + App\Jobs\RunServerBackup::dispatch($backup->id); + + success("Server backup queued (ID: {$backup->id})"); + info("Monitor progress: jabali backup history"); + break; + + case 'server-list': + // List server backups from database + $backups = App\Models\Backup::where('type', 'server') + ->orderByDesc('created_at') + ->limit(20) + ->get(); + + if ($backups->isEmpty()) { + info("No server backups found."); + exit(0); + } + + $rows = []; + foreach ($backups as $backup) { + $rows[] = [ + $backup->id, + $backup->name, + formatBytes((int) ($backup->size_bytes ?? 0)), + $backup->status, + $backup->created_at->format('Y-m-d H:i'), + ]; + } + table(['ID', 'Name', 'Size', 'Status', 'Created'], $rows); + break; + + // ========== BACKUP HISTORY (DATABASE) ========== + case 'history': + $limit = $options['limit'] ?? 20; + $status = $options['status'] ?? null; + $type = $options['type'] ?? null; + + $query = App\Models\Backup::orderByDesc('created_at')->limit($limit); + if ($status) { + $query->where('status', $status); + } + if ($type) { + $query->where('type', $type); + } + + $backups = $query->get(); + + if ($backups->isEmpty()) { + info("No backups found."); + exit(0); + } + + $rows = []; + foreach ($backups as $backup) { + $location = []; + if ($backup->local_path) { + $location[] = 'local'; + } + if ($backup->remote_path) { + $location[] = 'remote'; + } + + $rows[] = [ + $backup->id, + strlen($backup->name) > 30 ? substr($backup->name, 0, 27) . '...' : $backup->name, + $backup->type, + formatBytes((int) ($backup->size_bytes ?? 0)), + $backup->status, + implode('+', $location) ?: '-', + $backup->created_at->format('Y-m-d H:i'), + ]; + } + table(['ID', 'Name', 'Type', 'Size', 'Status', 'Location', 'Created'], $rows); + break; + + case 'show': + $id = $options['_args'][0] ?? null; + if (!$id) { + error("Backup ID is required"); + exit(1); + } + + $backup = App\Models\Backup::with(['destination', 'schedule'])->find($id); + if (!$backup) { + error("Backup not found: $id"); + exit(1); + } + + echo "\n" . C_BOLD . "Backup Details" . C_RESET . "\n"; + echo str_repeat('─', 50) . "\n"; + echo C_CYAN . "ID:" . C_RESET . " $backup->id\n"; + echo C_CYAN . "Name:" . C_RESET . " $backup->name\n"; + echo C_CYAN . "Type:" . C_RESET . " $backup->type\n"; + echo C_CYAN . "Status:" . C_RESET . " " . statusColor((string) ($backup->status ?? '')) . "\n"; + echo C_CYAN . "Size:" . C_RESET . " " . formatBytes((int) ($backup->size_bytes ?? 0)) . "\n"; + if ($backup->local_path) { + echo C_CYAN . "Local Path:" . C_RESET . " $backup->local_path\n"; + } + if ($backup->remote_path) { + echo C_CYAN . "Remote Path:" . C_RESET . " $backup->remote_path\n"; + } + if ($backup->destination) { + echo C_CYAN . "Destination:" . C_RESET . " {$backup->destination->name} ({$backup->destination->type})\n"; + } + if ($backup->schedule) { + echo C_CYAN . "Schedule:" . C_RESET . " {$backup->schedule->name}\n"; + } + echo C_CYAN . "Created:" . C_RESET . " " . $backup->created_at->format('Y-m-d H:i:s') . "\n"; + if ($backup->started_at) { + echo C_CYAN . "Started:" . C_RESET . " " . $backup->started_at->format('Y-m-d H:i:s') . "\n"; + } + if ($backup->completed_at) { + echo C_CYAN . "Completed:" . C_RESET . " " . $backup->completed_at->format('Y-m-d H:i:s') . "\n"; + } + if ($backup->error_message) { + echo C_CYAN . "Error:" . C_RESET . " " . C_RED . $backup->error_message . C_RESET . "\n"; + } + echo "\n"; + break; + + // ========== SCHEDULES ========== + case 'schedules': + case 'schedule-list': + $schedules = App\Models\BackupSchedule::with('destination') + ->orderBy('name') + ->get(); + + if ($schedules->isEmpty()) { + info("No backup schedules found."); + exit(0); + } + + $rows = []; + foreach ($schedules as $schedule) { + $rows[] = [ + $schedule->id, + $schedule->name, + $schedule->frequency, + $schedule->is_active ? C_GREEN . 'active' . C_RESET : C_DIM . 'inactive' . C_RESET, + $schedule->retention_count, + $schedule->destination?->name ?? 'local', + $schedule->next_run_at?->format('Y-m-d H:i') ?? '-', + ]; + } + table(['ID', 'Name', 'Frequency', 'Status', 'Retention', 'Destination', 'Next Run'], $rows); + break; + + case 'schedule-create': + $name = $options['name'] ?? $options['_args'][0] ?? null; + if (!$name) { + error("Schedule name is required (--name=)"); + exit(1); + } + + $schedule = App\Models\BackupSchedule::create([ + 'name' => $name, + 'frequency' => $options['frequency'] ?? 'daily', + 'time' => $options['time'] ?? '02:00', + 'day_of_week' => $options['day'] ?? null, + 'day_of_month' => $options['date'] ?? null, + 'is_active' => true, + 'is_server_backup' => ($options['type'] ?? 'server') === 'server', + 'retention_count' => (int)($options['retention'] ?? 7), + 'destination_id' => $options['destination'] ?? $options['dest'] ?? null, + 'include_files' => !isset($options['no-files']), + 'include_databases' => !isset($options['no-databases']), + 'include_mailboxes' => !isset($options['no-mailboxes']), + 'include_dns' => !isset($options['no-dns']), + 'metadata' => ['backup_type' => $options['backup-type'] ?? 'full'], + ]); + + $schedule->calculateNextRun(); + $schedule->save(); + + success("Schedule created: {$schedule->name} (ID: {$schedule->id})"); + info("Next run: " . $schedule->next_run_at?->format('Y-m-d H:i')); + break; + + case 'schedule-run': + $id = $options['_args'][0] ?? null; + if (!$id) { + error("Schedule ID is required"); + exit(1); + } + + $schedule = App\Models\BackupSchedule::find($id); + if (!$schedule) { + error("Schedule not found: $id"); + exit(1); + } + + info("Running schedule: {$schedule->name}..."); + + // Create backup record and dispatch job + $timestamp = now()->format('Y-m-d_His'); + $backupType = $schedule->metadata['backup_type'] ?? 'full'; + + $backup = App\Models\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' => $timestamp, + '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' => "/var/backups/jabali/$timestamp", + 'metadata' => ['backup_type' => $backupType, 'schedule_id' => $schedule->id], + ]); + + App\Jobs\RunServerBackup::dispatch($backup->id); + + success("Backup queued (ID: {$backup->id})"); + break; + + case 'schedule-enable': + $id = $options['_args'][0] ?? null; + if (!$id) { + error("Schedule ID is required"); + exit(1); + } + + $schedule = App\Models\BackupSchedule::find($id); + if (!$schedule) { + error("Schedule not found: $id"); + exit(1); + } + + $schedule->update(['is_active' => true]); + $schedule->calculateNextRun(); + $schedule->save(); + + success("Schedule enabled: {$schedule->name}"); + break; + + case 'schedule-disable': + $id = $options['_args'][0] ?? null; + if (!$id) { + error("Schedule ID is required"); + exit(1); + } + + $schedule = App\Models\BackupSchedule::find($id); + if (!$schedule) { + error("Schedule not found: $id"); + exit(1); + } + + $schedule->update(['is_active' => false, 'next_run_at' => null]); + + success("Schedule disabled: {$schedule->name}"); + break; + + case 'schedule-delete': + $id = $options['_args'][0] ?? null; + if (!$id) { + error("Schedule ID is required"); + exit(1); + } + + $schedule = App\Models\BackupSchedule::find($id); + if (!$schedule) { + error("Schedule not found: $id"); + exit(1); + } + + if (!confirm("Delete schedule '{$schedule->name}'?", $options)) { + info("Operation cancelled"); + exit(0); + } + + $schedule->delete(); + success("Schedule deleted"); + break; + + // ========== DESTINATIONS ========== + case 'destinations': + case 'dest-list': + $destinations = App\Models\BackupDestination::orderBy('name')->get(); + + if ($destinations->isEmpty()) { + info("No backup destinations configured."); + info("Add one with: jabali backup dest-add --type=sftp --name=..."); + exit(0); + } + + $rows = []; + foreach ($destinations as $dest) { + $rows[] = [ + $dest->id, + $dest->name, + $dest->type, + $dest->config['host'] ?? $dest->config['path'] ?? '-', + $dest->is_active ? C_GREEN . 'active' . C_RESET : C_DIM . 'inactive' . C_RESET, + ]; + } + table(['ID', 'Name', 'Type', 'Host/Path', 'Status'], $rows); + break; + + case 'dest-add': + $type = $options['type'] ?? null; + $name = $options['name'] ?? null; + + if (!$type || !$name) { + error("Required: --type= --name="); + exit(1); + } + + $config = []; + switch ($type) { + case 'sftp': + $config = [ + 'host' => $options['host'] ?? null, + 'port' => (int)($options['port'] ?? 22), + 'username' => $options['user'] ?? $options['username'] ?? null, + 'password' => $options['password'] ?? null, + 'path' => $options['path'] ?? '/backups', + ]; + if (!$config['host'] || !$config['username']) { + error("SFTP requires: --host= --user= [--password=] [--port=22] [--path=/backups]"); + exit(1); + } + break; + + case 'nfs': + $config = [ + 'host' => $options['host'] ?? null, + 'path' => $options['path'] ?? null, + 'mount_point' => $options['mount'] ?? '/mnt/backup', + ]; + if (!$config['host'] || !$config['path']) { + error("NFS requires: --host= --path= [--mount=/mnt/backup]"); + exit(1); + } + break; + + case 's3': + $config = [ + 'bucket' => $options['bucket'] ?? null, + 'region' => $options['region'] ?? 'us-east-1', + 'access_key' => $options['key'] ?? $options['access-key'] ?? null, + 'secret_key' => $options['secret'] ?? $options['secret-key'] ?? null, + 'path' => $options['path'] ?? '', + ]; + if (!$config['bucket'] || !$config['access_key'] || !$config['secret_key']) { + error("S3 requires: --bucket= --key= --secret= [--region=us-east-1] [--path=]"); + exit(1); + } + break; + + default: + error("Unknown destination type: $type (use: sftp, nfs, s3)"); + exit(1); + } + + $destination = App\Models\BackupDestination::create([ + 'name' => $name, + 'type' => $type, + 'config' => $config, + 'is_active' => true, + ]); + + success("Destination created: {$destination->name} (ID: {$destination->id})"); + info("Test connection: jabali backup dest-test {$destination->id}"); + break; + + case 'dest-test': + $id = $options['_args'][0] ?? null; + if (!$id) { + error("Destination ID is required"); + exit(1); + } + + $destination = App\Models\BackupDestination::find($id); + if (!$destination) { + error("Destination not found: $id"); + exit(1); + } + + info("Testing connection to {$destination->name}..."); + + $config = array_merge($destination->config ?? [], ['type' => $destination->type]); + $result = agentSend('backup.test_destination', ['destination' => $config]); + + if ($result['success'] ?? false) { + success("Connection successful!"); + if (!empty($result['message'])) { + info($result['message']); + } + } else { + error("Connection failed: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + break; + + case 'dest-delete': + $id = $options['_args'][0] ?? null; + if (!$id) { + error("Destination ID is required"); + exit(1); + } + + $destination = App\Models\BackupDestination::find($id); + if (!$destination) { + error("Destination not found: $id"); + exit(1); + } + + // Check if any schedules use this destination + $scheduleCount = App\Models\BackupSchedule::where('destination_id', $id)->count(); + if ($scheduleCount > 0) { + error("Cannot delete: $scheduleCount schedule(s) use this destination"); + exit(1); + } + + if (!confirm("Delete destination '{$destination->name}'?", $options)) { + info("Operation cancelled"); + exit(0); + } + + $destination->delete(); + success("Destination deleted"); + break; + + // ========== HELP ========== + case 'help': + case '': + echo "\n" . C_BOLD . "Backup Commands" . C_RESET . "\n"; + echo str_repeat('─', 60) . "\n\n"; + + echo C_YELLOW . "Local Backups:" . C_RESET . "\n"; + echo " " . C_GREEN . "backup list [--user=]" . C_RESET . " List backups\n"; + echo " " . C_GREEN . "backup user-list " . C_RESET . " List user backups\n"; + echo " " . C_GREEN . "backup create " . C_RESET . " Create user backup\n"; + echo " --type=full|incremental Backup type (default: full)\n"; + echo " --output= Output file/dir\n"; + echo " --incremental-base= Base backup for incremental\n"; + echo " --domains=a,b Include domains\n"; + echo " --databases=a,b Include databases\n"; + echo " --mailboxes=a,b Include mailboxes\n"; + echo " --no-files --no-databases --no-mailboxes --no-dns --no-ssl\n"; + echo " " . C_GREEN . "backup restore []" . C_RESET . " Restore backup\n"; + echo " --user= User for server backups\n"; + echo " --domains=a,b Restore domains\n"; + echo " --databases=a,b Restore databases\n"; + echo " --mailboxes=a,b Restore mailboxes\n"; + echo " --no-files --no-databases --no-mailboxes --no-dns --no-ssl\n"; + echo " " . C_GREEN . "backup info " . C_RESET . " Show backup info\n"; + echo " " . C_GREEN . "backup verify " . C_RESET . " Verify backup\n"; + echo " " . C_GREEN . "backup delete " . C_RESET . " Delete backup\n"; + echo " --user= Delete user backup\n\n"; + + echo C_YELLOW . "Server Backups:" . C_RESET . "\n"; + echo " " . C_GREEN . "backup server" . C_RESET . " Create server backup\n"; + echo " --type=full|incremental Backup type (default: full)\n"; + echo " --users=user1,user2 Specific users only\n"; + echo " --dest= Upload to destination\n"; + echo " " . C_GREEN . "backup server-list" . C_RESET . " List server backups\n\n"; + + echo C_YELLOW . "Backup History:" . C_RESET . "\n"; + echo " " . C_GREEN . "backup history" . C_RESET . " Show all backups from database\n"; + echo " --limit=20 Number of records\n"; + echo " --status=completed|failed Filter by status\n"; + echo " --type=server|user Filter by type\n"; + echo " " . C_GREEN . "backup show " . C_RESET . " Show backup details\n\n"; + + echo C_YELLOW . "Schedules:" . C_RESET . "\n"; + echo " " . C_GREEN . "backup schedules" . C_RESET . " List backup schedules\n"; + echo " " . C_GREEN . "backup schedule-create" . C_RESET . " Create a schedule\n"; + echo " --name= Schedule name (required)\n"; + echo " --frequency=daily|weekly Run frequency\n"; + echo " --time=02:00 Run time (24h)\n"; + echo " --retention=7 Keep N backups\n"; + echo " --dest= Destination ID\n"; + echo " --backup-type=full|incremental Backup type\n"; + echo " " . C_GREEN . "backup schedule-run " . C_RESET . " Run schedule now\n"; + echo " " . C_GREEN . "backup schedule-enable " . C_RESET . " Enable schedule\n"; + echo " " . C_GREEN . "backup schedule-disable " . C_RESET . " Disable schedule\n"; + echo " " . C_GREEN . "backup schedule-delete " . C_RESET . " Delete schedule\n\n"; + + echo C_YELLOW . "Destinations:" . C_RESET . "\n"; + echo " " . C_GREEN . "backup destinations" . C_RESET . " List destinations\n"; + echo " " . C_GREEN . "backup dest-add" . C_RESET . " Add destination\n"; + echo " --type=sftp --host= --user=\n"; + echo " --type=nfs --host= --path=\n"; + echo " --type=s3 --bucket= --key= --secret=\n"; + echo " " . C_GREEN . "backup dest-test " . C_RESET . " Test connection\n"; + echo " " . C_GREEN . "backup dest-delete " . C_RESET . " Delete destination\n\n"; + break; + + default: + error("Unknown backup command: $subcommand"); + echo "Run 'jabali backup help' for usage.\n"; + exit(1); + } +} + +// ============ CPANEL COMMANDS ============ + +function handleCpanel(string $subcommand, array $options): void +{ + $defaultDir = '/var/backups/jabali/cpanel-migrations'; + + switch ($subcommand) { + case 'analyze': + $backupPath = $options['_args'][0] ?? $options['file'] ?? null; + if (!is_string($backupPath)) { + error("Backup file is required"); + exit(1); + } + $backupPath = trim($backupPath); + if ($backupPath === '') { + error("Backup file is required"); + exit(1); + } + + $backupPath = resolveBackupPath($backupPath, $defaultDir); + if (!file_exists($backupPath)) { + error("Backup file not found: $backupPath"); + exit(1); + } + + info("Analyzing cPanel backup..."); + $timeout = (int) ($options['timeout'] ?? 600); + $result = agentSendWithTimeout('cpanel.analyze_backup', ['backup_path' => $backupPath], $timeout); + + if (!($result['success'] ?? false)) { + error("Analysis failed: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + + $data = $result['data'] ?? []; + printCpanelAnalysis($backupPath, $data); + break; + + case 'restore': + $backupPath = $options['_args'][0] ?? $options['file'] ?? null; + $username = $options['_args'][1] ?? $options['user'] ?? $options['username'] ?? null; + + if (!is_string($backupPath)) { + error("Backup file is required"); + exit(1); + } + $backupPath = trim($backupPath); + if ($backupPath === '') { + error("Backup file is required"); + exit(1); + } + if (!is_string($username)) { + error("Username is required"); + exit(1); + } + $username = trim($username); + if ($username === '') { + error("Username is required"); + exit(1); + } + + $backupPath = resolveBackupPath($backupPath, $defaultDir); + if (!file_exists($backupPath)) { + error("Backup file not found: $backupPath"); + exit(1); + } + + $panelUser = App\Models\User::where('username', $username)->orWhere('name', $username)->first(); + $systemCheck = agentSend('user.exists', ['username' => $username]); + $systemExists = (bool) ($systemCheck['exists'] ?? false); + + if (!$panelUser || !$systemExists) { + $missing = []; + if (!$systemExists) { + $missing[] = 'system user'; + } + if (!$panelUser) { + $missing[] = 'panel user'; + } + error("Missing " . implode(' and ', $missing) . " for '$username'. Create with: jabali user create $username"); + exit(1); + } + + $restoreOptions = [ + 'backup_path' => $backupPath, + 'username' => $username, + 'restore_files' => !isset($options['no-files']), + 'restore_databases' => !isset($options['no-databases']), + 'restore_emails' => !isset($options['no-emails']), + 'restore_ssl' => !isset($options['no-ssl']), + ]; + + if (!empty($options['log'])) { + $restoreOptions['log_path'] = $options['log']; + } + + if (isset($options['analyze'])) { + info("Analyzing backup..."); + $analysisTimeout = (int) ($options['analysis-timeout'] ?? $options['timeout'] ?? 600); + $analysisResult = agentSendWithTimeout('cpanel.analyze_backup', ['backup_path' => $backupPath], $analysisTimeout); + if (!($analysisResult['success'] ?? false)) { + error("Analysis failed: " . ($analysisResult['error'] ?? 'Unknown error')); + exit(1); + } + $analysisData = $analysisResult['data'] ?? []; + printCpanelAnalysis($backupPath, $analysisData); + $restoreOptions['discovered_data'] = $analysisData; + } + + if (!confirm("Restore backup '$backupPath' into '$username'?", $options)) { + info("Operation cancelled"); + exit(0); + } + + info("Restoring cPanel backup..."); + $timeout = (int) ($options['timeout'] ?? 7200); + $result = agentSendWithTimeout('cpanel.restore_backup', $restoreOptions, $timeout); + + if (!($result['success'] ?? false)) { + error("Restore failed: " . ($result['error'] ?? 'Unknown error')); + $logEntries = $result['log'] ?? []; + if (is_array($logEntries) && !empty($logEntries)) { + printCpanelLog($logEntries); + } + exit(1); + } + + $logEntries = $result['log'] ?? []; + if (is_array($logEntries) && !empty($logEntries)) { + printCpanelLog($logEntries); + } + + success("cPanel restore completed"); + break; + + case 'fix-permissions': + $backupPath = $options['_args'][0] ?? $options['file'] ?? null; + if (!is_string($backupPath)) { + error("Backup file is required"); + exit(1); + } + $backupPath = trim($backupPath); + if ($backupPath === '') { + error("Backup file is required"); + exit(1); + } + + $backupPath = resolveBackupPath($backupPath, $defaultDir); + $result = agentSend('cpanel.fix_backup_permissions', ['backup_path' => $backupPath]); + if (!($result['success'] ?? false)) { + error("Fix permissions failed: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + success("Permissions updated for $backupPath"); + break; + + case 'help': + case '': + echo "\n" . C_BOLD . "cPanel Migration Commands" . C_RESET . "\n"; + echo str_repeat('─', 60) . "\n\n"; + + echo C_YELLOW . "Analyze:" . C_RESET . "\n"; + echo " " . C_GREEN . "cpanel analyze " . C_RESET . " Analyze backup contents\n"; + echo " --timeout=600 Analysis timeout\n\n"; + + echo C_YELLOW . "Restore:" . C_RESET . "\n"; + echo " " . C_GREEN . "cpanel restore " . C_RESET . " Restore backup\n"; + echo " --no-files Skip website files\n"; + echo " --no-databases Skip databases\n"; + echo " --no-emails Skip mailboxes/forwarders\n"; + echo " --no-ssl Skip SSL certificates\n"; + echo " --log=/path/to/log.jsonl Append log entries to file\n"; + echo " --analyze Run analysis and reuse results\n"; + echo " --timeout=7200 Restore timeout\n\n"; + + echo C_YELLOW . "Maintenance:" . C_RESET . "\n"; + echo " " . C_GREEN . "cpanel fix-permissions " . C_RESET . " Fix backup file permissions\n\n"; + break; + + default: + error("Unknown cPanel command: $subcommand"); + echo "Run 'jabali cpanel help' for usage.\n"; + exit(1); + } +} + +function formatCpanelStatus(string $status): string +{ + return match ($status) { + 'success' => C_GREEN . '✓' . C_RESET, + 'error' => C_RED . '✗' . C_RESET, + 'warning' => C_YELLOW . '⚠' . C_RESET, + 'pending' => C_CYAN . '○' . C_RESET, + 'info' => C_CYAN . 'ℹ' . C_RESET, + default => $status !== '' ? $status : '-', + }; +} + +function printCpanelLog(array $entries): void +{ + foreach ($entries as $entry) { + $status = formatCpanelStatus((string) ($entry['status'] ?? '')); + $time = $entry['time'] ?? ''; + $message = $entry['message'] ?? ''; + $timeLabel = $time !== '' ? "[$time] " : ''; + echo $status . " " . $timeLabel . $message . "\n"; + } +} + +function printCpanelAnalysis(string $backupPath, array $data): void +{ + $domains = $data['domains'] ?? []; + $databases = $data['databases'] ?? []; + $mailboxes = $data['mailboxes'] ?? []; + $forwarders = $data['forwarders'] ?? []; + $ssl = $data['ssl_certificates'] ?? []; + + echo "\n" . C_BOLD . "cPanel Backup Analysis" . C_RESET . "\n"; + echo str_repeat('─', 60) . "\n"; + echo C_CYAN . "File:" . C_RESET . " $backupPath\n"; + if (!empty($data['total_size'])) { + echo C_CYAN . "Size:" . C_RESET . " " . formatBytes((int) $data['total_size']) . "\n"; + } + if (!empty($data['cpanel_username'])) { + echo C_CYAN . "cPanel User:" . C_RESET . " {$data['cpanel_username']}\n"; + } + echo C_CYAN . "Domains:" . C_RESET . " " . count($domains) . "\n"; + echo C_CYAN . "Databases:" . C_RESET . " " . count($databases) . "\n"; + echo C_CYAN . "Mailboxes:" . C_RESET . " " . count($mailboxes) . "\n"; + echo C_CYAN . "Forwarders:" . C_RESET . " " . count($forwarders) . "\n"; + echo C_CYAN . "SSL Certificates:" . C_RESET . " " . count($ssl) . "\n\n"; + + if (!empty($domains)) { + $rows = []; + foreach ($domains as $domain) { + $rows[] = [ + $domain['name'] ?? (string) $domain, + $domain['type'] ?? '-', + ]; + } + table(['Domain', 'Type'], $rows); + echo "\n"; + } + + if (!empty($databases)) { + $rows = []; + foreach ($databases as $database) { + $rows[] = [ + $database['name'] ?? (string) $database, + ]; + } + table(['Database'], $rows); + echo "\n"; + } + + if (!empty($mailboxes)) { + $rows = []; + foreach ($mailboxes as $mailbox) { + $rows[] = [ + $mailbox['email'] ?? (string) $mailbox, + ]; + } + table(['Mailbox'], $rows); + echo "\n"; + } + + if (!empty($forwarders)) { + $rows = []; + foreach ($forwarders as $forwarder) { + $rows[] = [ + $forwarder['email'] ?? '-', + $forwarder['destinations'] ?? ($forwarder['format'] ?? '-'), + ]; + } + table(['Forwarder', 'Destinations'], $rows); + echo "\n"; + } + + if (!empty($ssl)) { + $rows = []; + foreach ($ssl as $cert) { + $rows[] = [ + $cert['domain'] ?? '-', + $cert['keyid'] ?? '-', + ]; + } + table(['Domain', 'Key ID'], $rows); + echo "\n"; + } +} + +function formatBytes(int $bytes, int $precision = 2): string +{ + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $bytes = max($bytes, 0); + $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); + $pow = min($pow, count($units) - 1); + $bytes /= pow(1024, $pow); + return round($bytes, $precision) . ' ' . $units[$pow]; +} + +function parseListOption(string|bool|null $value): ?array +{ + if (!is_string($value) || $value === '') { + return null; + } + + $items = array_map('trim', explode(',', $value)); + $items = array_values(array_filter($items, fn($item) => $item !== '')); + + return empty($items) ? null : $items; +} + +function resolveBackupPath(string $path, string $defaultDir): string +{ + if (file_exists($path)) { + return $path; + } + + $candidate = rtrim($defaultDir, '/') . '/' . ltrim($path, '/'); + if (file_exists($candidate)) { + return $candidate; + } + + return $path; +} + +function statusColor(string $status): string +{ + return match ($status) { + 'completed' => C_GREEN . $status . C_RESET, + 'running', 'uploading', 'pending' => C_YELLOW . $status . C_RESET, + 'failed' => C_RED . $status . C_RESET, + default => $status, + }; +} + +// ============ SYSTEM COMMANDS ============ + +function handleSystem(string $subcommand, array $options): void +{ + switch ($subcommand) { + case 'info': + $result = agentSend('server.info', []); + echo "\n" . C_BOLD . "System Information" . C_RESET . "\n"; + echo str_repeat('─', 50) . "\n"; + + // Hostname + echo C_CYAN . "Hostname:" . C_RESET . " " . trim(shell_exec('hostname')) . "\n"; + + // OS + if (file_exists('/etc/os-release')) { + $osRelease = parse_ini_file('/etc/os-release'); + echo C_CYAN . "OS:" . C_RESET . " " . ($osRelease['PRETTY_NAME'] ?? 'Unknown') . "\n"; + } + + // Kernel + echo C_CYAN . "Kernel:" . C_RESET . " " . trim(shell_exec('uname -r')) . "\n"; + + // Uptime + $uptime = trim(shell_exec('uptime -p')); + echo C_CYAN . "Uptime:" . C_RESET . " $uptime\n"; + + // CPU + $cpuInfo = shell_exec("grep 'model name' /proc/cpuinfo | head -1 | cut -d: -f2"); + $cpuCores = trim(shell_exec("nproc")); + echo C_CYAN . "CPU:" . C_RESET . " " . trim($cpuInfo) . " ($cpuCores cores)\n"; + + // Load + $load = sys_getloadavg(); + echo C_CYAN . "Load:" . C_RESET . " " . implode(', ', array_map(fn($l) => number_format($l, 2), $load)) . "\n"; + + // Memory + $memInfo = shell_exec('free -m | grep Mem'); + if (preg_match('/Mem:\s+(\d+)\s+(\d+)/', $memInfo, $m)) { + $total = $m[1]; + $used = $m[2]; + $pct = round($used / $total * 100); + echo C_CYAN . "Memory:" . C_RESET . " {$used}MB / {$total}MB ({$pct}%)\n"; + } + + // Disk + $diskTotal = disk_total_space('/'); + $diskFree = disk_free_space('/'); + $diskUsed = $diskTotal - $diskFree; + $diskPct = round($diskUsed / $diskTotal * 100); + echo C_CYAN . "Disk:" . C_RESET . " " . round($diskUsed / 1024 / 1024 / 1024, 1) . "GB / " . round($diskTotal / 1024 / 1024 / 1024, 1) . "GB ({$diskPct}%)\n"; + + // PHP + echo C_CYAN . "PHP:" . C_RESET . " " . PHP_VERSION . "\n"; + + // Laravel + echo C_CYAN . "Laravel:" . C_RESET . " " . app()->version() . "\n"; + break; + + case 'status': + handleService('list', $options); + break; + + case 'hostname': + $newHostname = $options['_args'][0] ?? null; + if ($newHostname) { + $result = agentSend('server.set_hostname', ['hostname' => $newHostname]); + if ($result['success'] ?? false) { + success("Hostname set to '$newHostname'"); + } else { + error("Failed to set hostname: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + } else { + echo trim(shell_exec('hostname')) . "\n"; + } + break; + + case 'disk': + echo shell_exec('df -h'); + break; + + case 'memory': + echo shell_exec('free -h'); + break; + + default: + error("Unknown system command: $subcommand"); + exit(1); + } +} + +// ============ AGENT COMMANDS ============ + +function handleAgent(string $subcommand, array $options): void +{ + $agentScript = JABALI_ROOT . '/bin/jabali-agent'; + $pidFile = '/var/run/jabali/agent.pid'; + + switch ($subcommand) { + case 'status': + if (file_exists(AGENT_SOCKET)) { + $result = agentSend('ping', []); + if ($result['success'] ?? false) { + success("Agent is running (version: " . ($result['version'] ?? 'unknown') . ")"); + } else { + warning("Agent socket exists but not responding"); + } + } else { + error("Agent is not running"); + } + break; + + case 'start': + if (file_exists(AGENT_SOCKET)) { + $result = agentSend('ping', []); + if ($result['success'] ?? false) { + info("Agent is already running"); + exit(0); + } + } + info("Starting agent..."); + exec("nohup /usr/bin/php $agentScript > /dev/null 2>&1 &"); + sleep(2); + if (file_exists(AGENT_SOCKET)) { + success("Agent started"); + } else { + error("Failed to start agent"); + exit(1); + } + break; + + case 'stop': + if (file_exists($pidFile)) { + $pid = trim(file_get_contents($pidFile)); + if ($pid && posix_kill((int)$pid, 15)) { + sleep(1); + success("Agent stopped"); + } else { + exec("pkill -f jabali-agent"); + success("Agent stopped"); + } + } else { + exec("pkill -f jabali-agent"); + info("Agent stopped"); + } + break; + + case 'restart': + handleAgent('stop', $options); + sleep(1); + handleAgent('start', $options); + break; + + case 'ping': + $result = agentSend('ping', []); + if ($result['success'] ?? false) { + success("Pong! Agent version: " . ($result['version'] ?? 'unknown')); + } else { + error("Agent not responding: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + break; + + case 'log': + $lines = $options['lines'] ?? 50; + $logFile = '/var/log/jabali/agent.log'; + if (file_exists($logFile)) { + passthru("tail -n $lines " . escapeshellarg($logFile)); + } else { + error("Log file not found: $logFile"); + } + break; + + default: + error("Unknown agent command: $subcommand"); + exit(1); + } +} + +// ============ PHP COMMANDS ============ + +function handlePhp(string $subcommand, array $options): void +{ + switch ($subcommand) { + case 'list': + $result = agentSend('php.list_versions', []); + if (!($result['success'] ?? false)) { + // Fallback to local detection + exec('ls /lib/systemd/system/php*-fpm.service 2>/dev/null', $services); + $rows = []; + foreach ($services as $svc) { + if (preg_match('/php([\d.]+)-fpm/', $svc, $m)) { + $version = $m[1]; + exec("systemctl is-active php{$version}-fpm 2>/dev/null", $active); + $isActive = (trim($active[0] ?? '') === 'active'); + $rows[] = [ + $version, + $isActive ? C_GREEN . 'Running' . C_RESET : C_RED . 'Stopped' . C_RESET, + ]; + } + } + table(['Version', 'Status'], $rows); + } else { + $rows = []; + foreach ($result['versions'] ?? [] as $v) { + $rows[] = [ + is_array($v) ? $v['version'] : $v, + is_array($v) ? ($v['active'] ? C_GREEN . 'Running' . C_RESET : C_RED . 'Stopped' . C_RESET) : '', + ]; + } + table(['Version', 'Status'], $rows); + } + break; + + case 'install': + $version = $options['_args'][0] ?? null; + if (!$version) { + error("PHP version is required (e.g., 8.2)"); + exit(1); + } + info("Installing PHP $version..."); + $result = agentSend('php.install', ['version' => $version]); + if ($result['success'] ?? false) { + success("PHP $version installed"); + } else { + error("Failed to install PHP $version: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + break; + + case 'uninstall': + $version = $options['_args'][0] ?? null; + if (!$version) { + error("PHP version is required"); + exit(1); + } + if (!confirm("Are you sure you want to uninstall PHP $version?", $options)) { + info("Operation cancelled"); + exit(0); + } + $result = agentSend('php.uninstall', ['version' => $version]); + if ($result['success'] ?? false) { + success("PHP $version uninstalled"); + } else { + error("Failed to uninstall: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + break; + + case 'default': + $version = $options['_args'][0] ?? null; + if ($version) { + $result = agentSend('php.set_default', ['version' => $version]); + if ($result['success'] ?? false) { + success("Default PHP version set to $version"); + } else { + error("Failed to set default: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + } else { + $current = trim(shell_exec('php -v | head -1 | cut -d" " -f2 | cut -d"." -f1,2')); + echo "Current default: PHP $current\n"; + } + break; + + case 'status': + exec('ls /lib/systemd/system/php*-fpm.service 2>/dev/null', $services); + foreach ($services as $svc) { + if (preg_match('/php([\d.]+)-fpm/', $svc, $m)) { + $service = "php{$m[1]}-fpm"; + passthru("systemctl status $service --no-pager -l | head -15"); + echo "\n"; + } + } + break; + + default: + error("Unknown PHP command: $subcommand"); + exit(1); + } +} + +// ============ FIREWALL COMMANDS ============ + +function handleFirewall(string $subcommand, array $options): void +{ + switch ($subcommand) { + case 'status': + $result = agentSend('ufw.status', []); + if ($result['success'] ?? false) { + echo C_BOLD . "Firewall Status: " . C_RESET; + echo ($result['enabled'] ?? false) ? C_GREEN . "Active" . C_RESET : C_RED . "Inactive" . C_RESET; + echo "\n"; + } else { + passthru('ufw status'); + } + break; + + case 'enable': + $result = agentSend('ufw.enable', []); + if ($result['success'] ?? false) { + success("Firewall enabled"); + } else { + error("Failed to enable firewall: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + break; + + case 'disable': + if (!confirm("Are you sure you want to disable the firewall?", $options)) { + info("Operation cancelled"); + exit(0); + } + $result = agentSend('ufw.disable', []); + if ($result['success'] ?? false) { + success("Firewall disabled"); + } else { + error("Failed to disable firewall: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + break; + + case 'rules': + $result = agentSend('ufw.list_rules', []); + if ($result['success'] ?? false) { + $rows = []; + foreach ($result['rules'] ?? [] as $rule) { + $rows[] = [ + $rule['number'] ?? '', + $rule['to'] ?? '', + $rule['action'] ?? '', + $rule['from'] ?? '', + ]; + } + table(['#', 'To', 'Action', 'From'], $rows); + } else { + passthru('ufw status numbered'); + } + break; + + case 'allow': + $port = $options['_args'][0] ?? null; + if (!$port) { + error("Port is required"); + exit(1); + } + $result = agentSend('ufw.allow_port', ['port' => $port]); + if ($result['success'] ?? false) { + success("Allowed port $port"); + } else { + error("Failed to allow port: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + break; + + case 'deny': + $port = $options['_args'][0] ?? null; + if (!$port) { + error("Port is required"); + exit(1); + } + $result = agentSend('ufw.deny_port', ['port' => $port]); + if ($result['success'] ?? false) { + success("Denied port $port"); + } else { + error("Failed to deny port: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + break; + + case 'delete': + $rule = $options['_args'][0] ?? null; + if (!$rule) { + error("Rule number is required"); + exit(1); + } + if (!confirm("Are you sure you want to delete rule #$rule?", $options)) { + info("Operation cancelled"); + exit(0); + } + $result = agentSend('ufw.delete_rule', ['rule' => $rule]); + if ($result['success'] ?? false) { + success("Rule deleted"); + } else { + error("Failed to delete rule: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + break; + + default: + error("Unknown firewall command: $subcommand"); + exit(1); + } +} + +function handleSsl(string $subcommand, array $options): void +{ + switch ($subcommand) { + case 'check': + $domain = $options['_args'][0] ?? null; + info("Checking SSL certificates..."); + + $cmd = 'php /var/www/jabali/artisan jabali:ssl-check'; + if ($domain) { + $cmd .= ' --domain=' . escapeshellarg($domain); + } + if ($options['issue-only'] ?? false) { + $cmd .= ' --issue-only'; + } + if ($options['renew-only'] ?? false) { + $cmd .= ' --renew-only'; + } + + passthru($cmd, $exitCode); + exit($exitCode); + + case 'issue': + $domain = $options['_args'][0] ?? null; + if (!$domain) { + error("Domain is required"); + echo "Usage: jabali ssl issue \n"; + exit(1); + } + + info("Issuing SSL certificate for $domain..."); + $result = agentSend('ssl.issue', [ + 'domain' => $domain, + 'force' => $options['force'] ?? false, + ]); + + if ($result['success'] ?? false) { + success("SSL certificate issued for $domain"); + if (!empty($result['valid_to'])) { + echo " Expires: " . $result['valid_to'] . "\n"; + } + } else { + error("Failed to issue SSL: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + break; + + case 'renew': + $domain = $options['_args'][0] ?? null; + if (!$domain) { + error("Domain is required"); + echo "Usage: jabali ssl renew \n"; + exit(1); + } + + info("Renewing SSL certificate for $domain..."); + $result = agentSend('ssl.renew', ['domain' => $domain]); + + if ($result['success'] ?? false) { + success("SSL certificate renewed for $domain"); + if (!empty($result['valid_to'])) { + echo " New expiry: " . $result['valid_to'] . "\n"; + } + } else { + error("Failed to renew SSL: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + break; + + case 'status': + $domain = $options['_args'][0] ?? null; + if (!$domain) { + error("Domain is required"); + echo "Usage: jabali ssl status \n"; + exit(1); + } + + $result = agentSend('ssl.info', ['domain' => $domain]); + + if ($result['success'] ?? false) { + echo C_BOLD . "SSL Certificate Status for $domain" . C_RESET . "\n\n"; + echo " Status: " . (($result['valid'] ?? false) ? C_GREEN . "Valid" : C_RED . "Invalid") . C_RESET . "\n"; + echo " Issuer: " . ($result['issuer'] ?? 'Unknown') . "\n"; + echo " From: " . ($result['valid_from'] ?? 'Unknown') . "\n"; + echo " To: " . ($result['valid_to'] ?? 'Unknown') . "\n"; + + if (!empty($result['days_remaining'])) { + $days = $result['days_remaining']; + $color = $days > 30 ? C_GREEN : ($days > 7 ? C_YELLOW : C_RED); + echo " Days: " . $color . $days . " days remaining" . C_RESET . "\n"; + } + } else { + error("Failed to get SSL status: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + break; + + case 'list': + info("Listing SSL certificates..."); + $result = agentSend('ssl.list', []); + + if ($result['success'] ?? false) { + $rows = []; + foreach ($result['certificates'] ?? [] as $cert) { + $status = ($cert['valid'] ?? false) ? C_GREEN . 'Valid' . C_RESET : C_RED . 'Invalid' . C_RESET; + $days = $cert['days_remaining'] ?? 0; + $daysColor = $days > 30 ? C_GREEN : ($days > 7 ? C_YELLOW : C_RED); + + $rows[] = [ + $cert['domain'] ?? '', + $status, + $cert['issuer'] ?? '', + $cert['valid_to'] ?? '', + $daysColor . $days . C_RESET, + ]; + } + table(['Domain', 'Status', 'Issuer', 'Expires', 'Days'], $rows); + } else { + error("Failed to list SSL certificates: " . ($result['error'] ?? 'Unknown error')); + exit(1); + } + break; + + case '': + case 'help': + echo C_BOLD . "SSL Certificate Management" . C_RESET . "\n\n"; + echo "Usage: jabali ssl [options]\n\n"; + echo C_YELLOW . "Commands:" . C_RESET . "\n"; + echo " " . C_GREEN . "check" . C_RESET . " Check all domains and issue/renew as needed\n"; + echo " " . C_GREEN . "check " . C_RESET . " Check specific domain\n"; + echo " " . C_GREEN . "issue " . C_RESET . " Issue SSL certificate for domain\n"; + echo " " . C_GREEN . "renew " . C_RESET . " Renew SSL certificate for domain\n"; + echo " " . C_GREEN . "status " . C_RESET . " Show SSL status for domain\n"; + echo " " . C_GREEN . "list" . C_RESET . " List all SSL certificates\n\n"; + echo C_YELLOW . "Options:" . C_RESET . "\n"; + echo " " . C_GREEN . "--issue-only" . C_RESET . " Only issue new certificates (with check)\n"; + echo " " . C_GREEN . "--renew-only" . C_RESET . " Only renew expiring certificates (with check)\n"; + echo " " . C_GREEN . "--force" . C_RESET . " Force issue even if certificate exists\n"; + break; + + default: + error("Unknown ssl command: $subcommand"); + echo "Run 'jabali ssl help' for usage information.\n"; + exit(1); + } +} diff --git a/bin/jabali-agent b/bin/jabali-agent new file mode 100755 index 0000000..938f202 --- /dev/null +++ b/bin/jabali-agent @@ -0,0 +1,25218 @@ +#!/usr/bin/env php + '', + // SET GLOBAL statements + '/^\s*SET\s+GLOBAL\s+/im' => '-- BLOCKED: SET GLOBAL ', + // GRANT statements embedded in dumps + '/^\s*GRANT\s+/im' => '-- BLOCKED: GRANT ', + // REVOKE statements + '/^\s*REVOKE\s+/im' => '-- BLOCKED: REVOKE ', + // CREATE USER statements + '/^\s*CREATE\s+USER\s+/im' => '-- BLOCKED: CREATE USER ', + // DROP USER statements + '/^\s*DROP\s+USER\s+/im' => '-- BLOCKED: DROP USER ', + // LOAD DATA INFILE (file reading) + '/LOAD\s+DATA\s+(LOCAL\s+)?INFILE/i' => '-- BLOCKED: LOAD DATA INFILE', + // SELECT INTO OUTFILE/DUMPFILE (file writing) + '/SELECT\s+.*\s+INTO\s+(OUTFILE|DUMPFILE)/i' => '-- BLOCKED: SELECT INTO OUTFILE', + // INSTALL/UNINSTALL PLUGIN + '/^\s*(INSTALL|UNINSTALL)\s+PLUGIN/im' => '-- BLOCKED: PLUGIN ', + ]; + + try { + if ($isGzipped) { + $content = gzdecode(file_get_contents($filePath)); + if ($content === false) { + return false; + } + } else { + $content = file_get_contents($filePath); + } + + // Apply sanitization + $modified = false; + foreach ($dangerousPatterns as $pattern => $replacement) { + $newContent = preg_replace($pattern, $replacement, $content); + if ($newContent !== $content) { + $modified = true; + $content = $newContent; + logger( "Sanitized dangerous pattern in database dump: $filePath"); + } + } + + // Check for USE statements pointing to other users' databases + if (preg_match_all('/^\s*USE\s+[`\'"]?([a-zA-Z0-9_]+)[`\'"]?\s*;/im', $content, $matches)) { + foreach ($matches[1] as $dbName) { + if (strpos($dbName, $prefix) !== 0) { + // USE statement for a database not owned by this user + $content = preg_replace( + '/^\s*USE\s+[`\'"]?' . preg_quote($dbName, '/') . '[`\'"]?\s*;/im', + "-- BLOCKED: USE $dbName (not owned by user)", + $content + ); + $modified = true; + logger( "Blocked USE statement for foreign database: $dbName"); + } + } + } + + if ($modified) { + if ($isGzipped) { + file_put_contents($filePath, gzencode($content)); + } else { + file_put_contents($filePath, $content); + } + } + + return true; + } catch (Exception $e) { + logger( "Failed to sanitize database dump: " . $e->getMessage()); + return false; + } +} + +/** + * Check a directory for dangerous symlinks that point outside allowed paths. + * Returns array of dangerous symlink paths found. + */ +function findDangerousSymlinks(string $directory, string $allowedBase): array +{ + $dangerous = []; + + if (!is_dir($directory)) { + return $dangerous; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($iterator as $file) { + if (is_link($file->getPathname())) { + $target = readlink($file->getPathname()); + if ($target === false) { + continue; + } + + // Resolve absolute path of symlink target + if ($target[0] !== '/') { + $target = dirname($file->getPathname()) . '/' . $target; + } + $realTarget = realpath($target); + + // If target doesn't exist or is outside allowed base, it's dangerous + if ($realTarget === false || strpos($realTarget, $allowedBase) !== 0) { + $dangerous[] = $file->getPathname(); + logger( "Found dangerous symlink: {$file->getPathname()} -> $target"); + } + } + } + + return $dangerous; +} + +/** + * Remove dangerous symlinks from a directory. + */ +function removeDangerousSymlinks(string $directory, string $allowedBase): int +{ + $dangerous = findDangerousSymlinks($directory, $allowedBase); + $removed = 0; + + foreach ($dangerous as $symlink) { + if (unlink($symlink)) { + $removed++; + logger( "Removed dangerous symlink: $symlink"); + } + } + + return $removed; +} + +function handleAction(array $request): array +{ + $action = $request['action'] ?? ''; + $params = $request['params'] ?? $request; + + return match ($action) { + 'ping' => ['success' => true, 'message' => 'pong', 'version' => '1.0.0'], + 'user.create' => createUser($params), + 'user.delete' => deleteUser($params), + 'user.exists' => userExists($params), + 'ufw.status' => ufwStatus($params), + 'ufw.list_rules' => ufwListRules($params), + 'ufw.enable' => ufwEnable($params), + 'ufw.disable' => ufwDisable($params), + 'ufw.allow_port' => ufwAllowPort($params), + 'ufw.deny_port' => ufwDenyPort($params), + 'ufw.allow_ip' => ufwAllowIp($params), + 'ufw.deny_ip' => ufwDenyIp($params), + 'ufw.delete_rule' => ufwDeleteRule($params), + 'ufw.set_default' => ufwSetDefault($params), + 'ufw.limit_port' => ufwLimitPort($params), + 'ufw.reset' => ufwReset($params), + 'ufw.reload' => ufwReload($params), + 'ufw.allow_service' => ufwAllowService($params), + 'user.password' => setUserPassword($params), + 'user.exists' => userExists($params), + 'ufw.status' => ufwStatus($params), + 'ufw.list_rules' => ufwListRules($params), + 'ufw.enable' => ufwEnable($params), + 'ufw.disable' => ufwDisable($params), + 'ufw.allow_port' => ufwAllowPort($params), + 'ufw.deny_port' => ufwDenyPort($params), + 'ufw.allow_ip' => ufwAllowIp($params), + 'ufw.deny_ip' => ufwDenyIp($params), + 'ufw.delete_rule' => ufwDeleteRule($params), + 'ufw.set_default' => ufwSetDefault($params), + 'ufw.limit_port' => ufwLimitPort($params), + 'ufw.reset' => ufwReset($params), + 'ufw.reload' => ufwReload($params), + 'ufw.allow_service' => ufwAllowService($params), + 'domain.create' => domainCreate($params), + 'domain.alias_add' => domainAliasAdd($params), + 'domain.alias_remove' => domainAliasRemove($params), + 'domain.ensure_error_pages' => domainEnsureErrorPages($params), + 'domain.delete' => domainDelete($params), + 'domain.list' => domainList($params), + 'domain.toggle' => domainToggle($params), + 'domain.set_redirects' => domainSetRedirects($params), + 'domain.set_hotlink_protection' => domainSetHotlinkProtection($params), + 'domain.set_directory_index' => domainSetDirectoryIndex($params), + 'domain.list_protected_dirs' => domainListProtectedDirs($params), + 'domain.add_protected_dir' => domainAddProtectedDir($params), + 'domain.remove_protected_dir' => domainRemoveProtectedDir($params), + 'domain.add_protected_dir_user' => domainAddProtectedDirUser($params), + 'domain.remove_protected_dir_user' => domainRemoveProtectedDirUser($params), + 'php.getSettings' => phpGetSettings($params), + 'php.setSettings' => phpSetSettings($params), + 'php.update_pool_limits' => phpUpdatePoolLimits($params), + 'php.update_all_pool_limits' => phpUpdateAllPoolLimits($params), + 'wp.install' => wpInstall($params), + 'wp.list' => wpList($params), + 'wp.delete' => wpDelete($params), + 'wp.auto_login' => wpAutoLogin($params), + 'wp.update' => wpUpdate($params), + 'wp.scan' => wpScan($params), + 'wp.import' => wpImport($params), + 'wp.cache_enable' => wpCacheEnable($params), + 'wp.cache_disable' => wpCacheDisable($params), + 'wp.cache_flush' => wpCacheFlush($params), + 'wp.cache_status' => wpCacheStatus($params), + 'wp.toggle_debug' => wpToggleDebug($params), + 'wp.toggle_auto_update' => wpToggleAutoUpdate($params), + 'wp.create_staging' => wpCreateStaging($params), + 'wp.push_staging' => wpPushStaging($params), + 'wp.page_cache_enable' => wpPageCacheEnable($params), + 'wp.page_cache_disable' => wpPageCacheDisable($params), + 'wp.page_cache_purge' => wpPageCachePurge($params), + 'wp.page_cache_status' => wpPageCacheStatus($params), + 'ssh.list_keys' => sshListKeys($params), + 'ssh.add_key' => sshAddKey($params), + 'ssh.delete_key' => sshDeleteKey($params), + 'ssh.enable_shell' => sshEnableShell($params), + 'ssh.disable_shell' => sshDisableShell($params), + 'ssh.shell_status' => sshGetShellStatus($params), + 'file.list' => fileList($params), + 'file.read' => fileRead($params), + 'file.write' => fileWrite($params), + 'file.delete' => fileDelete($params), + 'file.mkdir' => fileMkdir($params), + 'file.rename' => fileRename($params), + 'file.move' => fileMove($params), + 'file.copy' => fileCopy($params), + 'file.upload' => fileUpload($params), + 'file.upload_temp' => fileUploadTemp($params), + 'file.download' => fileDownload($params), + 'file.exists' => fileExists($params), + 'file.info' => fileInfo($params), + 'file.extract' => fileExtract($params), + 'file.chmod' => fileChmod($params), + 'file.chown' => fileChown($params), + 'file.trash' => fileTrash($params), + 'file.restore' => fileRestore($params), + 'file.empty_trash' => fileEmptyTrash($params), + 'file.list_trash' => fileListTrash($params), + 'image.optimize' => imageOptimize($params), + 'mysql.list_databases' => mysqlListDatabases($params), + 'mysql.create_database' => mysqlCreateDatabase($params), + 'mysql.delete_database' => mysqlDeleteDatabase($params), + 'mysql.list_users' => mysqlListUsers($params), + 'mysql.create_user' => mysqlCreateUser($params), + 'mysql.delete_user' => mysqlDeleteUser($params), + 'mysql.change_password' => mysqlChangePassword($params), + 'mysql.grant_privileges' => mysqlGrantPrivileges($params), + 'mysql.revoke_privileges' => mysqlRevokePrivileges($params), + 'mysql.get_privileges' => mysqlGetPrivileges($params), + 'mysql.create_master_user' => mysqlCreateMasterUser($params), + 'mysql.import_database' => mysqlImportDatabase($params), + 'mysql.export_database' => mysqlExportDatabase($params), + 'postgres.list_databases' => postgresListDatabases($params), + 'postgres.list_users' => postgresListUsers($params), + 'postgres.create_database' => postgresCreateDatabase($params), + 'postgres.delete_database' => postgresDeleteDatabase($params), + 'postgres.create_user' => postgresCreateUser($params), + 'postgres.delete_user' => postgresDeleteUser($params), + 'postgres.change_password' => postgresChangePassword($params), + 'postgres.grant_privileges' => postgresGrantPrivileges($params), + 'service.restart' => restartService($params), + 'service.reload' => reloadService($params), + 'service.status' => getServiceStatus($params), + 'dns.create_zone' => dnsCreateZone($params), + 'dns.sync_zone' => dnsSyncZone($params), + 'dns.delete_zone' => dnsDeleteZone($params), + 'dns.reload' => dnsReload($params), + 'dns.enable_dnssec' => dnsEnableDnssec($params), + 'dns.disable_dnssec' => dnsDisableDnssec($params), + 'dns.get_dnssec_status' => dnsGetDnssecStatus($params), + 'dns.get_ds_records' => dnsGetDsRecords($params), + 'php.install' => phpInstall($params), + 'php.uninstall' => phpUninstall($params), + 'php.set_default' => phpSetDefaultVersion($params), + 'php.restart_fpm' => phpRestartFpm($params), + 'php.reload_fpm' => phpReloadFpm($params), + 'php.restart_all_fpm' => phpRestartAllFpm($params), + 'php.reload_all_fpm' => phpReloadAllFpm($params), + 'php.list_versions' => phpListVersions($params), + 'php.install_wp_modules' => phpInstallWordPressModules($params), + 'ssh.generate_key' => sshGenerateKey($params), + 'git.generate_key' => gitGenerateKey($params), + 'git.deploy' => gitDeploy($params), + 'rspamd.user_settings' => rspamdUserSettings($params), + 'usage.bandwidth_total' => usageBandwidthTotal($params), + 'usage.user_resources' => usageUserResources($params), + 'server.set_hostname' => setHostname($params), + 'server.set_upload_limits' => setUploadLimits($params), + 'server.update_bind' => updateBindConfig($params), + 'server.info' => getServerInfo($params), + 'server.create_zone' => createServerZone($params), + 'updates.list' => updatesList($params), + 'updates.run' => updatesRun($params), + 'waf.apply' => wafApplySettings($params), + 'waf.audit_log' => wafAuditLogList($params), + 'geo.apply_rules' => geoApplyRules($params), + 'geo.update_database' => geoUpdateDatabase($params), + 'geo.upload_database' => geoUploadDatabase($params), + 'database.persist_tuning' => databasePersistTuning($params), + 'database.get_variables' => databaseGetVariables($params), + 'database.set_global' => databaseSetGlobal($params), + 'server.export_config' => serverExportConfig($params), + 'server.import_config' => serverImportConfig($params), + 'server.get_resolvers' => serverGetResolvers($params), + 'server.set_resolvers' => serverSetResolvers($params), + 'php.install' => phpInstall($params), + 'php.uninstall' => phpUninstall($params), + 'php.set_default' => phpSetDefault($params), + 'php.restart_fpm' => phpRestartFpm($params), + 'php.reload_fpm' => phpReloadFpm($params), + 'php.restart_all_fpm' => phpRestartAllFpm($params), + 'php.reload_all_fpm' => phpReloadAllFpm($params), + 'php.list_versions' => phpListVersions($params), + 'php.install_wp_modules' => phpInstallWordPressModules($params), + 'ssh.generate_key' => sshGenerateKey($params), + 'server.set_hostname' => setHostname($params), + 'server.set_upload_limits' => setUploadLimits($params), + 'server.update_bind' => updateBindConfig($params), + 'server.info' => getServerInfo($params), + 'server.create_zone' => createServerZone($params), + 'nginx.enable_compression' => nginxEnableCompression($params), + 'nginx.get_compression_status' => nginxGetCompressionStatus($params), + // Email operations + 'email.enable_domain' => emailEnableDomain($params), + 'email.disable_domain' => emailDisableDomain($params), + 'email.generate_dkim' => emailGenerateDkim($params), + 'email.domain_info' => emailGetDomainInfo($params), + 'email.mailbox_create' => emailMailboxCreate($params), + 'email.mailbox_delete' => emailMailboxDelete($params), + 'email.mailbox_change_password' => emailMailboxChangePassword($params), + 'email.mailbox_set_quota' => emailMailboxSetQuota($params), + 'email.mailbox_quota_usage' => emailMailboxGetQuotaUsage($params), + 'email.mailbox_toggle' => emailMailboxToggle($params), + 'email.sync_virtual_users' => emailSyncVirtualUsers($params), + 'email.reload_services' => emailReloadServices($params), + 'email.forwarder_create' => emailForwarderCreate($params), + 'email.forwarder_delete' => emailForwarderDelete($params), + 'email.forwarder_update' => emailForwarderUpdate($params), + 'email.forwarder_toggle' => emailForwarderToggle($params), + 'email.catchall_update' => emailCatchallUpdate($params), + 'email.sync_maps' => emailSyncMaps($params), + 'email.get_logs' => emailGetLogs($params), + 'email.autoresponder_set' => emailAutoresponderSet($params), + 'email.autoresponder_toggle' => emailAutoresponderToggle($params), + 'email.autoresponder_delete' => emailAutoresponderDelete($params), + 'email.hash_password' => emailHashPassword($params), + // Mail queue operations + 'mail.queue_list' => mailQueueList($params), + 'mail.queue_retry' => mailQueueRetry($params), + 'mail.queue_delete' => mailQueueDelete($params), + 'service.list' => serviceList($params), + 'service.start' => serviceStart($params), + 'service.stop' => serviceStop($params), + 'service.restart' => serviceRestart($params), + 'service.enable' => serviceEnable($params), + 'service.disable' => serviceDisable($params), + // Server Import operations + 'import.discover' => importDiscover($params), + 'import.start' => importStart($params), + // SSL Certificate operations + 'ssl.check' => sslCheck($params), + 'ssl.issue' => sslIssue($params), + 'ssl.install' => sslInstall($params), + 'ssl.renew' => sslRenew($params), + 'ssl.generate_self_signed' => sslGenerateSelfSigned($params), + 'ssl.delete' => sslDelete($params), + // Backup operations + 'backup.create' => backupCreate($params), + 'backup.create_server' => backupCreateServer($params), + 'backup.incremental_direct' => backupServerIncrementalDirect($params), + 'backup.restore' => backupRestore($params), + 'backup.list' => backupList($params), + 'backup.delete' => backupDelete($params), + 'backup.delete_server' => backupDeleteServer($params), + 'backup.verify' => backupVerify($params), + 'backup.get_info' => backupGetInfo($params), + 'backup.upload_remote' => backupUploadRemote($params), + 'backup.download_remote' => backupDownloadRemote($params), + 'backup.list_remote' => backupListRemote($params), + 'backup.delete_remote' => backupDeleteRemote($params), + 'backup.test_destination' => backupTestDestination($params), + 'backup.download_user_archive' => backupDownloadUserArchive($params), + // cPanel migration operations + 'cpanel.analyze_backup' => cpanelAnalyzeBackup($params), + 'cpanel.restore_backup' => cpanelRestoreBackup($params), + 'cpanel.fix_backup_permissions' => cpanelFixBackupPermissions($params), + // WHM migration operations + 'whm.download_backup_scp' => whmDownloadBackupScp($params), + // Jabali system SSH key operations + 'jabali_ssh.get_public_key' => jabaliSshGetPublicKey($params), + 'jabali_ssh.get_private_key' => jabaliSshGetPrivateKey($params), + 'jabali_ssh.ensure_exists' => jabaliSshEnsureExists($params), + 'jabali_ssh.add_to_authorized_keys' => jabaliSshAddToAuthorizedKeys($params), + // Fail2ban operations + 'fail2ban.status' => fail2banStatus($params), + 'fail2ban.status_light' => fail2banStatusLight($params), + 'fail2ban.install' => fail2banInstall($params), + 'fail2ban.start' => fail2banStart($params), + 'fail2ban.stop' => fail2banStop($params), + 'fail2ban.restart' => fail2banRestart($params), + 'fail2ban.save_settings' => fail2banSaveSettings($params), + 'fail2ban.unban_ip' => fail2banUnbanIp($params), + 'fail2ban.ban_ip' => fail2banBanIp($params), + 'fail2ban.list_jails' => fail2banListJails($params), + 'fail2ban.enable_jail' => fail2banEnableJail($params), + 'fail2ban.disable_jail' => fail2banDisableJail($params), + 'fail2ban.logs' => fail2banLogs($params), + // ClamAV operations + 'clamav.status' => clamavStatus($params), + 'clamav.status_light' => clamavStatusLight($params), + 'clamav.install' => clamavInstall($params), + 'clamav.start' => clamavStart($params), + 'clamav.stop' => clamavStop($params), + 'clamav.update_signatures' => clamavUpdateSignatures($params), + 'clamav.scan' => clamavScan($params), + 'clamav.realtime_start' => clamavRealtimeStart($params), + 'clamav.realtime_stop' => clamavRealtimeStop($params), + 'clamav.realtime_enable' => clamavRealtimeEnable($params), + 'clamav.realtime_disable' => clamavRealtimeDisable($params), + 'clamav.delete_quarantined' => clamavDeleteQuarantined($params), + 'clamav.clear_threats' => clamavClearThreats($params), + 'clamav.set_light_mode' => clamavSetLightMode($params), + 'clamav.set_full_mode' => clamavSetFullMode($params), + 'clamav.force_update_signatures' => clamavForceUpdateSignatures($params), + 'ssh.get_settings' => sshGetSettings($params), + 'ssh.save_settings' => sshSaveSettings($params), + // Cron job operations + 'cron.list' => cronList($params), + 'cron.create' => cronCreate($params), + 'cron.delete' => cronDelete($params), + 'cron.toggle' => cronToggle($params), + 'cron.run' => cronRun($params), + 'cron.wp_setup' => cronWordPressSetup($params), + // Server metrics operations + 'metrics.overview' => metricsOverview($params), + 'metrics.cpu' => metricsCpu($params), + 'metrics.memory' => metricsMemory($params), + 'metrics.disk' => metricsDisk($params), + 'metrics.network' => metricsNetwork($params), + 'metrics.processes' => metricsProcesses($params), + 'system.kill_process' => systemKillProcess($params), + // Disk quota operations + 'quota.status' => quotaStatus($params), + 'quota.enable' => quotaEnable($params), + 'quota.set' => quotaSet($params), + 'quota.get' => quotaGet($params), + 'quota.report' => quotaReport($params), + // IP address management + 'ip.list' => ipList($params), + 'ip.add' => ipAdd($params), + 'ip.remove' => ipRemove($params), + 'ip.info' => ipInfo($params), + // Security scanner tools + 'scanner.install' => scannerInstall($params), + 'scanner.uninstall' => scannerUninstall($params), + 'scanner.status' => scannerStatus($params), + 'scanner.run_lynis' => scannerRunLynis($params), + 'scanner.run_nikto' => scannerRunNikto($params), + 'scanner.start_lynis' => scannerStartLynis($params), + 'scanner.get_scan_status' => scannerGetScanStatus($params), + // Log analysis + 'logs.tail' => logsTail($params), + 'logs.goaccess' => logsGoaccess($params), + // Redis ACL management + 'redis.create_user' => redisCreateUser($params), + 'redis.delete_user' => redisDeleteUser($params), + 'redis.user_exists' => redisUserExists($params), + 'redis.change_password' => redisChangePassword($params), + 'redis.migrate_users' => redisMigrateUsers($params), + default => ['success' => false, 'error' => "Unknown action: $action"], + }; +} + +function wafAuditLogList(array $params): array +{ + $limit = (int) ($params['limit'] ?? 200); + if ($limit <= 0) { + $limit = 200; + } + + $logPath = '/var/log/nginx/modsec_audit.log'; + if (!file_exists($logPath)) { + return ['success' => true, 'entries' => []]; + } + + $lines = []; + exec('tail -n 5000 ' . escapeshellarg($logPath) . ' 2>/dev/null', $lines); + + $entries = []; + $current = [ + 'timestamp' => null, + 'remote_ip' => null, + 'host' => null, + 'uri' => null, + ]; + $contexts = []; + $hits = []; + $blocks = []; + + foreach ($lines as $line) { + if (preg_match('/^---[A-Za-z0-9]+---A--$/', $line)) { + $current = [ + 'timestamp' => null, + 'remote_ip' => null, + 'host' => null, + 'uri' => null, + ]; + continue; + } + + if (preg_match('/^\[(\d{2}\/[A-Za-z]{3}\/\d{4}:\d{2}:\d{2}:\d{2}) ([+-]\d{4})\]\s+\d+\.\d+\s+([0-9a-fA-F:.]+)/', $line, $matches)) { + $date = DateTime::createFromFormat('d/M/Y:H:i:s O', $matches[1] . ' ' . $matches[2]); + if ($date instanceof DateTime) { + $current['timestamp'] = $date->getTimestamp(); + } + $current['remote_ip'] = $matches[3]; + continue; + } + + if (preg_match('/^(GET|POST|PUT|DELETE|HEAD|OPTIONS|PATCH)\s+([^ ]+)\s+HTTP/i', $line, $matches)) { + $current['uri'] = $matches[2]; + continue; + } + + if (preg_match('/^\s*host:\s*(.+)$/i', $line, $matches)) { + $current['host'] = trim($matches[1]); + continue; + } + + if (!str_contains($line, 'ModSecurity:')) { + continue; + } + + $uniqueId = null; + if (preg_match('/\\[unique_id "([^"]+)"\\]/', $line, $matches)) { + $uniqueId = $matches[1]; + } + + if ($uniqueId !== null && !isset($contexts[$uniqueId])) { + $contexts[$uniqueId] = [ + 'timestamp' => $current['timestamp'], + 'remote_ip' => $current['remote_ip'], + 'host' => $current['host'], + 'uri' => $current['uri'], + ]; + } + + $entry = [ + 'timestamp' => $current['timestamp'], + 'remote_ip' => $current['remote_ip'], + 'host' => $current['host'], + 'uri' => $current['uri'], + 'rule_id' => null, + 'message' => null, + 'severity' => null, + 'unique_id' => $uniqueId, + ]; + + if ($uniqueId !== null && isset($contexts[$uniqueId])) { + $entry = array_merge($entry, $contexts[$uniqueId]); + } + + if (preg_match('/\\[id "([0-9]+)"\\]/', $line, $matches)) { + $entry['rule_id'] = $matches[1]; + } + if (preg_match('/\\[msg "([^"]+)"\\]/', $line, $matches)) { + $entry['message'] = $matches[1]; + } + if (preg_match('/\\[severity "([^"]+)"\\]/', $line, $matches)) { + $entry['severity'] = $matches[1]; + } + if (preg_match('/\\[uri "([^"]+)"\\]/', $line, $matches)) { + $loggedUri = $matches[1]; + $currentUri = (string) ($entry['uri'] ?? ''); + if ($currentUri === '' || (!str_contains($currentUri, '?') && $loggedUri !== '')) { + $entry['uri'] = $loggedUri; + } + } + if (preg_match('/\\[hostname "([^"]+)"\\]/', $line, $matches)) { + $loggedHost = $matches[1]; + $currentHost = (string) ($entry['host'] ?? ''); + $remoteIp = (string) ($entry['remote_ip'] ?? ''); + $shouldOverrideHost = $currentHost === '' + || $currentHost === $remoteIp + || $currentHost === '127.0.0.1' + || $currentHost === '::1' + || $currentHost === 'localhost'; + + if ($shouldOverrideHost && $loggedHost !== '') { + $entry['host'] = $loggedHost; + } + } + + if (str_contains($line, 'Access denied')) { + if ($uniqueId !== null) { + $blocks[$uniqueId] = $entry; + } else { + $entries[] = $entry; + } + continue; + } + + if (str_contains($line, 'Warning.')) { + if ($uniqueId !== null) { + $hits[$uniqueId][] = $entry; + } else { + $entries[] = $entry; + } + } + } + + foreach ($blocks as $uniqueId => $blockEntry) { + if (!empty($hits[$uniqueId])) { + foreach ($hits[$uniqueId] as $hitEntry) { + $hitEntry['blocked'] = true; + $entries[] = $hitEntry; + } + continue; + } + + $blockEntry['blocked'] = true; + $entries[] = $blockEntry; + } + + $entries = array_reverse($entries); + if (count($entries) > $limit) { + $entries = array_slice($entries, 0, $limit); + } + + return ['success' => true, 'entries' => $entries]; +} + +// ============ USER MANAGEMENT ============ + +function createUser(array $params): array +{ + $username = $params['username'] ?? ''; + $password = $params['password'] ?? null; + + logger("Creating user: $username"); + + if (!validateUsername($username)) { + return ['success' => false, 'error' => 'Invalid username format']; + } + + if (isProtectedUser($username)) { + return ['success' => false, 'error' => 'Cannot create protected system user']; + } + + exec("id " . escapeshellarg($username) . " 2>/dev/null", $output, $exitCode); + if ($exitCode === 0) { + return ['success' => false, 'error' => 'User already exists']; + } + + $homeDir = "/home/$username"; + + // Create user with nologin shell (SFTP-only by default) + $cmd = sprintf('useradd -m -d %s -s /usr/sbin/nologin %s 2>&1', + escapeshellarg($homeDir), + escapeshellarg($username) + ); + exec($cmd, $output, $exitCode); + + if ($exitCode !== 0) { + return ['success' => false, 'error' => 'Failed to create user: ' . implode("\n", $output)]; + } + + if ($password) { + $cmd = sprintf('echo %s:%s | chpasswd 2>&1', + escapeshellarg($username), + escapeshellarg($password) + ); + exec($cmd); + } + + // Remove symlinks that cause issues + @unlink("$homeDir/.face.icon"); + @unlink("$homeDir/.face"); + + // Create standard directories (NO ACLs for www-data!) + $dirs = ['domains', 'logs', 'tmp', 'ssl', 'backups']; + foreach ($dirs as $dir) { + $path = "$homeDir/$dir"; + if (!is_dir($path)) { + mkdir($path, 0755, true); + } + chown($path, $username); + chgrp($path, $username); + } + + // Set up for secure SFTP chroot + exec("usermod -aG sftpusers " . escapeshellarg($username)); + + // Chroot requires root ownership of home directory + // Use user's group with 750 for complete isolation between users + chown($homeDir, "root"); + chgrp($homeDir, $username); // User's own group - only this user can access + chmod($homeDir, 0750); // root=rwx, user's group=r-x, others=none + + // Create PHP-FPM pool for the user (so it's ready when they create domains) + // Don't reload FPM here - caller is responsible for reloading after all operations complete + $fpmResult = createFpmPool($username, false); + $fpmPoolCreated = (bool) ($fpmResult['pool_created'] ?? false); + $fpmReloadRequired = $fpmPoolCreated && (bool) ($fpmResult['needs_reload'] ?? false); + + // Create Redis ACL user for isolated caching + $redisPassword = bin2hex(random_bytes(16)); // 32 char password + $redisResult = redisCreateUser(['username' => $username, 'password' => $redisPassword]); + + if ($redisResult['success']) { + // Store Redis credentials in user's home directory + $redisCredFile = "{$homeDir}/.redis_credentials"; + $credContent = "REDIS_USER=jabali_{$username}\n" . + "REDIS_PASS={$redisPassword}\n" . + "REDIS_PREFIX={$username}:\n"; + file_put_contents($redisCredFile, $credContent); + chmod($redisCredFile, 0600); + chown($redisCredFile, $username); + chgrp($redisCredFile, $username); + logger("Created Redis ACL user for $username"); + } else { + logger("Warning: Failed to create Redis user for $username: " . ($redisResult['error'] ?? 'Unknown error')); + } + + logger("Created user $username with home directory $homeDir"); + + return [ + 'success' => true, + 'message' => "User $username created successfully", + 'home_directory' => $homeDir, + 'redis_user' => $redisResult['success'] ? "jabali_{$username}" : null, + 'fpm_pool_created' => $fpmPoolCreated, + 'fpm_reload_required' => $fpmReloadRequired, + ]; +} + +function deleteUser(array $params): array +{ + $username = $params['username'] ?? ''; + $removeHome = $params['remove_home'] ?? false; + $domains = $params['domains'] ?? []; // List of user's domains to clean up + $steps = []; + + if (!validateUsername($username)) { + return ['success' => false, 'error' => 'Invalid username format']; + } + + if (isProtectedUser($username)) { + return ['success' => false, 'error' => 'Cannot delete protected system user']; + } + + // Check if user exists + exec("id " . escapeshellarg($username) . " 2>/dev/null", $idOutput, $idExit); + if ($idExit !== 0) { + return ['success' => false, 'error' => 'User does not exist']; + } + + $homeDir = "/home/$username"; + + // Get domains from .domains file if not provided + if (empty($domains)) { + $domainsFile = "$homeDir/.domains"; + if (file_exists($domainsFile)) { + $domainsData = json_decode(file_get_contents($domainsFile), true) ?: []; + $domains = array_keys($domainsData); + } + } + + $domainConfigRemoved = false; + $domainsDirExists = is_dir("$homeDir/domains"); + + // Clean up domain-related files for each domain + foreach ($domains as $domain) { + if (!validateDomain($domain)) { + continue; + } + + $domainTouched = false; + + // Remove nginx vhost configs (with .conf extension) + $nginxAvailable = "/etc/nginx/sites-available/{$domain}.conf"; + $nginxEnabled = "/etc/nginx/sites-enabled/{$domain}.conf"; + // Also try without .conf for backwards compatibility + $nginxAvailableOld = "/etc/nginx/sites-available/$domain"; + $nginxEnabledOld = "/etc/nginx/sites-enabled/$domain"; + + foreach ([$nginxEnabled, $nginxEnabledOld] as $file) { + if (file_exists($file) || is_link($file)) { + @unlink($file); + logger("Removed nginx symlink: $file"); + $domainTouched = true; + } + } + foreach ([$nginxAvailable, $nginxAvailableOld] as $file) { + if (file_exists($file)) { + @unlink($file); + logger("Removed nginx config: $file"); + $domainTouched = true; + } + } + + // Remove DNS zone file + $zoneFile = "/etc/bind/zones/db.$domain"; + if (file_exists($zoneFile)) { + @unlink($zoneFile); + logger("Removed DNS zone: $zoneFile"); + $domainTouched = true; + + // Remove from named.conf.local + $namedConf = '/etc/bind/named.conf.local'; + if (file_exists($namedConf)) { + $content = file_get_contents($namedConf); + // Remove zone block for this domain (use [\s\S]*?\n\} to match nested braces) + $pattern = '/\n?zone\s+"' . preg_quote($domain, '/') . '"\s*\{[\s\S]*?\n\};\n?/'; + $newContent = preg_replace($pattern, "\n", $content); + if ($newContent !== $content) { + file_put_contents($namedConf, $newContent); + logger("Removed zone from named.conf.local: $domain"); + } + } + } + + // Remove mail directories (both in home and /var/vmail) + $mailDir = "$homeDir/mail/$domain"; + if (is_dir($mailDir)) { + exec("rm -rf " . escapeshellarg($mailDir)); + logger("Removed mail directory: $mailDir"); + $domainTouched = true; + } + $vmailDir = "/var/vmail/$domain"; + if (is_dir($vmailDir)) { + exec("rm -rf " . escapeshellarg($vmailDir)); + logger("Removed vmail directory: $vmailDir"); + $domainTouched = true; + } + + // Remove from Postfix virtual_mailbox_domains + $vdomainsFile = POSTFIX_VIRTUAL_DOMAINS; + if (file_exists($vdomainsFile)) { + $content = file_get_contents($vdomainsFile); + $lines = explode("\n", $content); + $lines = array_filter($lines, fn($line) => trim($line) !== $domain); + file_put_contents($vdomainsFile, implode("\n", $lines)); + } + + // Remove mailboxes from Postfix virtual_mailbox_maps + $vmailboxFile = POSTFIX_VIRTUAL_MAILBOXES; + if (file_exists($vmailboxFile)) { + $content = file_get_contents($vmailboxFile); + $lines = explode("\n", $content); + $lines = array_filter($lines, fn($line) => !str_contains($line, "@$domain")); + file_put_contents($vmailboxFile, implode("\n", $lines)); + } + + // Remove from Postfix virtual_alias_maps + $valiasFile = POSTFIX_VIRTUAL_ALIASES; + if (file_exists($valiasFile)) { + $content = file_get_contents($valiasFile); + $lines = explode("\n", $content); + $lines = array_filter($lines, fn($line) => !str_contains($line, "@$domain")); + file_put_contents($valiasFile, implode("\n", $lines)); + } + + // Remove SSL certificates (live, archive, and renewal) + $certPath = "/etc/letsencrypt/live/$domain"; + $certArchive = "/etc/letsencrypt/archive/$domain"; + $certRenewal = "/etc/letsencrypt/renewal/$domain.conf"; + if (is_dir($certPath)) { + exec("rm -rf " . escapeshellarg($certPath)); + logger("Removed SSL certificate: $certPath"); + $domainTouched = true; + } + if (is_dir($certArchive)) { + exec("rm -rf " . escapeshellarg($certArchive)); + logger("Removed SSL archive: $certArchive"); + $domainTouched = true; + } + if (file_exists($certRenewal)) { + @unlink($certRenewal); + logger("Removed SSL renewal config: $certRenewal"); + $domainTouched = true; + } + + if ($domainTouched) { + $domainConfigRemoved = true; + $steps[] = "$domain config files removed"; + } + } + + // Delete MySQL databases and users belonging to this user + $dbPrefix = $username . '_'; + $mysqli = getMysqlConnection(); + if ($mysqli) { + $dbDeletedCount = 0; + $dbUserDeletedCount = 0; + + // Get all databases belonging to this user + $result = $mysqli->query("SHOW DATABASES LIKE '{$mysqli->real_escape_string($dbPrefix)}%'"); + if ($result) { + while ($row = $result->fetch_row()) { + $dbName = $row[0]; + // Double-check it starts with username_ + if (strpos($dbName, $dbPrefix) === 0) { + $mysqli->query("DROP DATABASE IF EXISTS `{$mysqli->real_escape_string($dbName)}`"); + logger("Deleted MySQL database: $dbName"); + $dbDeletedCount++; + } + } + $result->free(); + } + + // Get all MySQL users belonging to this user + $result = $mysqli->query("SELECT User, Host FROM mysql.user WHERE User LIKE '{$mysqli->real_escape_string($dbPrefix)}%'"); + if ($result) { + while ($row = $result->fetch_assoc()) { + $dbUser = $row['User']; + $dbHost = $row['Host']; + // Double-check it starts with username_ + if (strpos($dbUser, $dbPrefix) === 0) { + $mysqli->query("DROP USER IF EXISTS '{$mysqli->real_escape_string($dbUser)}'@'{$mysqli->real_escape_string($dbHost)}'"); + logger("Deleted MySQL user: $dbUser@$dbHost"); + $dbUserDeletedCount++; + } + } + $result->free(); + } + $mysqli->query("FLUSH PRIVILEGES"); + $mysqli->close(); + + if ($dbDeletedCount > 0) { + $steps[] = "MySQL databases removed ({$dbDeletedCount})"; + } + if ($dbUserDeletedCount > 0) { + $steps[] = "MySQL users removed ({$dbUserDeletedCount})"; + } + } + + // Remove PHP-FPM pool config + $fpmRemovedCount = 0; + foreach (glob("/etc/php/*/fpm/pool.d/$username.conf") as $poolConf) { + if (@unlink($poolConf)) { + logger("Removed PHP-FPM pool: $poolConf"); + $fpmRemovedCount++; + } + } + if ($fpmRemovedCount > 0) { + $steps[] = 'PHP-FPM pool removed'; + $domainConfigRemoved = true; + } + + // Delete user with --force to ignore warnings about mail spool + // Don't use -r since home directory is owned by root for chroot + $cmd = sprintf('userdel --force %s 2>&1', escapeshellarg($username)); + $userdelOutput = []; + exec($cmd, $userdelOutput, $userdelExit); + + // Verify user was actually deleted (userdel may return non-zero for warnings) + exec("id " . escapeshellarg($username) . " 2>/dev/null", $checkOutput, $checkExit); + if ($checkExit === 0) { + // User still exists - deletion actually failed + return ['success' => false, 'error' => 'Failed to delete user: ' . implode("\n", $userdelOutput)]; + } + + $steps[] = 'User removed from SSH'; + $steps[] = 'Unix user removed from the server'; + + // Delete Redis ACL user (and all their cached keys) + $redisResult = redisDeleteUser(['username' => $username]); + if (!$redisResult['success']) { + logger("Warning: Failed to delete Redis user for $username: " . ($redisResult['error'] ?? 'Unknown error')); + } else { + logger("Deleted Redis ACL user for $username"); + $steps[] = 'Redis ACL user removed'; + } + + // Manually remove home directory if requested (since it's owned by root) + if ($removeHome && is_dir($homeDir)) { + exec(sprintf('rm -rf %s 2>&1', escapeshellarg($homeDir)), $rmOutput, $rmExit); + if ($rmExit !== 0) { + logger("Warning: Failed to remove home directory for $username"); + } else { + $steps[] = "User's data directory removed"; + if ($domainsDirExists) { + $steps[] = "User's domains directory removed"; + } + } + } + + // Reload services + exec('postmap ' . escapeshellarg(POSTFIX_VIRTUAL_DOMAINS) . ' 2>/dev/null'); + exec('postmap ' . escapeshellarg(POSTFIX_VIRTUAL_MAILBOXES) . ' 2>/dev/null'); + exec('postmap ' . escapeshellarg(POSTFIX_VIRTUAL_ALIASES) . ' 2>/dev/null'); + exec('systemctl reload nginx 2>/dev/null'); + exec('rndc reload 2>/dev/null'); + exec('systemctl reload php*-fpm 2>/dev/null'); + + if ($domainConfigRemoved) { + $steps[] = "User's config files deleted"; + } + + logger("Deleted user $username" . ($removeHome ? " with home directory" : "") . " and cleaned up " . count($domains) . " domain(s)"); + + return ['success' => true, 'message' => "User $username deleted successfully", 'steps' => $steps]; +} + +function setUserPassword(array $params): array +{ + $username = $params['username'] ?? ''; + $password = $params['password'] ?? ''; + + if (!validateUsername($username) || empty($password)) { + return ['success' => false, 'error' => 'Invalid username or password']; + } + + exec("id " . escapeshellarg($username) . " 2>/dev/null", $output, $exitCode); + if ($exitCode !== 0) { + return ['success' => false, 'error' => 'User does not exist']; + } + + $cmd = sprintf('echo %s:%s | chpasswd 2>&1', escapeshellarg($username), escapeshellarg($password)); + exec($cmd, $output, $exitCode); + + return $exitCode === 0 + ? ['success' => true, 'message' => 'Password updated'] + : ['success' => false, 'error' => 'Failed to set password']; +} + +function userExists(array $params): array +{ + $username = $params['username'] ?? ''; + + if (!validateUsername($username)) { + return ['success' => true, 'exists' => false]; + } + + exec("id " . escapeshellarg($username) . " 2>/dev/null", $output, $exitCode); + + return ['success' => true, 'exists' => $exitCode === 0]; +} + + +// ============ PHP-FPM POOL MANAGEMENT ============ + +function getFpmSocketPath(string $username): string +{ + $phpVersion = '8.4'; + return "/run/php/php{$phpVersion}-fpm-{$username}.sock"; +} + +function generateNginxVhost(string $domain, string $publicHtml, string $logs, string $fpmSocket): string +{ + $config = <<<'NGINXCONF' +server { + listen 80; + listen [::]:80; + server_name DOMAIN_PLACEHOLDER www.DOMAIN_PLACEHOLDER; + root DOCROOT_PLACEHOLDER; + + include /etc/nginx/jabali/includes/waf.conf; + include /etc/nginx/jabali/includes/geo.conf; + + # Allow ACME challenge for SSL certificate issuance/renewal + location /.well-known/acme-challenge/ { + try_files $uri =404; + } + + # Redirect all other HTTP traffic to HTTPS + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name DOMAIN_PLACEHOLDER www.DOMAIN_PLACEHOLDER; + root DOCROOT_PLACEHOLDER; + + include /etc/nginx/jabali/includes/waf.conf; + include /etc/nginx/jabali/includes/geo.conf; + + # Symlink protection - prevent following symlinks outside document root + disable_symlinks if_not_owner from=$document_root; + + ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem; + ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key; + + index index.php index.html; + client_max_body_size 50M; + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location ~ \.php$ { + fastcgi_pass unix:SOCKET_PLACEHOLDER; + fastcgi_next_upstream error timeout invalid_header http_500 http_503; + fastcgi_next_upstream_tries 2; + fastcgi_next_upstream_timeout 5s; + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + include fastcgi_params; + } + + # GoAccess statistics reports + location /stats/ { + alias STATS_PLACEHOLDER/; + index report.html; + } + + location ~ /\.(?!well-known).* { + deny all; + } + + access_log LOGS_PLACEHOLDER/access.log combined; + error_log LOGS_PLACEHOLDER/error.log; +} +NGINXCONF; + + // Stats directory lives under the document root for web access + $stats = rtrim($publicHtml, '/') . '/stats'; + + $config = str_replace('DOMAIN_PLACEHOLDER', $domain, $config); + $config = str_replace('DOCROOT_PLACEHOLDER', $publicHtml, $config); + $config = str_replace('SOCKET_PLACEHOLDER', $fpmSocket, $config); + $config = str_replace('LOGS_PLACEHOLDER', $logs, $config); + $config = str_replace('STATS_PLACEHOLDER', $stats, $config); + + return $config; +} +function createFpmPool(string $username, bool $reload = true): array +{ + $phpVersion = '8.4'; + $poolFile = "/etc/php/{$phpVersion}/fpm/pool.d/{$username}.conf"; + + // Check if pool already exists + if (file_exists($poolFile)) { + return [ + 'success' => true, + 'message' => 'Pool already exists', + 'socket' => getFpmSocketPath($username), + 'pool_created' => false, + ]; + } + + $userInfo = posix_getpwnam($username); + if (!$userInfo) { + return ['success' => false, 'error' => 'User not found']; + } + + $userHome = $userInfo['dir']; + + // Create required directories + $dirs = ["{$userHome}/tmp", "{$userHome}/logs"]; + foreach ($dirs as $dir) { + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + chown($dir, $username); + chgrp($dir, $username); + } + } + + // Default PHP settings - can be overridden via admin settings + $memoryLimit = '512M'; + $uploadMaxFilesize = '64M'; + $postMaxSize = '64M'; + $maxExecutionTime = '300'; + $maxInputTime = '300'; + $maxInputVars = '3000'; + + // Resource limits - configurable via admin settings + $pmMaxChildren = (int)($params['pm_max_children'] ?? 5); + $pmStartServers = max(1, (int)($pmMaxChildren / 5)); + $pmMinSpareServers = max(1, (int)($pmMaxChildren / 5)); + $pmMaxSpareServers = max(2, (int)($pmMaxChildren / 2)); + $pmMaxRequests = (int)($params['pm_max_requests'] ?? 200); + $rlimitFiles = (int)($params['rlimit_files'] ?? 1024); + $processPriority = (int)($params['process_priority'] ?? 0); + $requestTerminateTimeout = (int)($params['request_terminate_timeout'] ?? 300); + + $poolConfig = "[{$username}] +user = {$username} +group = {$username} + +listen = /run/php/php{$phpVersion}-fpm-{$username}.sock +listen.owner = {$username} +listen.group = www-data +listen.mode = 0660 + +; Process manager settings +pm = dynamic +pm.max_children = {$pmMaxChildren} +pm.start_servers = {$pmStartServers} +pm.min_spare_servers = {$pmMinSpareServers} +pm.max_spare_servers = {$pmMaxSpareServers} +pm.max_requests = {$pmMaxRequests} + +; Resource limits +rlimit_files = {$rlimitFiles} +process.priority = {$processPriority} +request_terminate_timeout = {$requestTerminateTimeout}s +; slowlog disabled by default to avoid startup failures when logs dir missing +; request_slowlog_timeout = 30s +; slowlog = {$userHome}/logs/php-slow.log + +chdir = / + +; PHP Settings (defaults) +php_admin_value[memory_limit] = {$memoryLimit} +php_admin_value[upload_max_filesize] = {$uploadMaxFilesize} +php_admin_value[post_max_size] = {$postMaxSize} +php_admin_value[max_execution_time] = {$maxExecutionTime} +php_admin_value[max_input_time] = {$maxInputTime} +php_admin_value[max_input_vars] = {$maxInputVars} + +; Security +php_admin_value[open_basedir] = {$userHome}/:/tmp/:/usr/share/php/ +php_admin_value[upload_tmp_dir] = {$userHome}/tmp +php_admin_value[session.save_path] = {$userHome}/tmp +php_admin_value[sys_temp_dir] = {$userHome}/tmp +php_admin_value[disable_functions] = symlink,link,exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec + +; Logging +php_admin_flag[log_errors] = on +php_admin_value[error_log] = {$userHome}/logs/php-error.log + +security.limit_extensions = .php +"; + if (file_put_contents($poolFile, $poolConfig) === false) { + return [ + 'success' => false, + 'error' => 'Failed to create pool configuration', + 'pool_created' => false, + ]; + } + + // Reload PHP-FPM if requested (default behavior for normal operations) + // Pass reload=false during migrations to avoid unnecessary reloads during batches + if ($reload) { + exec("systemctl reload php{$phpVersion}-fpm 2>&1", $output, $code); + if ($code !== 0) { + logger("Warning: PHP-FPM reload failed: " . implode("\n", $output)); + } + } + + return [ + 'success' => true, + 'socket' => getFpmSocketPath($username), + 'needs_reload' => !$reload, + 'pool_created' => true, + ]; +} + +function deleteFpmPool(string $username): array +{ + $phpVersion = '8.4'; + $poolFile = "/etc/php/{$phpVersion}/fpm/pool.d/{$username}.conf"; + + if (file_exists($poolFile)) { + unlink($poolFile); + exec("(sleep 1 && systemctl reload php{$phpVersion}-fpm) > /dev/null 2>&1 &"); + } + + return ['success' => true]; +} + +/** + * Update FPM pool limits for a specific user + */ +function phpUpdatePoolLimits(array $params): array +{ + $username = $params['username'] ?? ''; + $phpVersion = '8.4'; + + if (!validateUsername($username)) { + return ['success' => false, 'error' => 'Invalid username']; + } + + $poolFile = "/etc/php/{$phpVersion}/fpm/pool.d/{$username}.conf"; + if (!file_exists($poolFile)) { + return ['success' => false, 'error' => 'Pool configuration not found']; + } + + $userHome = "/home/{$username}"; + + // Ensure logs directory exists (for error logs) + $logsDir = "{$userHome}/logs"; + if (!is_dir($logsDir)) { + mkdir($logsDir, 0755, true); + chown($logsDir, $username); + chgrp($logsDir, $username); + } + + // Get limits from params with defaults + $pmMaxChildren = (int)($params['pm_max_children'] ?? 5); + $pmStartServers = max(1, (int)($pmMaxChildren / 5)); + $pmMinSpareServers = max(1, (int)($pmMaxChildren / 5)); + $pmMaxSpareServers = max(2, (int)($pmMaxChildren / 2)); + $pmMaxRequests = (int)($params['pm_max_requests'] ?? 200); + $rlimitFiles = (int)($params['rlimit_files'] ?? 1024); + $processPriority = (int)($params['process_priority'] ?? 0); + $requestTerminateTimeout = (int)($params['request_terminate_timeout'] ?? 300); + + // PHP settings + $memoryLimit = $params['memory_limit'] ?? '512M'; + $uploadMaxFilesize = $params['upload_max_filesize'] ?? '64M'; + $postMaxSize = $params['post_max_size'] ?? '64M'; + $maxExecutionTime = $params['max_execution_time'] ?? '300'; + $maxInputTime = $params['max_input_time'] ?? '300'; + $maxInputVars = $params['max_input_vars'] ?? '3000'; + + $poolConfig = "[{$username}] +user = {$username} +group = {$username} + +listen = /run/php/php{$phpVersion}-fpm-{$username}.sock +listen.owner = {$username} +listen.group = www-data +listen.mode = 0660 + +; Process manager settings +pm = dynamic +pm.max_children = {$pmMaxChildren} +pm.start_servers = {$pmStartServers} +pm.min_spare_servers = {$pmMinSpareServers} +pm.max_spare_servers = {$pmMaxSpareServers} +pm.max_requests = {$pmMaxRequests} + +; Resource limits +rlimit_files = {$rlimitFiles} +process.priority = {$processPriority} +request_terminate_timeout = {$requestTerminateTimeout}s +; slowlog disabled by default to avoid startup failures when logs dir missing +; request_slowlog_timeout = 30s +; slowlog = {$userHome}/logs/php-slow.log + +chdir = / + +; PHP Settings +php_admin_value[memory_limit] = {$memoryLimit} +php_admin_value[upload_max_filesize] = {$uploadMaxFilesize} +php_admin_value[post_max_size] = {$postMaxSize} +php_admin_value[max_execution_time] = {$maxExecutionTime} +php_admin_value[max_input_time] = {$maxInputTime} +php_admin_value[max_input_vars] = {$maxInputVars} + +; Security +php_admin_value[open_basedir] = {$userHome}/:/tmp/:/usr/share/php/ +php_admin_value[upload_tmp_dir] = {$userHome}/tmp +php_admin_value[session.save_path] = {$userHome}/tmp +php_admin_value[sys_temp_dir] = {$userHome}/tmp +php_admin_value[disable_functions] = symlink,link,exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec + +; Logging +php_admin_flag[log_errors] = on +php_admin_value[error_log] = {$userHome}/logs/php-error.log + +security.limit_extensions = .php +"; + + if (file_put_contents($poolFile, $poolConfig) === false) { + return ['success' => false, 'error' => 'Failed to update pool configuration']; + } + + return ['success' => true]; +} + +/** + * Update FPM pool limits for all users + */ +function phpUpdateAllPoolLimits(array $params): array +{ + $phpVersion = '8.4'; + $poolDir = "/etc/php/{$phpVersion}/fpm/pool.d"; + + $pools = glob("{$poolDir}/*.conf"); + $updated = []; + $errors = []; + + foreach ($pools as $poolFile) { + $username = basename($poolFile, '.conf'); + + // Skip www.conf (default pool) + if ($username === 'www') { + continue; + } + + // Verify user exists + exec("id " . escapeshellarg($username) . " 2>/dev/null", $output, $exitCode); + if ($exitCode !== 0) { + continue; + } + + $result = phpUpdatePoolLimits(array_merge($params, ['username' => $username])); + if ($result['success']) { + $updated[] = $username; + } else { + $errors[$username] = $result['error']; + } + } + + // Reload PHP-FPM after all updates + exec("(sleep 2 && systemctl reload php{$phpVersion}-fpm) > /dev/null 2>&1 &"); + + return [ + 'success' => true, + 'updated' => $updated, + 'errors' => $errors, + ]; +} + + +// ============ DOMAIN MANAGEMENT ============ + +function createDomain(array $params): array +{ + $username = $params['username'] ?? ''; + $domain = $params['domain'] ?? ''; + + if (!validateUsername($username) || !validateDomain($domain)) { + return ['success' => false, 'error' => 'Invalid username or domain format']; + } + + exec("id " . escapeshellarg($username) . " 2>/dev/null", $output, $exitCode); + if ($exitCode !== 0) { + return ['success' => false, 'error' => 'User does not exist']; + } + + $homeDir = "/home/$username"; + $domainDir = "$homeDir/domains/$domain"; + $publicDir = "$domainDir/public_html"; + + if (is_dir($domainDir)) { + return ['success' => false, 'error' => 'Domain directory already exists']; + } + + if (!mkdir($publicDir, 0755, true)) { + return ['success' => false, 'error' => 'Failed to create domain directory']; + } + + // Set ownership to user (NO www-data access) + exec(sprintf('chown -R %s:%s %s', escapeshellarg($username), escapeshellarg($username), escapeshellarg($domainDir))); + + // Create default index.html + $indexContent = "\n\nWelcome to $domain\n

Welcome to $domain

\n"; + file_put_contents("$publicDir/index.html", $indexContent); + chown("$publicDir/index.html", $username); + chgrp("$publicDir/index.html", $username); + + logger("Created domain $domain for user $username"); + + return [ + 'success' => true, + 'message' => "Domain $domain created", + 'domain_path' => $domainDir, + 'public_path' => $publicDir, + ]; +} + +function deleteDomain(array $params): array +{ + $username = $params['username'] ?? ''; + $domain = $params['domain'] ?? ''; + + if (!validateUsername($username) || !validateDomain($domain)) { + return ['success' => false, 'error' => 'Invalid username or domain format']; + } + + $domainDir = "/home/$username/domains/$domain"; + + if (!is_dir($domainDir)) { + return ['success' => false, 'error' => 'Domain directory does not exist']; + } + + exec(sprintf('rm -rf %s 2>&1', escapeshellarg($domainDir)), $output, $exitCode); + + if ($exitCode !== 0) { + return ['success' => false, 'error' => 'Failed to delete domain']; + } + + logger("Deleted domain $domain for user $username"); + + return ['success' => true, 'message' => "Domain $domain deleted"]; +} + +// ============ FILE OPERATIONS ============ + +function fileList(array $params): array +{ + $username = $params['username'] ?? ''; + $path = $params['path'] ?? ''; + $showHidden = (bool) ($params['show_hidden'] ?? false); + + if (!validateUsername($username)) { + return ['success' => false, 'error' => 'Invalid username']; + } + + $fullPath = validateUserPath($username, $path); + if ($fullPath === null) { + return ['success' => false, 'error' => 'Invalid path']; + } + + if (!is_dir($fullPath)) { + return ['success' => false, 'error' => 'Directory not found']; + } + + $items = []; + $entries = @scandir($fullPath); + + if ($entries === false) { + return ['success' => false, 'error' => 'Cannot read directory']; + } + + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') continue; + // Hide hidden files unless show_hidden is true (always hide .trash) + if (str_starts_with($entry, '.') && (!$showHidden || $entry === '.trash')) continue; + + $itemPath = "$fullPath/$entry"; + $stat = @stat($itemPath); + + if ($stat === false) continue; + + // Skip symlinks + if (is_link($itemPath)) continue; + + $items[] = [ + 'name' => $entry, + 'path' => ltrim(str_replace("/home/$username", '', $itemPath), '/'), + 'is_dir' => is_dir($itemPath), + 'size' => is_file($itemPath) ? $stat['size'] : null, + 'modified' => $stat['mtime'], + 'permissions' => substr(sprintf('%o', $stat['mode']), -4), + ]; + } + + // Sort: directories first, then alphabetically + usort($items, function ($a, $b) { + if ($a['is_dir'] !== $b['is_dir']) { + return $b['is_dir'] <=> $a['is_dir']; + } + return strcasecmp($a['name'], $b['name']); + }); + + return ['success' => true, 'items' => $items, 'path' => $path]; +} + +function fileRead(array $params): array +{ + $username = $params['username'] ?? ''; + $path = $params['path'] ?? ''; + + if (!validateUsername($username)) { + return ['success' => false, 'error' => 'Invalid username']; + } + + $fullPath = validateUserPath($username, $path); + if ($fullPath === null || !is_file($fullPath)) { + return ['success' => false, 'error' => 'File not found']; + } + + // Limit file size for reading + $maxSize = 50 * 1024 * 1024; // 5MB + if (filesize($fullPath) > $maxSize) { + return ['success' => false, 'error' => 'File too large to read']; + } + + $content = @file_get_contents($fullPath); + if ($content === false) { + return ['success' => false, 'error' => 'Cannot read file']; + } + + return [ + 'success' => true, + 'content' => base64_encode($content), + 'encoding' => 'base64', + 'size' => strlen($content), + ]; +} + +function fileWrite(array $params): array +{ + $username = $params['username'] ?? ''; + $path = $params['path'] ?? ''; + $content = $params['content'] ?? ''; + $encoding = $params['encoding'] ?? 'plain'; + + if (!validateUsername($username)) { + return ['success' => false, 'error' => 'Invalid username']; + } + + $fullPath = validateUserPath($username, $path); + if ($fullPath === null) { + return ['success' => false, 'error' => 'Invalid path']; + } + + // Decode content if base64 + if ($encoding === 'base64') { + $content = base64_decode($content, true); + if ($content === false) { + return ['success' => false, 'error' => 'Invalid base64 content']; + } + } + + // Ensure parent directory exists + $parentDir = dirname($fullPath); + if (!is_dir($parentDir)) { + return ['success' => false, 'error' => 'Parent directory does not exist']; + } + + if (@file_put_contents($fullPath, $content) === false) { + return ['success' => false, 'error' => 'Cannot write file']; + } + + chown($fullPath, $username); + chgrp($fullPath, $username); + chmod($fullPath, 0644); + + logger("File written: $fullPath for user $username"); + + return ['success' => true, 'message' => 'File saved', 'size' => strlen($content)]; +} + +function fileDelete(array $params): array +{ + $username = $params['username'] ?? ''; + $path = $params['path'] ?? ''; + + if (!validateUsername($username)) { + return ['success' => false, 'error' => 'Invalid username']; + } + + $fullPath = validateUserPath($username, $path); + if ($fullPath === null) { + return ['success' => false, 'error' => 'Invalid path']; + } + + // Don't allow deleting the home directory itself or critical folders + $homeDir = "/home/$username"; + $protected = [$homeDir, "$homeDir/domains", "$homeDir/logs", "$homeDir/ssl", "$homeDir/backups", "$homeDir/tmp"]; + if (in_array($fullPath, $protected)) { + return ['success' => false, 'error' => 'Cannot delete protected directory']; + } + + if (is_dir($fullPath)) { + exec(sprintf('rm -rf %s 2>&1', escapeshellarg($fullPath)), $output, $exitCode); + } else { + $exitCode = @unlink($fullPath) ? 0 : 1; + } + + if ($exitCode !== 0) { + return ['success' => false, 'error' => 'Cannot delete']; + } + + logger("Deleted: $fullPath for user $username"); + + return ['success' => true, 'message' => 'Deleted successfully']; +} + +function fileMkdir(array $params): array +{ + $username = $params['username'] ?? ''; + $path = $params['path'] ?? ''; + + if (!validateUsername($username)) { + return ['success' => false, 'error' => 'Invalid username']; + } + + $fullPath = validateUserPath($username, $path); + if ($fullPath === null) { + return ['success' => false, 'error' => 'Invalid path']; + } + + if (file_exists($fullPath)) { + return ['success' => false, 'error' => 'Path already exists']; + } + + if (!@mkdir($fullPath, 0755, true)) { + return ['success' => false, 'error' => 'Cannot create directory']; + } + + chown($fullPath, $username); + chgrp($fullPath, $username); + + logger("Directory created: $fullPath for user $username"); + + return ['success' => true, 'message' => 'Directory created']; +} + +function fileRename(array $params): array +{ + $username = $params['username'] ?? ''; + $path = $params['path'] ?? ''; + $newName = $params['new_name'] ?? ''; + + if (!validateUsername($username) || empty($newName)) { + return ['success' => false, 'error' => 'Invalid parameters']; + } + + // Validate new name doesn't contain path separators + if (str_contains($newName, '/') || str_contains($newName, '..')) { + return ['success' => false, 'error' => 'Invalid new name']; + } + + $fullPath = validateUserPath($username, $path); + if ($fullPath === null || !file_exists($fullPath)) { + return ['success' => false, 'error' => 'File not found']; + } + + $newPath = dirname($fullPath) . '/' . $newName; + + if (file_exists($newPath)) { + return ['success' => false, 'error' => 'Target already exists']; + } + + if (!@rename($fullPath, $newPath)) { + return ['success' => false, 'error' => 'Cannot rename']; + } + + logger("Renamed: $fullPath to $newPath for user $username"); + + return ['success' => true, 'message' => 'Renamed successfully']; +} + +function fileMove(array $params): array +{ + $username = $params['username'] ?? ''; + $path = $params['path'] ?? ''; + $destination = $params['destination'] ?? ''; + + if (!validateUsername($username)) { + return ['success' => false, 'error' => 'Invalid username']; + } + + $fullPath = validateUserPath($username, $path); + $destPath = validateUserPath($username, $destination); + + if ($fullPath === null || $destPath === null) { + return ['success' => false, 'error' => 'Invalid path']; + } + + if (!file_exists($fullPath)) { + return ['success' => false, 'error' => 'Source not found']; + } + + // If destination is a directory, move into it + if (is_dir($destPath)) { + $destPath = $destPath . '/' . basename($fullPath); + } + + if (file_exists($destPath)) { + return ['success' => false, 'error' => 'Destination already exists']; + } + + if (!@rename($fullPath, $destPath)) { + return ['success' => false, 'error' => 'Cannot move']; + } + + logger("Moved: $fullPath to $destPath for user $username"); + + return ['success' => true, 'message' => 'Moved successfully']; +} + +function fileCopy(array $params): array +{ + $username = $params['username'] ?? ''; + $path = $params['path'] ?? ''; + $destination = $params['destination'] ?? ''; + + if (!validateUsername($username)) { + return ['success' => false, 'error' => 'Invalid username']; + } + + $fullPath = validateUserPath($username, $path); + $destPath = validateUserPath($username, $destination); + + if ($fullPath === null || $destPath === null) { + return ['success' => false, 'error' => 'Invalid path']; + } + + if (!file_exists($fullPath)) { + return ['success' => false, 'error' => 'Source not found']; + } + + if (is_dir($destPath)) { + $destPath = $destPath . '/' . basename($fullPath); + } + + if (file_exists($destPath)) { + return ['success' => false, 'error' => 'Destination already exists']; + } + + if (is_dir($fullPath)) { + exec(sprintf('cp -r %s %s 2>&1', escapeshellarg($fullPath), escapeshellarg($destPath)), $output, $exitCode); + if ($exitCode !== 0) { + return ['success' => false, 'error' => 'Cannot copy directory']; + } + exec(sprintf('chown -R %s:%s %s', escapeshellarg($username), escapeshellarg($username), escapeshellarg($destPath))); + } else { + if (!@copy($fullPath, $destPath)) { + return ['success' => false, 'error' => 'Cannot copy file']; + } + chown($destPath, $username); + chgrp($destPath, $username); + } + + logger("Copied: $fullPath to $destPath for user $username"); + + return ['success' => true, 'message' => 'Copied successfully']; +} + +function fileUpload(array $params): array +{ + $username = $params['username'] ?? ''; + $path = $params['path'] ?? ''; + $filename = $params['filename'] ?? ''; + $content = $params['content'] ?? ''; + + if (!validateUsername($username) || empty($filename)) { + return ['success' => false, 'error' => 'Invalid parameters']; + } + + // Sanitize filename + $filename = basename($filename); + $filename = preg_replace('/[^a-zA-Z0-9._-]/', '_', $filename); + + if (empty($filename)) { + return ['success' => false, 'error' => 'Invalid filename']; + } + + $dirPath = validateUserPath($username, $path); + if ($dirPath === null || !is_dir($dirPath)) { + return ['success' => false, 'error' => 'Invalid directory']; + } + + $fullPath = "$dirPath/$filename"; + + // Decode base64 content + $decoded = base64_decode($content, true); + if ($decoded === false) { + return ['success' => false, 'error' => 'Invalid file content']; + } + + // Limit upload size (50MB) + if (strlen($decoded) > 50 * 1024 * 1024) { + return ['success' => false, 'error' => 'File too large']; + } + + if (@file_put_contents($fullPath, $decoded) === false) { + return ['success' => false, 'error' => 'Cannot save file']; + } + + chown($fullPath, $username); + chgrp($fullPath, $username); + chmod($fullPath, 0644); + + logger("Uploaded: $fullPath for user $username (" . strlen($decoded) . " bytes)"); + + return ['success' => true, 'message' => 'File uploaded', 'path' => ltrim(str_replace("/home/$username", '', $fullPath), '/')]; +} + +/** + * Upload large files by moving from temp location. + * This avoids JSON encoding issues with large binary content. + */ +function fileUploadTemp(array $params): array +{ + $username = $params['username'] ?? ''; + $path = $params['path'] ?? ''; + $filename = $params['filename'] ?? ''; + $tempPath = $params['temp_path'] ?? ''; + + if (!validateUsername($username) || empty($filename) || empty($tempPath)) { + return ['success' => false, 'error' => 'Invalid parameters']; + } + + // Validate temp path is in allowed temp directory + $allowedTempDir = '/tmp/jabali-uploads/'; + $realTempPath = realpath($tempPath); + $allowedPrefix = rtrim($allowedTempDir, '/') . '/'; + if ($realTempPath === false || !str_starts_with($realTempPath, $allowedPrefix)) { + logger("Invalid temp path: $tempPath (real: $realTempPath)", 'ERROR'); + return ['success' => false, 'error' => 'Invalid temp file path']; + } + + if (!file_exists($realTempPath)) { + return ['success' => false, 'error' => 'Temp file not found']; + } + + // Sanitize filename + $filename = basename($filename); + $filename = preg_replace('/[^a-zA-Z0-9._-]/', '_', $filename); + + if (empty($filename)) { + @unlink($realTempPath); + return ['success' => false, 'error' => 'Invalid filename']; + } + + $dirPath = validateUserPath($username, $path); + if ($dirPath === null || !is_dir($dirPath)) { + @unlink($realTempPath); + return ['success' => false, 'error' => 'Invalid directory']; + } + + $fullPath = "$dirPath/$filename"; + + // Get file size for logging + $fileSize = filesize($realTempPath); + + // Limit upload size (500MB for temp uploads) + if ($fileSize > 500 * 1024 * 1024) { + @unlink($realTempPath); + return ['success' => false, 'error' => 'File too large (max 500MB)']; + } + + // Move temp file to destination + if (!@rename($realTempPath, $fullPath)) { + // If rename fails (cross-device), try copy+delete + if (!@copy($realTempPath, $fullPath)) { + @unlink($realTempPath); + return ['success' => false, 'error' => 'Cannot move file to destination']; + } + @unlink($realTempPath); + } + + chown($fullPath, $username); + chgrp($fullPath, $username); + chmod($fullPath, 0644); + + logger("Uploaded (temp): $fullPath for user $username ($fileSize bytes)"); + + return ['success' => true, 'message' => 'File uploaded', 'path' => ltrim(str_replace("/home/$username", '', $fullPath), '/')]; +} + +function fileDownload(array $params): array +{ + return fileRead($params); // Same as read, just different context +} + +function fileExists(array $params): array +{ + $username = $params['username'] ?? ''; + $path = $params['path'] ?? ''; + + if (!validateUsername($username)) { + return ['success' => true, 'exists' => false]; + } + + $fullPath = validateUserPath($username, $path); + + return ['success' => true, 'exists' => $fullPath !== null && file_exists($fullPath)]; +} + +function fileInfo(array $params): array +{ + $username = $params['username'] ?? ''; + $path = $params['path'] ?? ''; + + if (!validateUsername($username)) { + return ['success' => false, 'error' => 'Invalid username']; + } + + $fullPath = validateUserPath($username, $path); + if ($fullPath === null || !file_exists($fullPath)) { + return ['success' => false, 'error' => 'File not found']; + } + + $stat = stat($fullPath); + + return [ + 'success' => true, + 'info' => [ + 'name' => basename($fullPath), + 'path' => $path, + 'is_dir' => is_dir($fullPath), + 'is_file' => is_file($fullPath), + 'size' => $stat['size'], + 'modified' => $stat['mtime'], + 'permissions' => substr(sprintf('%o', $stat['mode']), -4), + 'owner' => posix_getpwuid($stat['uid'])['name'] ?? $stat['uid'], + 'group' => posix_getgrgid($stat['gid'])['name'] ?? $stat['gid'], + ], + ]; +} + +function fileExtract(array $params): array +{ + $username = $params['username'] ?? ''; + $path = $params['path'] ?? ''; + + if (!validateUsername($username) || isProtectedUser($username)) { + return ['success' => false, 'error' => 'Invalid username']; + } + + $fullPath = validateUserPath($username, $path); + if ($fullPath === null) { + return ['success' => false, 'error' => 'Invalid path']; + } + + if (!file_exists($fullPath)) { + return ['success' => false, 'error' => 'File not found']; + } + + $ext = strtolower(pathinfo($fullPath, PATHINFO_EXTENSION)); + $filename = basename($fullPath); + $destDir = dirname($fullPath); + + $output = []; + $returnCode = 0; + + // Handle .tar.gz, .tar.bz2, .tar.xz + if (preg_match('/\.tar\.(gz|bz2|xz)$/i', $filename)) { + $flag = match(strtolower(pathinfo($filename, PATHINFO_EXTENSION))) { + 'gz' => 'z', + 'bz2' => 'j', + 'xz' => 'J', + default => '' + }; + exec("cd " . escapeshellarg($destDir) . " && tar -x{$flag}f " . escapeshellarg($fullPath) . " 2>&1", $output, $returnCode); + } else { + switch ($ext) { + case 'zip': + exec("cd " . escapeshellarg($destDir) . " && unzip -o " . escapeshellarg($fullPath) . " 2>&1", $output, $returnCode); + break; + case 'gz': + exec("cd " . escapeshellarg($destDir) . " && pigz -dk " . escapeshellarg($fullPath) . " 2>&1", $output, $returnCode); + break; + case 'tgz': + exec("cd " . escapeshellarg($destDir) . " && tar -I pigz -xf " . escapeshellarg($fullPath) . " 2>&1", $output, $returnCode); + break; + case 'tar': + exec("cd " . escapeshellarg($destDir) . " && tar -xf " . escapeshellarg($fullPath) . " 2>&1", $output, $returnCode); + break; + case 'bz2': + exec("cd " . escapeshellarg($destDir) . " && bunzip2 -k " . escapeshellarg($fullPath) . " 2>&1", $output, $returnCode); + break; + case 'xz': + exec("cd " . escapeshellarg($destDir) . " && unxz -k " . escapeshellarg($fullPath) . " 2>&1", $output, $returnCode); + break; + case 'rar': + exec("cd " . escapeshellarg($destDir) . " && unrar x -o+ " . escapeshellarg($fullPath) . " 2>&1", $output, $returnCode); + break; + case '7z': + exec("cd " . escapeshellarg($destDir) . " && 7z x -y " . escapeshellarg($fullPath) . " 2>&1", $output, $returnCode); + break; + default: + return ['success' => false, 'error' => 'Unsupported archive format']; + } + } + + if ($returnCode !== 0) { + return ['success' => false, 'error' => 'Extract failed: ' . implode("\n", $output)]; + } + + // Fix ownership of extracted files + exec("chown -R " . escapeshellarg($username) . ":" . escapeshellarg($username) . " " . escapeshellarg($destDir)); + + logger("Extracted: $fullPath for user $username"); + + return ['success' => true, 'message' => 'Archive extracted successfully']; +} + +function fileChmod(array $params): array +{ + $username = $params['username'] ?? ''; + $path = $params['path'] ?? ''; + $mode = $params['mode'] ?? ''; + + if (!validateUsername($username) || isProtectedUser($username)) { + return ['success' => false, 'error' => 'Invalid username']; + } + + $fullPath = validateUserPath($username, $path); + if ($fullPath === null) { + return ['success' => false, 'error' => 'Invalid path']; + } + + if (!file_exists($fullPath)) { + return ['success' => false, 'error' => 'File not found']; + } + + // Validate mode - must be octal string like "755" or "0755" + $mode = ltrim($mode, '0'); + if (!preg_match('/^[0-7]{3,4}$/', $mode)) { + return ['success' => false, 'error' => 'Invalid permission mode']; + } + + $octalMode = octdec($mode); + + // Safety: Don't allow setuid/setgid bits for regular users + $octalMode = $octalMode & 0777; + + if (!@chmod($fullPath, $octalMode)) { + return ['success' => false, 'error' => 'Failed to change permissions']; + } + + logger("Chmod: $fullPath to $mode for user $username"); + + return ['success' => true, 'message' => 'Permissions changed successfully', 'mode' => sprintf('%o', $octalMode)]; +} + +function fileChown(array $params): array +{ + $path = $params['path'] ?? ''; + $owner = $params['owner'] ?? ''; + $group = $params['group'] ?? ''; + + if (empty($path)) { + return ['success' => false, 'error' => 'Path is required']; + } + + if (empty($owner) && empty($group)) { + return ['success' => false, 'error' => 'Owner or group is required']; + } + + // Security: Only allow chown in specific directories + $allowedPrefixes = [ + '/var/backups/jabali/', + '/home/', + ]; + + $realPath = realpath($path); + if ($realPath === false) { + return ['success' => false, 'error' => 'Path does not exist']; + } + + $allowed = false; + foreach ($allowedPrefixes as $prefix) { + if (strpos($realPath, $prefix) === 0) { + $allowed = true; + break; + } + } + + if (!$allowed) { + logger("Security: Blocked chown attempt on $realPath", 'WARNING'); + return ['success' => false, 'error' => 'Path not in allowed directories']; + } + + // Change owner + if (!empty($owner)) { + if (!@chown($realPath, $owner)) { + return ['success' => false, 'error' => 'Failed to change owner']; + } + } + + // Change group + if (!empty($group)) { + if (!@chgrp($realPath, $group)) { + return ['success' => false, 'error' => 'Failed to change group']; + } + } + + logger("Chown: $realPath to $owner:$group"); + + return ['success' => true, 'message' => 'Ownership changed successfully']; +} + +function fileTrash(array $params): array +{ + $username = $params['username'] ?? ''; + $path = $params['path'] ?? ''; + + if (!validateUsername($username) || isProtectedUser($username)) { + return ['success' => false, 'error' => 'Invalid username']; + } + + $fullPath = validateUserPath($username, $path); + if ($fullPath === null) { + return ['success' => false, 'error' => 'Invalid path']; + } + + if (!file_exists($fullPath)) { + return ['success' => false, 'error' => 'File not found']; + } + + // Create trash directory if it doesn't exist + $trashDir = "/home/$username/.trash"; + if (!is_dir($trashDir)) { + mkdir($trashDir, 0755, true); + chown($trashDir, $username); + chgrp($trashDir, $username); + } + + // Generate unique name to avoid conflicts + $basename = basename($fullPath); + $timestamp = date('Y-m-d_His'); + $trashName = "{$basename}.{$timestamp}"; + $trashPath = "$trashDir/$trashName"; + + // Store original path in metadata file for potential restore + $metaFile = "$trashPath.meta"; + + if (!@rename($fullPath, $trashPath)) { + return ['success' => false, 'error' => 'Failed to move to trash']; + } + + // Save metadata + file_put_contents($metaFile, json_encode([ + 'original_path' => str_replace("/home/$username/", '', $path), + 'trashed_at' => time(), + 'name' => $basename, + ])); + chown($metaFile, $username); + chgrp($metaFile, $username); + + logger("Trashed: $fullPath to $trashPath for user $username"); + + return ['success' => true, 'message' => 'Moved to trash', 'trash_path' => ".trash/$trashName"]; +} + +function fileRestore(array $params): array +{ + $username = $params['username'] ?? ''; + $trashName = $params['trash_name'] ?? ''; + + if (!validateUsername($username) || isProtectedUser($username)) { + return ['success' => false, 'error' => 'Invalid username']; + } + + $trashDir = "/home/$username/.trash"; + $trashPath = "$trashDir/$trashName"; + $metaFile = "$trashPath.meta"; + + if (!file_exists($trashPath)) { + return ['success' => false, 'error' => 'Item not found in trash']; + } + + // Try to get original path from metadata + $originalPath = null; + if (file_exists($metaFile)) { + $meta = json_decode(file_get_contents($metaFile), true); + $originalPath = $meta['original_path'] ?? null; + } + + if (!$originalPath) { + // Fall back to home directory with original name (strip timestamp) + $basename = preg_replace('/\.\d{4}-\d{2}-\d{2}_\d{6}$/', '', $trashName); + $originalPath = $basename; + } + + $fullDestPath = "/home/$username/$originalPath"; + + // If destination exists, add suffix + if (file_exists($fullDestPath)) { + $pathInfo = pathinfo($fullDestPath); + $counter = 1; + do { + $newName = $pathInfo['filename'] . "_restored_$counter"; + if (isset($pathInfo['extension'])) { + $newName .= '.' . $pathInfo['extension']; + } + $fullDestPath = $pathInfo['dirname'] . '/' . $newName; + $counter++; + } while (file_exists($fullDestPath)); + } + + // Ensure destination directory exists + $destDir = dirname($fullDestPath); + if (!is_dir($destDir)) { + mkdir($destDir, 0755, true); + chown($destDir, $username); + chgrp($destDir, $username); + } + + if (!@rename($trashPath, $fullDestPath)) { + return ['success' => false, 'error' => 'Failed to restore from trash']; + } + + // Remove metadata file + @unlink($metaFile); + + logger("Restored: $trashPath to $fullDestPath for user $username"); + + return ['success' => true, 'message' => 'Restored from trash', 'restored_path' => str_replace("/home/$username/", '', $fullDestPath)]; +} + +function fileEmptyTrash(array $params): array +{ + $username = $params['username'] ?? ''; + + if (!validateUsername($username) || isProtectedUser($username)) { + return ['success' => false, 'error' => 'Invalid username']; + } + + $trashDir = "/home/$username/.trash"; + + if (!is_dir($trashDir)) { + return ['success' => true, 'message' => 'Trash is already empty', 'deleted' => 0]; + } + + $deleted = 0; + $items = scandir($trashDir); + foreach ($items as $item) { + if ($item === '.' || $item === '..') continue; + $itemPath = "$trashDir/$item"; + if (is_dir($itemPath)) { + exec("rm -rf " . escapeshellarg($itemPath)); + } else { + @unlink($itemPath); + } + $deleted++; + } + + logger("Emptied trash for user $username ($deleted items)"); + + return ['success' => true, 'message' => 'Trash emptied', 'deleted' => $deleted]; +} + +function fileListTrash(array $params): array +{ + $username = $params['username'] ?? ''; + + if (!validateUsername($username)) { + return ['success' => false, 'error' => 'Invalid username']; + } + + $trashDir = "/home/$username/.trash"; + + if (!is_dir($trashDir)) { + return ['success' => true, 'items' => []]; + } + + $items = []; + $files = scandir($trashDir); + foreach ($files as $file) { + if ($file === '.' || $file === '..' || str_ends_with($file, '.meta')) continue; + + $fullPath = "$trashDir/$file"; + $stat = @stat($fullPath); + if (!$stat) continue; + + $meta = null; + $metaFile = "$fullPath.meta"; + if (file_exists($metaFile)) { + $meta = json_decode(file_get_contents($metaFile), true); + } + + $items[] = [ + 'trash_name' => $file, + 'name' => $meta['name'] ?? preg_replace('/\.\d{4}-\d{2}-\d{2}_\d{6}$/', '', $file), + 'original_path' => $meta['original_path'] ?? null, + 'trashed_at' => $meta['trashed_at'] ?? $stat['mtime'], + 'is_dir' => is_dir($fullPath), + 'size' => is_dir($fullPath) ? 0 : $stat['size'], + ]; + } + + // Sort by trashed_at descending (most recent first) + usort($items, fn($a, $b) => $b['trashed_at'] <=> $a['trashed_at']); + + return ['success' => true, 'items' => $items]; +} + +function mysqlCreateMasterUser(array $params): array +{ + $username = $params["username"] ?? ""; + + if (!validateUsername($username)) { + return ["success" => false, "error" => "Invalid username"]; + } + + $conn = getMysqlConnection(); + if (!$conn) { + return ["success" => false, "error" => "Cannot connect to MySQL"]; + } + + // Create master user with pattern like: user1_admin + $masterUser = $username . "_admin"; + $password = bin2hex(random_bytes(16)); + + // Drop if exists + $conn->query("DROP USER IF EXISTS '{$masterUser}'@'localhost'"); + + // Create user + $escapedPassword = $conn->real_escape_string($password); + if (!$conn->query("CREATE USER '{$masterUser}'@'localhost' IDENTIFIED BY '{$escapedPassword}'")) { + $conn->close(); + return ["success" => false, "error" => "Failed to create master user: " . $conn->error]; + } + + // Grant privileges to all user's databases using wildcard + $pattern = $username . "\\_%"; + if (!$conn->query("GRANT ALL PRIVILEGES ON `{$pattern}`.* TO '{$masterUser}'@'localhost'")) { + $conn->close(); + return ["success" => false, "error" => "Failed to grant privileges: " . $conn->error]; + } + + $conn->query("FLUSH PRIVILEGES"); + $conn->close(); + + return [ + "success" => true, + "master_user" => $masterUser, + "password" => $password, + "message" => "Master MySQL user created with access to all {$username}_* databases" + ]; +} + +function mysqlImportDatabase(array $params): array +{ + $username = $params["username"] ?? ""; + $database = $params["database"] ?? ""; + $sqlFile = $params["sql_file"] ?? ""; + + if (!validateUsername($username)) { + return ["success" => false, "error" => "Invalid username"]; + } + + if (!file_exists($sqlFile) || !is_readable($sqlFile)) { + return ["success" => false, "error" => "SQL file not found or not readable"]; + } + + // Ensure database belongs to user + $prefix = $username . "_"; + if (strpos($database, $prefix) !== 0) { + return ["success" => false, "error" => "Database does not belong to user"]; + } + + // Get MySQL root credentials + $mysqlRoot = getMysqlRootCredentials(); + if (!$mysqlRoot) { + return ["success" => false, "error" => "Cannot get MySQL credentials"]; + } + + // Detect file type by extension + $extension = strtolower(pathinfo($sqlFile, PATHINFO_EXTENSION)); + $filename = strtolower(basename($sqlFile)); + + // Import using mysql command with socket authentication + // Redirect stderr to a temp file to avoid mixing with stdin + $errFile = tempnam('/tmp', 'mysql_err_'); + + if ($extension === 'gz' || str_ends_with($filename, '.sql.gz')) { + // Gzipped SQL file - decompress and pipe to mysql + $cmd = sprintf( + 'pigz -dc %s | mysql --defaults-file=/etc/mysql/debian.cnf %s 2>%s', + escapeshellarg($sqlFile), + escapeshellarg($database), + escapeshellarg($errFile) + ); + } elseif ($extension === 'zip') { + // ZIP file - extract first file and pipe to mysql + $cmd = sprintf( + 'unzip -p %s | mysql --defaults-file=/etc/mysql/debian.cnf %s 2>%s', + escapeshellarg($sqlFile), + escapeshellarg($database), + escapeshellarg($errFile) + ); + } else { + // Plain SQL file + $cmd = sprintf( + 'mysql --defaults-file=/etc/mysql/debian.cnf %s < %s 2>%s', + escapeshellarg($database), + escapeshellarg($sqlFile), + escapeshellarg($errFile) + ); + } + + exec($cmd, $output, $returnCode); + + // Read stderr if command failed + if ($returnCode !== 0) { + $stderr = file_get_contents($errFile); + @unlink($errFile); + return ["success" => false, "error" => "Import failed: " . trim($stderr)]; + } + @unlink($errFile); + + logger("Database imported: $database from $sqlFile for user $username"); + + return ["success" => true, "message" => "Database imported successfully"]; +} + +function mysqlExportDatabase(array $params): array +{ + $username = $params["username"] ?? ""; + $database = $params["database"] ?? ""; + $outputFile = $params["output_file"] ?? ""; + $compress = $params["compress"] ?? "gz"; // "gz", "zip", or "none" + + if (!validateUsername($username)) { + return ["success" => false, "error" => "Invalid username"]; + } + + // Ensure database belongs to user + $prefix = $username . "_"; + if (strpos($database, $prefix) !== 0) { + return ["success" => false, "error" => "Database does not belong to user"]; + } + + // Get MySQL root credentials + $mysqlRoot = getMysqlRootCredentials(); + if (!$mysqlRoot) { + return ["success" => false, "error" => "Cannot get MySQL credentials"]; + } + + // Ensure output directory exists + $outputDir = dirname($outputFile); + if (!is_dir($outputDir)) { + mkdir($outputDir, 0755, true); + } + + // Export using mysqldump with socket authentication + $errFile = tempnam('/tmp', 'mysqldump_err_'); + + if ($compress === "gz") { + // Pipe through pigz (parallel gzip) for compression + $cmd = sprintf( + 'mysqldump --defaults-file=/etc/mysql/debian.cnf --single-transaction --routines --triggers %s 2>%s | pigz > %s', + escapeshellarg($database), + escapeshellarg($errFile), + escapeshellarg($outputFile) + ); + } elseif ($compress === "zip") { + // Export to temp SQL file, then zip + $tempSql = tempnam('/tmp', 'mysqldump_') . '.sql'; + $cmd = sprintf( + 'mysqldump --defaults-file=/etc/mysql/debian.cnf --single-transaction --routines --triggers %s > %s 2>%s && zip -j %s %s && rm -f %s', + escapeshellarg($database), + escapeshellarg($tempSql), + escapeshellarg($errFile), + escapeshellarg($outputFile), + escapeshellarg($tempSql), + escapeshellarg($tempSql) + ); + } else { + // No compression + $cmd = sprintf( + 'mysqldump --defaults-file=/etc/mysql/debian.cnf --single-transaction --routines --triggers %s > %s 2>%s', + escapeshellarg($database), + escapeshellarg($outputFile), + escapeshellarg($errFile) + ); + } + + exec($cmd, $output, $returnCode); + + if ($returnCode !== 0) { + $stderr = file_get_contents($errFile); + @unlink($errFile); + return ["success" => false, "error" => "Export failed: " . trim($stderr)]; + } + @unlink($errFile); + + // Set ownership to user + chown($outputFile, $username); + chgrp($outputFile, $username); + + logger("Database exported: $database to $outputFile (compress: $compress) for user $username"); + + return ["success" => true, "output_file" => $outputFile, "message" => "Database exported successfully"]; +} + +// ============ SERVICE MANAGEMENT ============ + +function shouldReloadService(string $service): bool +{ + $normalized = $service; + if (str_ends_with($normalized, '.service')) { + $normalized = substr($normalized, 0, -strlen('.service')); + } + + if ($normalized === 'nginx') { + return true; + } + + return preg_match('/^php(\d+\.\d+)?-fpm$/', $normalized) === 1; +} + +function restartService(array $params): array +{ + global $allowedServices; + $service = $params['service'] ?? ''; + + if (!in_array($service, $allowedServices, true)) { + return ['success' => false, 'error' => "Service not allowed: $service"]; + } + + $action = shouldReloadService($service) ? 'reload' : 'restart'; + exec("systemctl {$action} " . escapeshellarg($service) . " 2>&1", $output, $exitCode); + + return $exitCode === 0 + ? ['success' => true, 'message' => "Service $service " . ($action === 'reload' ? 'reloaded' : 'restarted')] + : ['success' => false, 'error' => 'Failed to ' . $action . ' service']; +} + +function reloadService(array $params): array +{ + global $allowedServices; + $service = $params['service'] ?? ''; + + if (!in_array($service, $allowedServices, true)) { + return ['success' => false, 'error' => "Service not allowed: $service"]; + } + + exec("systemctl reload " . escapeshellarg($service) . " 2>&1", $output, $exitCode); + + return $exitCode === 0 + ? ['success' => true, 'message' => "Service $service reloaded"] + : ['success' => false, 'error' => 'Failed to reload service']; +} + +function getServiceStatus(array $params): array +{ + global $allowedServices; + $service = $params['service'] ?? ''; + + if (!in_array($service, $allowedServices, true)) { + return ['success' => false, 'error' => "Service not allowed: $service"]; + } + + exec("systemctl is-active " . escapeshellarg($service) . " 2>&1", $output, $exitCode); + $status = trim($output[0] ?? 'unknown'); + + return ['success' => true, 'service' => $service, 'status' => $status, 'running' => $status === 'active']; +} + +/** + * Enable gzip compression in nginx for all text-based content + * This provides ~400-500KB savings on typical WordPress sites + */ +function nginxEnableCompression(array $params): array +{ + $nginxConf = '/etc/nginx/nginx.conf'; + + if (!file_exists($nginxConf)) { + return ['success' => false, 'error' => 'nginx.conf not found']; + } + + $content = file_get_contents($nginxConf); + $modified = false; + + // Check if gzip_types is already configured (not commented) + if (preg_match('/^\s*gzip_types\s/m', $content)) { + return ['success' => true, 'message' => 'Compression already enabled', 'already_enabled' => true]; + } + + // Uncomment and configure gzip settings (patterns account for tab indentation) + $replacements = [ + '/^[ \t]*# gzip_vary on;/m' => "\tgzip_vary on;", + '/^[ \t]*# gzip_proxied any;/m' => "\tgzip_proxied any;", + '/^[ \t]*# gzip_comp_level 6;/m' => "\tgzip_comp_level 6;", + '/^[ \t]*# gzip_buffers 16 8k;/m' => "\tgzip_buffers 16 8k;", + '/^[ \t]*# gzip_http_version 1.1;/m' => "\tgzip_http_version 1.1;", + '/^[ \t]*# gzip_types .*/m' => "\tgzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml application/xml+rss application/x-javascript application/vnd.ms-fontobject application/x-font-ttf font/opentype font/woff font/woff2 image/svg+xml image/x-icon;", + ]; + + foreach ($replacements as $pattern => $replacement) { + $newContent = preg_replace($pattern, $replacement, $content); + if ($newContent !== $content) { + $content = $newContent; + $modified = true; + } + } + + // Add gzip_min_length if gzip_types was added and gzip_min_length doesn't exist + if ($modified && strpos($content, 'gzip_min_length') === false) { + $content = preg_replace( + '/(gzip_types[^;]+;)/', + "$1\n\tgzip_min_length 256;", + $content + ); + } + + // Write back if modified + if ($modified) { + // Test configuration before applying + file_put_contents($nginxConf, $content); + exec('nginx -t 2>&1', $testOutput, $testCode); + + if ($testCode !== 0) { + // Restore original + exec("git -C /etc/nginx checkout nginx.conf 2>/dev/null"); + return ['success' => false, 'error' => 'nginx configuration test failed: ' . implode("\n", $testOutput)]; + } + + // Reload nginx + exec('systemctl reload nginx 2>&1', $output, $exitCode); + + if ($exitCode !== 0) { + return ['success' => false, 'error' => 'Failed to reload nginx']; + } + + return ['success' => true, 'message' => 'Compression enabled successfully']; + } + + return ['success' => true, 'message' => 'Compression settings already optimal', 'already_enabled' => true]; +} + +/** + * Get current nginx compression status + */ +function nginxGetCompressionStatus(array $params): array +{ + $nginxConf = '/etc/nginx/nginx.conf'; + + if (!file_exists($nginxConf)) { + return ['success' => false, 'error' => 'nginx.conf not found']; + } + + $content = file_get_contents($nginxConf); + + $settings = [ + 'gzip' => (bool)preg_match('/^\s*gzip\s+on\s*;/m', $content), + 'gzip_vary' => (bool)preg_match('/^\s*gzip_vary\s+on\s*;/m', $content), + 'gzip_proxied' => (bool)preg_match('/^\s*gzip_proxied\s/m', $content), + 'gzip_comp_level' => null, + 'gzip_types' => (bool)preg_match('/^\s*gzip_types\s/m', $content), + 'gzip_min_length' => null, + ]; + + // Extract compression level + if (preg_match('/^\s*gzip_comp_level\s+(\d+)\s*;/m', $content, $matches)) { + $settings['gzip_comp_level'] = (int)$matches[1]; + } + + // Extract min length + if (preg_match('/^\s*gzip_min_length\s+(\d+)\s*;/m', $content, $matches)) { + $settings['gzip_min_length'] = (int)$matches[1]; + } + + // Determine if fully optimized + $optimized = $settings['gzip'] && $settings['gzip_vary'] && $settings['gzip_types'] && $settings['gzip_comp_level']; + + return [ + 'success' => true, + 'enabled' => $settings['gzip'], + 'optimized' => $optimized, + 'settings' => $settings, + ]; +} + +function ensureJabaliNginxIncludeFiles(): void +{ + if (!is_dir(JABALI_NGINX_INCLUDES)) { + @mkdir(JABALI_NGINX_INCLUDES, 0755, true); + } + + ensureWafUnicodeMapFile(); + ensureWafMainConfig(); + + $modSecurityAvailable = isModSecurityModuleAvailable(); + $baseConfig = findWafBaseConfig(); + $shouldDisableWaf = $baseConfig === null || !$modSecurityAvailable; + + if (!file_exists(JABALI_WAF_INCLUDE)) { + $content = "# Managed by Jabali\n"; + if (!$modSecurityAvailable) { + $content .= "# ModSecurity module not available in nginx.\n"; + } elseif ($shouldDisableWaf) { + $content .= "modsecurity off;\n"; + } + file_put_contents(JABALI_WAF_INCLUDE, $content); + } elseif ($shouldDisableWaf) { + $current = file_get_contents(JABALI_WAF_INCLUDE); + if ($current === false || strpos($current, 'modsecurity_rules_file') !== false || strpos($current, 'modsecurity on;') !== false || strpos($current, 'modsecurity off;') !== false) { + if (!$modSecurityAvailable) { + file_put_contents(JABALI_WAF_INCLUDE, "# Managed by Jabali\n# ModSecurity module not available in nginx.\n"); + } else { + file_put_contents(JABALI_WAF_INCLUDE, "# Managed by Jabali\nmodsecurity off;\n"); + } + } + } + + if (!file_exists(JABALI_GEO_INCLUDE)) { + file_put_contents(JABALI_GEO_INCLUDE, "# Managed by Jabali\n"); + } +} + +function ensureWafMainConfig(): void +{ + $path = '/etc/nginx/modsec/main.conf'; + $dir = dirname($path); + + if (!is_dir($dir)) { + @mkdir($dir, 0755, true); + } + + $needsRewrite = !file_exists($path); + if (!$needsRewrite) { + $content = file_get_contents($path); + if ($content === false || stripos($content, 'IncludeOptional') !== false || stripos($content, 'owasp-crs.load') !== false) { + $needsRewrite = true; + } + } + + if (!$needsRewrite) { + return; + } + + $lines = ['Include /etc/modsecurity/modsecurity.conf']; + + if (file_exists('/etc/modsecurity/crs/crs-setup.conf')) { + $lines[] = 'Include /etc/modsecurity/crs/crs-setup.conf'; + } elseif (file_exists('/usr/share/modsecurity-crs/crs-setup.conf')) { + $lines[] = 'Include /usr/share/modsecurity-crs/crs-setup.conf'; + } + + if (file_exists('/etc/modsecurity/crs/REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf')) { + $lines[] = 'Include /etc/modsecurity/crs/REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf'; + } + + if (is_dir('/usr/share/modsecurity-crs/rules')) { + $lines[] = 'Include /usr/share/modsecurity-crs/rules/*.conf'; + } + + if (file_exists('/etc/modsecurity/crs/RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf')) { + $lines[] = 'Include /etc/modsecurity/crs/RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf'; + } + + file_put_contents($path, implode("\n", $lines) . "\n"); +} + +function ensureWafUnicodeMapFile(): void +{ + $target = '/etc/modsecurity/unicode.mapping'; + if (file_exists($target)) { + return; + } + + $sources = [ + '/usr/share/modsecurity-crs/util/unicode.mapping', + '/usr/share/modsecurity-crs/unicode.mapping', + '/usr/share/modsecurity/unicode.mapping', + '/etc/nginx/unicode.mapping', + '/usr/share/nginx/docs/modsecurity/unicode.mapping', + ]; + + foreach ($sources as $source) { + if (!file_exists($source)) { + continue; + } + + if (!is_dir('/etc/modsecurity')) { + @mkdir('/etc/modsecurity', 0755, true); + } + + if (@copy($source, $target)) { + break; + } + } +} + +function ensureNginxServerIncludes(array $includeLines): array +{ + $files = glob('/etc/nginx/sites-enabled/*.conf') ?: []; + $updated = 0; + + foreach ($files as $file) { + $content = file_get_contents($file); + if ($content === false) { + continue; + } + + $original = $content; + foreach ($includeLines as $line) { + if (strpos($content, $line) !== false) { + continue; + } + + $content = preg_replace('/(server_name[^\n]*\n)/', "$1 {$line}\n", $content); + } + + if ($content !== $original) { + file_put_contents($file, $content); + $updated++; + } + } + + return [ + 'files' => count($files), + 'updated' => $updated, + ]; +} + +function nginxTestAndReload(): array +{ + exec('nginx -t 2>&1', $testOutput, $testCode); + if ($testCode !== 0) { + return ['success' => false, 'error' => 'nginx configuration test failed: ' . implode("\n", $testOutput)]; + } + + exec('systemctl reload nginx 2>&1', $output, $exitCode); + if ($exitCode !== 0) { + return ['success' => false, 'error' => 'Failed to reload nginx']; + } + + return ['success' => true]; +} + +function findWafBaseConfig(): ?string +{ + $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) && isWafBaseConfigUsable($path)) { + return $path; + } + } + + return null; +} + +function findWafCoreConfig(): ?string +{ + $paths = [ + '/etc/modsecurity/modsecurity.conf', + '/etc/modsecurity/modsecurity.conf-recommended', + '/etc/nginx/modsecurity.conf', + ]; + + foreach ($paths as $path) { + if (file_exists($path) && isWafBaseConfigUsable($path)) { + return $path; + } + } + + return null; +} + +function buildWafCrsIncludeLines(): array +{ + $lines = []; + + if (file_exists('/etc/modsecurity/crs/crs-setup.conf')) { + $lines[] = 'Include /etc/modsecurity/crs/crs-setup.conf'; + } elseif (file_exists('/usr/share/modsecurity-crs/crs-setup.conf')) { + $lines[] = 'Include /usr/share/modsecurity-crs/crs-setup.conf'; + } + + if (file_exists('/etc/modsecurity/crs/REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf')) { + $lines[] = 'Include /etc/modsecurity/crs/REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf'; + } + + if (is_dir('/etc/modsecurity/crs/rules')) { + $lines[] = 'Include /etc/modsecurity/crs/rules/*.conf'; + } elseif (is_dir('/usr/share/modsecurity-crs/rules')) { + $lines[] = 'Include /usr/share/modsecurity-crs/rules/*.conf'; + } + + if (file_exists('/etc/modsecurity/crs/RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf')) { + $lines[] = 'Include /etc/modsecurity/crs/RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf'; + } + + return $lines; +} + +function isModSecurityModuleAvailable(): bool +{ + $output = []; + exec('nginx -V 2>&1', $output); + $info = implode("\n", $output); + + if (stripos($info, 'modsecurity') !== false) { + return true; + } + + foreach (glob('/etc/nginx/modules-enabled/*.conf') ?: [] as $file) { + $content = file_get_contents($file); + if ($content !== false && stripos($content, 'modsecurity') !== false) { + return true; + } + } + + return false; +} + +function isWafBaseConfigUsable(string $path): bool +{ + if (!is_readable($path)) { + return false; + } + + $content = file_get_contents($path); + if ($content === false) { + return false; + } + + if (stripos($content, 'IncludeOptional') !== false) { + return false; + } + + if (preg_match_all('/^\s*Include\s+("?)([^"\s]+)\1/m', $content, $matches)) { + foreach ($matches[2] as $includePath) { + if ($includePath === '/etc/modsecurity/modsecurity.conf' && !file_exists($includePath)) { + return false; + } + } + } + + if (preg_match_all('/^\s*SecUnicodeMapFile\s+([^\s]+)\s*/m', $content, $matches)) { + $baseDir = dirname($path); + foreach ($matches[1] as $mapPath) { + $candidates = []; + if (str_starts_with($mapPath, '/')) { + $candidates[] = $mapPath; + } else { + $candidates[] = $baseDir . '/' . $mapPath; + $candidates[] = '/etc/modsecurity/' . $mapPath; + } + + $found = false; + foreach ($candidates as $candidate) { + if (file_exists($candidate)) { + $found = true; + break; + } + } + + if (!$found) { + return false; + } + } + } + + return true; +} + +function wafApplySettings(array $params): array +{ + $enabled = !empty($params['enabled']); + $paranoia = (int) ($params['paranoia'] ?? 1); + $paranoia = max(1, min(4, $paranoia)); + $auditLog = !empty($params['audit_log']); + $whitelistRules = $params['whitelist_rules'] ?? []; + + ensureJabaliNginxIncludeFiles(); + + $prevInclude = file_exists(JABALI_WAF_INCLUDE) ? file_get_contents(JABALI_WAF_INCLUDE) : null; + $prevRules = file_exists(JABALI_WAF_RULES) ? file_get_contents(JABALI_WAF_RULES) : null; + $modSecurityAvailable = isModSecurityModuleAvailable(); + + if ($enabled) { + if (!$modSecurityAvailable) { + file_put_contents(JABALI_WAF_INCLUDE, "# Managed by Jabali\n# ModSecurity module not available in nginx.\n"); + return ['success' => false, 'error' => 'ModSecurity module not available in nginx']; + } + + ensureWafUnicodeMapFile(); + $coreConfig = findWafCoreConfig(); + if (!$coreConfig) { + file_put_contents(JABALI_WAF_INCLUDE, "# Managed by Jabali\nmodsecurity off;\n"); + return ['success' => false, 'error' => 'ModSecurity base configuration not found']; + } + + $rules = [ + '# Managed by Jabali', + 'Include "' . $coreConfig . '"', + 'SecRuleEngine On', + 'SecAuditEngine ' . ($auditLog ? 'On' : 'Off'), + 'SecAuditLog /var/log/nginx/modsec_audit.log', + 'SecAction "id:900000,phase:1,t:none,pass,setvar:tx.paranoia_level=' . $paranoia . '"', + 'SecAction "id:900110,phase:1,t:none,pass,setvar:tx.executing_paranoia_level=' . $paranoia . '"', + ]; + + $whitelistLines = buildWafWhitelistRules($whitelistRules); + if (!empty($whitelistLines)) { + $rules = array_merge($rules, $whitelistLines); + } + + $crsIncludes = buildWafCrsIncludeLines(); + if (!empty($crsIncludes)) { + $rules = array_merge($rules, $crsIncludes); + } + + file_put_contents(JABALI_WAF_RULES, implode("\n", $rules) . "\n"); + + $include = [ + '# Managed by Jabali', + 'modsecurity on;', + 'modsecurity_rules_file ' . JABALI_WAF_RULES . ';', + ]; + + file_put_contents(JABALI_WAF_INCLUDE, implode("\n", $include) . "\n"); + } else { + if ($modSecurityAvailable) { + file_put_contents(JABALI_WAF_INCLUDE, "# Managed by Jabali\nmodsecurity off;\n"); + } else { + file_put_contents(JABALI_WAF_INCLUDE, "# Managed by Jabali\n# ModSecurity module not available in nginx.\n"); + } + } + + ensureNginxServerIncludes([ + 'include ' . JABALI_WAF_INCLUDE . ';', + ]); + + $reload = nginxTestAndReload(); + if (!($reload['success'] ?? false)) { + if ($prevInclude === null) { + @unlink(JABALI_WAF_INCLUDE); + } else { + file_put_contents(JABALI_WAF_INCLUDE, $prevInclude); + } + + if ($prevRules === null) { + @unlink(JABALI_WAF_RULES); + } else { + file_put_contents(JABALI_WAF_RULES, $prevRules); + } + + return $reload; + } + + return ['success' => true, 'enabled' => $enabled, 'paranoia' => $paranoia, 'audit_log' => $auditLog]; +} + +function buildWafWhitelistRules(array $rules): array +{ + $lines = []; + $ruleBaseId = 120000; + $index = 0; + + $matchMap = [ + 'ip' => ['REMOTE_ADDR', '@ipMatch'], + 'uri_exact' => ['REQUEST_URI', '@streq'], + 'uri_prefix' => ['REQUEST_URI', '@beginsWith'], + 'host' => ['REQUEST_HEADERS:Host', '@streq'], + ]; + + foreach ($rules as $rule) { + if (!is_array($rule)) { + continue; + } + + $matchType = (string) ($rule['match_type'] ?? ''); + $matchValue = trim((string) ($rule['match_value'] ?? '')); + $idsRaw = (string) ($rule['rule_ids'] ?? ''); + + if ($matchValue === '' || $idsRaw === '') { + continue; + } + + if (!isset($matchMap[$matchType])) { + continue; + } + + $ids = preg_split('/[,\s]+/', $idsRaw, -1, PREG_SPLIT_NO_EMPTY) ?: []; + $ids = array_values(array_filter(array_map('trim', $ids), function ($id) { + return ctype_digit($id); + })); + + if (empty($ids)) { + continue; + } + + [$variable, $operator] = $matchMap[$matchType]; + $ruleId = $ruleBaseId + $index; + $index++; + + $ctlParts = []; + foreach ($ids as $id) { + $ctlParts[] = 'ctl:ruleRemoveById=' . $id; + } + + $value = str_replace('"', '\\"', $matchValue); + $lines[] = sprintf( + 'SecRule %s "%s %s" "id:%d,phase:1,pass,nolog,%s"', + $variable, + $operator, + $value, + $ruleId, + implode(',', $ctlParts) + ); + } + + if (!empty($lines)) { + array_unshift($lines, '# Whitelist rules (managed by Jabali)'); + } + + return $lines; +} + +function geoUpdateDatabase(array $params): array +{ + $accountId = trim((string) ($params['account_id'] ?? '')); + $licenseKey = trim((string) ($params['license_key'] ?? '')); + $editionIdsRaw = $params['edition_ids'] ?? 'GeoLite2-Country'; + $useExisting = !empty($params['use_existing']); + + $toolError = ensureGeoIpUpdateTool(); + if ($toolError !== null) { + return ['success' => false, 'error' => $toolError]; + } + + if (!$useExisting && ($accountId === '' || $licenseKey === '')) { + return ['success' => false, 'error' => 'MaxMind Account ID and License Key are required']; + } + + $editionIds = []; + if (is_array($editionIdsRaw)) { + $editionIds = $editionIdsRaw; + } else { + $editionIds = preg_split('/[,\s]+/', (string) $editionIdsRaw, -1, PREG_SPLIT_NO_EMPTY) ?: []; + } + + $editionIds = array_values(array_filter(array_map('trim', $editionIds))); + if (empty($editionIds)) { + $editionIds = ['GeoLite2-Country']; + } + + $configLines = [ + '# Managed by Jabali', + 'AccountID ' . $accountId, + 'LicenseKey ' . $licenseKey, + 'EditionIDs ' . implode(' ', $editionIds), + 'DatabaseDirectory /usr/share/GeoIP', + ]; + $config = implode("\n", $configLines) . "\n"; + + if (!is_dir('/usr/share/GeoIP')) { + @mkdir('/usr/share/GeoIP', 0755, true); + } + + $configPaths = [ + '/etc/GeoIP.conf', + '/etc/geoipupdate/GeoIP.conf', + ]; + + foreach ($configPaths as $path) { + $dir = dirname($path); + if (!is_dir($dir)) { + @mkdir($dir, 0755, true); + } + + if (!$useExisting) { + file_put_contents($path, $config); + @chmod($path, 0600); + } elseif (!file_exists($path)) { + continue; + } + } + + exec('geoipupdate -v 2>&1', $output, $code); + $outputText = trim(implode("\n", $output)); + if ($code !== 0) { + return [ + 'success' => false, + 'error' => $outputText !== '' ? $outputText : 'geoipupdate failed', + ]; + } + + $paths = []; + foreach ($editionIds as $edition) { + $paths[] = '/usr/share/GeoIP/' . $edition . '.mmdb'; + $paths[] = '/usr/local/share/GeoIP/' . $edition . '.mmdb'; + } + + foreach ($paths as $path) { + if (file_exists($path)) { + return ['success' => true, 'path' => $path]; + } + } + + return ['success' => false, 'error' => 'GeoIP database not found after update']; +} + +function geoUploadDatabase(array $params): array +{ + $edition = trim((string) ($params['edition'] ?? 'GeoLite2-Country')); + $content = (string) ($params['content'] ?? ''); + + if ($content === '') { + return ['success' => false, 'error' => 'No database content provided']; + } + + if (!preg_match('/^[A-Za-z0-9._-]+$/', $edition)) { + return ['success' => false, 'error' => 'Invalid edition name']; + } + + $decoded = base64_decode($content, true); + if ($decoded === false) { + return ['success' => false, 'error' => 'Invalid database content']; + } + + $targetDir = '/usr/share/GeoIP'; + if (!is_dir($targetDir)) { + @mkdir($targetDir, 0755, true); + } + + $target = $targetDir . '/' . $edition . '.mmdb'; + if (file_put_contents($target, $decoded) === false) { + return ['success' => false, 'error' => 'Failed to write GeoIP database']; + } + + @chmod($target, 0644); + + return ['success' => true, 'path' => $target]; +} + +function ensureGeoIpUpdateTool(): ?string +{ + if (toolExists('geoipupdate')) { + return null; + } + + $error = installGeoIpUpdateBinary(); + if ($error !== null) { + return $error; + } + + if (!toolExists('geoipupdate')) { + return 'geoipupdate is not installed'; + } + + return null; +} + +function installGeoIpUpdateBinary(): ?string +{ + $arch = php_uname('m'); + $archMap = [ + 'x86_64' => 'amd64', + 'amd64' => 'amd64', + 'aarch64' => 'arm64', + 'arm64' => 'arm64', + ]; + $archToken = $archMap[$arch] ?? $arch; + + $apiUrl = 'https://api.github.com/repos/maxmind/geoipupdate/releases/latest'; + $metadata = @shell_exec('curl -fsSL ' . escapeshellarg($apiUrl) . ' 2>/dev/null'); + if (!$metadata) { + $metadata = @shell_exec('wget -qO- ' . escapeshellarg($apiUrl) . ' 2>/dev/null'); + } + + if (!$metadata) { + return 'Failed to download geoipupdate release metadata'; + } + + $data = json_decode($metadata, true); + if (!is_array($data)) { + return 'Invalid geoipupdate release metadata'; + } + + $downloadUrl = null; + foreach (($data['assets'] ?? []) as $asset) { + $name = strtolower((string) ($asset['name'] ?? '')); + $url = (string) ($asset['browser_download_url'] ?? ''); + if ($name === '' || $url === '') { + continue; + } + if (strpos($name, 'linux') === false) { + continue; + } + if (strpos($name, $archToken) === false) { + if (!($archToken === 'amd64' && strpos($name, 'x86_64') !== false)) { + continue; + } + } + if (!str_ends_with($name, '.tar.gz') && !str_ends_with($name, '.tgz')) { + continue; + } + $downloadUrl = $url; + break; + } + + if (!$downloadUrl) { + return 'No suitable geoipupdate binary found for ' . $arch; + } + + $tmpDir = sys_get_temp_dir() . '/jabali-geoipupdate-' . bin2hex(random_bytes(4)); + @mkdir($tmpDir, 0755, true); + $archive = $tmpDir . '/geoipupdate.tgz'; + + $downloadCmd = toolExists('curl') + ? 'curl -fsSL ' . escapeshellarg($downloadUrl) . ' -o ' . escapeshellarg($archive) + : 'wget -qO ' . escapeshellarg($archive) . ' ' . escapeshellarg($downloadUrl); + + exec($downloadCmd . ' 2>&1', $output, $code); + if ($code !== 0) { + return 'Failed to download geoipupdate binary'; + } + + exec('tar -xzf ' . escapeshellarg($archive) . ' -C ' . escapeshellarg($tmpDir) . ' 2>&1', $output, $code); + if ($code !== 0) { + return 'Failed to extract geoipupdate archive'; + } + + $binary = null; + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($tmpDir, FilesystemIterator::SKIP_DOTS)); + foreach ($iterator as $file) { + if ($file->isFile() && $file->getFilename() === 'geoipupdate') { + $binary = $file->getPathname(); + break; + } + } + + if (!$binary) { + return 'geoipupdate binary not found in archive'; + } + + exec('install -m 0755 ' . escapeshellarg($binary) . ' /usr/local/bin/geoipupdate 2>&1', $output, $code); + if ($code !== 0) { + return 'Failed to install geoipupdate'; + } + + return null; +} + +function ensureGeoIpModuleEnabled(): ?string +{ + $modulePaths = [ + '/usr/lib/nginx/modules/ngx_http_geoip2_module.so', + '/usr/share/nginx/modules/ngx_http_geoip2_module.so', + ]; + + $modulePath = null; + foreach ($modulePaths as $path) { + if (file_exists($path)) { + $modulePath = $path; + break; + } + } + + if (!$modulePath) { + return 'nginx geoip2 module not installed'; + } + + $modulesEnabledDir = '/etc/nginx/modules-enabled'; + if (!is_dir($modulesEnabledDir)) { + return 'nginx modules-enabled directory not found'; + } + + $alreadyEnabled = false; + foreach (glob($modulesEnabledDir . '/*.conf') ?: [] as $file) { + $contents = file_get_contents($file); + if ($contents !== false && strpos($contents, 'geoip2_module') !== false) { + $alreadyEnabled = true; + break; + } + } + + if ($alreadyEnabled) { + return null; + } + + $loadLine = 'load_module ' . $modulePath . ';'; + $target = $modulesEnabledDir . '/50-jabali-geoip2.conf'; + file_put_contents($target, $loadLine . "\n"); + + return null; +} + +function geoApplyRules(array $params): array +{ + $rules = $params['rules'] ?? []; + $activeRules = array_values(array_filter($rules, function ($rule) { + return !isset($rule['is_active']) || !empty($rule['is_active']); + })); + + $allow = array_values(array_filter($activeRules, fn ($rule) => ($rule['action'] ?? '') === 'allow')); + $block = array_values(array_filter($activeRules, fn ($rule) => ($rule['action'] ?? '') === 'block')); + + ensureJabaliNginxIncludeFiles(); + + $prevGeoHttp = file_exists(JABALI_GEO_HTTP_CONF) ? file_get_contents(JABALI_GEO_HTTP_CONF) : null; + $prevGeoInclude = file_exists(JABALI_GEO_INCLUDE) ? file_get_contents(JABALI_GEO_INCLUDE) : null; + + if (empty($allow) && empty($block)) { + file_put_contents(JABALI_GEO_HTTP_CONF, "# Managed by Jabali\n# No geo rules enabled\n"); + file_put_contents(JABALI_GEO_INCLUDE, "# Managed by Jabali\n"); + ensureNginxServerIncludes([ + 'include ' . JABALI_GEO_INCLUDE . ';', + ]); + + $reload = nginxTestAndReload(); + if (!($reload['success'] ?? false)) { + return $reload; + } + + return ['success' => true, 'rules' => 0]; + } + + $mmdbPaths = [ + '/usr/share/GeoIP/GeoLite2-Country.mmdb', + '/usr/local/share/GeoIP/GeoLite2-Country.mmdb', + ]; + $mmdb = null; + foreach ($mmdbPaths as $path) { + if (file_exists($path)) { + $mmdb = $path; + break; + } + } + + if (!$mmdb) { + $update = geoUpdateDatabase([ + 'use_existing' => true, + 'edition_ids' => 'GeoLite2-Country', + ]); + if (!empty($update['success'])) { + $mmdb = $update['path'] ?? null; + } + } + + if (!$mmdb) { + return ['success' => false, 'error' => 'GeoIP database not found. Update the GeoIP database in the panel.']; + } + + $geoModule = ensureGeoIpModuleEnabled(); + if ($geoModule !== null) { + return ['success' => false, 'error' => $geoModule]; + } + + $countryVar = '$jabali_geo_country_code'; + $mapName = !empty($allow) ? '$jabali_geo_allow' : '$jabali_geo_block'; + $mapLines = [ + "map {$countryVar} {$mapName} {", + ' default 0;', + ]; + + $ruleset = !empty($allow) ? $allow : $block; + foreach ($ruleset as $rule) { + $code = strtoupper(trim($rule['country_code'] ?? '')); + if ($code === '') { + continue; + } + + $mapLines[] = " {$code} 1;"; + } + $mapLines[] = '}'; + + $httpConf = [ + '# Managed by Jabali', + 'real_ip_header X-Forwarded-For;', + 'real_ip_recursive on;', + 'set_real_ip_from 127.0.0.1;', + 'set_real_ip_from ::1;', + '', + "geoip2 {$mmdb} {", + " {$countryVar} country iso_code;", + '}', + '', + ...$mapLines, + ]; + file_put_contents(JABALI_GEO_HTTP_CONF, implode("\n", $httpConf) . "\n"); + + if (!empty($allow)) { + $geoInclude = "# Managed by Jabali\nif ({$mapName} = 0) { return 403; }\n"; + } else { + $geoInclude = "# Managed by Jabali\nif ({$mapName} = 1) { return 403; }\n"; + } + + file_put_contents(JABALI_GEO_INCLUDE, $geoInclude); + + ensureNginxServerIncludes([ + 'include ' . JABALI_GEO_INCLUDE . ';', + ]); + + $reload = nginxTestAndReload(); + if (!($reload['success'] ?? false)) { + if ($prevGeoHttp === null) { + @unlink(JABALI_GEO_HTTP_CONF); + } else { + file_put_contents(JABALI_GEO_HTTP_CONF, $prevGeoHttp); + } + + if ($prevGeoInclude === null) { + @unlink(JABALI_GEO_INCLUDE); + } else { + file_put_contents(JABALI_GEO_INCLUDE, $prevGeoInclude); + } + + return $reload; + } + + return ['success' => true, 'rules' => count($ruleset)]; +} + +function databasePersistTuning(array $params): array +{ + $name = $params['name'] ?? ''; + $value = $params['value'] ?? ''; + + if (!preg_match('/^[a-zA-Z0-9_]+$/', $name)) { + return ['success' => false, 'error' => 'Invalid variable name']; + } + + $configDir = '/etc/mysql/mariadb.conf.d'; + if (!is_dir($configDir)) { + $configDir = '/etc/mysql/conf.d'; + } + + if (!is_dir($configDir)) { + return ['success' => false, 'error' => 'MySQL configuration directory not found']; + } + + $file = $configDir . '/90-jabali-tuning.cnf'; + $lines = file_exists($file) ? file($file, FILE_IGNORE_NEW_LINES) : []; + + if (empty($lines)) { + $lines = ['# Managed by Jabali', '[mysqld]']; + } + + $hasSection = false; + $found = false; + foreach ($lines as $index => $line) { + if (trim($line) === '[mysqld]') { + $hasSection = true; + } + + if (preg_match('/^\s*' . preg_quote($name, '/') . '\s*=/i', $line)) { + $lines[$index] = $name . ' = ' . $value; + $found = true; + } + } + + if (!$hasSection) { + $lines[] = '[mysqld]'; + } + + if (!$found) { + $lines[] = $name . ' = ' . $value; + } + + file_put_contents($file, implode("\n", $lines) . "\n"); + + return ['success' => true, 'message' => 'Configuration persisted']; +} + +// ============ MAIN ============ + +function main(): void +{ + @mkdir(dirname(SOCKET_PATH), 0755, true); + @mkdir(dirname(LOG_FILE), 0755, true); + + if (file_exists(SOCKET_PATH)) { + unlink(SOCKET_PATH); + } + + +// SSH Key Management Functions +function sshListKeys(array $params): array +{ + $username = $params['username'] ?? ''; + + if (!validateUsername($username)) { + return ['success' => false, 'error' => 'Invalid username']; + } + + $userInfo = posix_getpwnam($username); + if (!$userInfo) { + return ['success' => false, 'error' => 'User not found']; + } + + $sshDir = $userInfo['dir'] . '/.ssh'; + $authKeysFile = $sshDir . '/authorized_keys'; + + if (!file_exists($authKeysFile)) { + return ['success' => true, 'keys' => []]; + } + + $keys = []; + $lines = file($authKeysFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + + foreach ($lines as $index => $line) { + $line = trim($line); + if (empty($line) || strpos($line, '#') === 0) { + continue; + } + + // Parse SSH key: type base64 comment + $parts = preg_split('/\s+/', $line, 3); + if (count($parts) < 2) { + continue; + } + + $type = $parts[0]; + $keyData = $parts[1]; + $comment = $parts[2] ?? 'Key ' . ($index + 1); + + // Generate fingerprint + $tempFile = tempnam(sys_get_temp_dir(), 'sshkey'); + file_put_contents($tempFile, $line); + exec("ssh-keygen -lf " . escapeshellarg($tempFile) . " 2>&1", $fpOutput); + @unlink($tempFile); + + $fingerprint = isset($fpOutput[0]) ? preg_replace('/^\d+\s+/', '', $fpOutput[0]) : substr($keyData, 0, 20) . '...'; + + $keys[] = [ + 'id' => md5($line), + 'name' => $comment, + 'type' => $type, + 'fingerprint' => $fingerprint, + 'key' => substr($keyData, 0, 30) . '...', + ]; + } + + return ['success' => true, 'keys' => $keys]; +} + +function sshAddKey(array $params): array +{ + $username = $params['username'] ?? ''; + $name = $params['name'] ?? ''; + $publicKey = $params['public_key'] ?? ''; + + if (!validateUsername($username)) { + return ['success' => false, 'error' => 'Invalid username']; + } + + if (empty($publicKey)) { + return ['success' => false, 'error' => 'Public key is required']; + } + + // Validate key format + $publicKey = trim($publicKey); + if (!preg_match('/^(ssh-rsa|ssh-ed25519|ssh-dss|ecdsa-sha2-\S+)\s+[A-Za-z0-9+\/=]+/', $publicKey)) { + return ['success' => false, 'error' => 'Invalid SSH public key format']; + } + + $userInfo = posix_getpwnam($username); + if (!$userInfo) { + return ['success' => false, 'error' => 'User not found']; + } + + $uid = $userInfo['uid']; + $gid = $userInfo['gid']; + $sshDir = $userInfo['dir'] . '/.ssh'; + $authKeysFile = $sshDir . '/authorized_keys'; + + // Create .ssh directory if it doesn't exist + if (!is_dir($sshDir)) { + mkdir($sshDir, 0700, true); + chown($sshDir, $uid); + chgrp($sshDir, $gid); + } + + // Add comment if not present + if (!preg_match('/\s+\S+$/', $publicKey) || preg_match('/==$/', $publicKey)) { + $publicKey .= ' ' . $name; + } + + // Check if key already exists + if (file_exists($authKeysFile)) { + $existingKeys = file_get_contents($authKeysFile); + $keyParts = preg_split('/\s+/', $publicKey); + if (count($keyParts) >= 2 && strpos($existingKeys, $keyParts[1]) !== false) { + return ['success' => false, 'error' => 'This key already exists']; + } + } + + // Append key to authorized_keys + $result = file_put_contents($authKeysFile, $publicKey . "\n", FILE_APPEND | LOCK_EX); + + if ($result === false) { + return ['success' => false, 'error' => 'Failed to write authorized_keys file']; + } + + // Set proper permissions + chmod($authKeysFile, 0600); + chown($authKeysFile, $uid); + chgrp($authKeysFile, $gid); + + logger("SSH key added for user $username: $name"); + + return ['success' => true, 'message' => 'SSH key added successfully']; +} + +function sshDeleteKey(array $params): array +{ + $username = $params['username'] ?? ''; + $keyId = $params['key_id'] ?? ''; + + if (!validateUsername($username)) { + return ['success' => false, 'error' => 'Invalid username']; + } + + if (empty($keyId)) { + return ['success' => false, 'error' => 'Key ID is required']; + } + + $userInfo = posix_getpwnam($username); + if (!$userInfo) { + return ['success' => false, 'error' => 'User not found']; + } + + $uid = $userInfo['uid']; + $gid = $userInfo['gid']; + $authKeysFile = $userInfo['dir'] . '/.ssh/authorized_keys'; + + if (!file_exists($authKeysFile)) { + return ['success' => false, 'error' => 'No SSH keys found']; + } + + $lines = file($authKeysFile, FILE_IGNORE_NEW_LINES); + $newLines = []; + $found = false; + + foreach ($lines as $line) { + if (md5(trim($line)) === $keyId) { + $found = true; + continue; // Skip this key + } + $newLines[] = $line; + } + + if (!$found) { + return ['success' => false, 'error' => 'Key not found']; + } + + // Write back + file_put_contents($authKeysFile, implode("\n", $newLines) . (count($newLines) > 0 ? "\n" : "")); + chmod($authKeysFile, 0600); + chown($authKeysFile, $uid); + chgrp($authKeysFile, $gid); + + logger("SSH key deleted for user $username: $keyId"); + + return ['success' => true, 'message' => 'SSH key deleted successfully']; +} + + $socket = socket_create(AF_UNIX, SOCK_STREAM, 0); + if ($socket === false) { + logger("Failed to create socket", 'ERROR'); + exit(1); + } + + if (socket_bind($socket, SOCKET_PATH) === false) { + logger("Failed to bind socket", 'ERROR'); + exit(1); + } + + if (socket_listen($socket, 5) === false) { + logger("Failed to listen", 'ERROR'); + exit(1); + } + + chmod(SOCKET_PATH, 0660); + chown(SOCKET_PATH, 'root'); + chgrp(SOCKET_PATH, 'www-data'); + + file_put_contents(PID_FILE, getmypid()); + + logger("Jabali Agent started on " . SOCKET_PATH); + + pcntl_signal(SIGTERM, function () use ($socket) { + logger("Shutting down..."); + socket_close($socket); + @unlink(SOCKET_PATH); + @unlink(PID_FILE); + exit(0); + }); + + pcntl_signal(SIGINT, function () use ($socket) { + logger("Shutting down..."); + socket_close($socket); + @unlink(SOCKET_PATH); + @unlink(PID_FILE); + exit(0); + }); + + // Make socket non-blocking for signal handling + socket_set_nonblock($socket); + + while (true) { + pcntl_signal_dispatch(); + + $client = @socket_accept($socket); + if ($client === false) { + usleep(50000); // 50ms delay + continue; + } + + // Set short timeout for reading + socket_set_option($client, SOL_SOCKET, SO_RCVTIMEO, ['sec' => 30, 'usec' => 0]); + + $data = ''; + while (($chunk = @socket_read($client, 65536)) !== false && $chunk !== '') { + $data .= $chunk; + // Check if we have complete JSON + if (strlen($chunk) < 65536) { + break; + } + } + + if (!empty($data)) { + $request = json_decode($data, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + $response = ['success' => false, 'error' => 'Invalid JSON: ' . json_last_error_msg()]; + } else { + try { + $response = handleAction($request); + } catch (Throwable $e) { + logger("[ERROR] Exception in handleAction: " . $e->getMessage()); + $response = ['success' => false, 'error' => 'Internal error: ' . $e->getMessage()]; + } + } + + socket_write($client, json_encode($response)); + } + + socket_close($client); + } +} + + +// ============ MYSQL MANAGEMENT ============ + +function getMysqlConnection(): ?mysqli +{ + $socket = "/var/run/mysqld/mysqld.sock"; + + // Try socket connection as root + $conn = @new mysqli("localhost", "root", "", "", 0, $socket); + if ($conn->connect_error) { + // Try TCP + $conn = @new mysqli("127.0.0.1", "root", ""); + if ($conn->connect_error) { + return null; + } + } + return $conn; +} + +function getMysqlRootCredentials(): ?array +{ + // Try to read from debian.cnf first (Debian/Ubuntu) + $debianCnf = '/etc/mysql/debian.cnf'; + if (file_exists($debianCnf)) { + $content = file_get_contents($debianCnf); + if (preg_match('/user\s*=\s*(\S+)/', $content, $userMatch) && + preg_match('/password\s*=\s*(\S+)/', $content, $passMatch)) { + return ['user' => $userMatch[1], 'password' => $passMatch[1]]; + } + } + + // Try root with no password (socket auth) + return ['user' => 'root', 'password' => '']; +} + +function mysqlListDatabases(array $params): array +{ + $username = $params["username"] ?? ""; + + $conn = getMysqlConnection(); + if (!$conn) { + return ["success" => false, "error" => "Cannot connect to MySQL"]; + } + + $databases = []; + $prefix = $username . "_"; + + // Get database sizes + $sizeQuery = "SELECT table_schema AS db_name, + SUM(data_length + index_length) AS size_bytes + FROM information_schema.tables + GROUP BY table_schema"; + $sizeResult = $conn->query($sizeQuery); + $dbSizes = []; + if ($sizeResult) { + while ($row = $sizeResult->fetch_assoc()) { + $dbSizes[$row['db_name']] = (int)($row['size_bytes'] ?? 0); + } + } + + $result = $conn->query("SHOW DATABASES"); + while ($row = $result->fetch_array()) { + $dbName = $row[0]; + if (strpos($dbName, $prefix) === 0 || $username === "admin") { + $sizeBytes = $dbSizes[$dbName] ?? 0; + $databases[] = [ + "name" => $dbName, + "size_bytes" => $sizeBytes, + "size_human" => formatBytes($sizeBytes), + ]; + } + } + + $conn->close(); + return ["success" => true, "databases" => $databases]; +} + +function mysqlCreateDatabase(array $params): array +{ + $username = $params["username"] ?? ""; + $database = $params["database"] ?? ""; + + if (!validateUsername($username)) { + return ["success" => false, "error" => "Invalid username"]; + } + + // Check if already prefixed + $prefix = $username . "_"; + $cleanDb = preg_replace("/[^a-zA-Z0-9_]/", "", $database); + if (strpos($cleanDb, $prefix) === 0) { + $dbName = $cleanDb; + } else { + $dbName = $prefix . $cleanDb; + } + + if (strlen($dbName) > 64) { + return ["success" => false, "error" => "Database name too long"]; + } + + $conn = getMysqlConnection(); + if (!$conn) { + return ["success" => false, "error" => "Cannot connect to MySQL"]; + } + + $dbName = $conn->real_escape_string($dbName); + + if (!$conn->query("CREATE DATABASE IF NOT EXISTS `$dbName` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")) { + $error = $conn->error; + $conn->close(); + return ["success" => false, "error" => "Failed to create database: $error"]; + } + + $conn->close(); + logger("Created MySQL database: $dbName for user $username"); + return ["success" => true, "message" => "Database created", "database" => $dbName]; +} + +function mysqlDeleteDatabase(array $params): array +{ + $username = $params["username"] ?? ""; + $database = $params["database"] ?? ""; + + if (!validateUsername($username)) { + return ["success" => false, "error" => "Invalid username"]; + } + + $prefix = $username . "_"; + if (strpos($database, $prefix) !== 0 && $username !== "admin") { + return ["success" => false, "error" => "Access denied"]; + } + + $conn = getMysqlConnection(); + if (!$conn) { + return ["success" => false, "error" => "Cannot connect to MySQL"]; + } + + $dbName = $conn->real_escape_string($database); + + if (!$conn->query("DROP DATABASE IF EXISTS `$dbName`")) { + $error = $conn->error; + $conn->close(); + return ["success" => false, "error" => "Failed to delete database: $error"]; + } + + $conn->close(); + logger("Deleted MySQL database: $dbName for user $username"); + return ["success" => true, "message" => "Database deleted"]; +} + +function mysqlListUsers(array $params): array +{ + $username = $params["username"] ?? ""; + + $conn = getMysqlConnection(); + if (!$conn) { + return ["success" => false, "error" => "Cannot connect to MySQL"]; + } + + $users = []; + $prefix = $username . "_"; + + $result = $conn->query("SELECT User, Host FROM mysql.user WHERE User != 'root' AND User != '' AND User NOT LIKE 'mysql.%'"); + while ($row = $result->fetch_assoc()) { + if (strpos($row["User"], $prefix) === 0 || $username === "admin") { + $users[] = ["user" => $row["User"], "host" => $row["Host"]]; + } + } + + $conn->close(); + return ["success" => true, "users" => $users]; +} + +function mysqlCreateUser(array $params): array +{ + $username = $params["username"] ?? ""; + $dbUser = $params["db_user"] ?? ""; + $password = $params["password"] ?? ""; + $host = $params["host"] ?? "localhost"; + + if (!validateUsername($username)) { + return ["success" => false, "error" => "Invalid username"]; + } + + if (empty($password) || strlen($password) < 8) { + return ["success" => false, "error" => "Password must be at least 8 characters"]; + } + + // Check if already prefixed + $prefix = $username . "_"; + $cleanDbUser = preg_replace("/[^a-zA-Z0-9_]/", "", $dbUser); + if (strpos($cleanDbUser, $prefix) === 0) { + $dbUserName = $cleanDbUser; + } else { + $dbUserName = $prefix . $cleanDbUser; + } + + if (strlen($dbUserName) > 32) { + return ["success" => false, "error" => "Username too long"]; + } + + $conn = getMysqlConnection(); + if (!$conn) { + return ["success" => false, "error" => "Cannot connect to MySQL"]; + } + + $dbUserName = $conn->real_escape_string($dbUserName); + $host = $conn->real_escape_string($host); + $password = $conn->real_escape_string($password); + + if (!$conn->query("CREATE USER '$dbUserName'@'$host' IDENTIFIED BY '$password'")) { + $error = $conn->error; + $conn->close(); + return ["success" => false, "error" => "Failed to create user: $error"]; + } + + $conn->close(); + logger("Created MySQL user: $dbUserName@$host for user $username"); + return ["success" => true, "message" => "User created", "db_user" => $dbUserName]; +} + +function mysqlDeleteUser(array $params): array +{ + $username = $params["username"] ?? ""; + $dbUser = $params["db_user"] ?? ""; + $host = $params["host"] ?? "localhost"; + + if (!validateUsername($username)) { + return ["success" => false, "error" => "Invalid username"]; + } + + $prefix = $username . "_"; + if (strpos($dbUser, $prefix) !== 0 && $username !== "admin") { + return ["success" => false, "error" => "Access denied"]; + } + + $conn = getMysqlConnection(); + if (!$conn) { + return ["success" => false, "error" => "Cannot connect to MySQL"]; + } + + $dbUser = $conn->real_escape_string($dbUser); + $host = $conn->real_escape_string($host); + + if (!$conn->query("DROP USER IF EXISTS '$dbUser'@'$host'")) { + $error = $conn->error; + $conn->close(); + return ["success" => false, "error" => "Failed to delete user: $error"]; + } + + $conn->close(); + logger("Deleted MySQL user: $dbUser@$host for user $username"); + return ["success" => true, "message" => "User deleted"]; +} + +function mysqlChangePassword(array $params): array +{ + $username = $params["username"] ?? ""; + $dbUser = $params["db_user"] ?? ""; + $password = $params["password"] ?? ""; + $host = $params["host"] ?? "localhost"; + + if (!validateUsername($username)) { + return ["success" => false, "error" => "Invalid username"]; + } + + $prefix = $username . "_"; + if (strpos($dbUser, $prefix) !== 0 && $username !== "admin") { + return ["success" => false, "error" => "Access denied"]; + } + + if (empty($password) || strlen($password) < 8) { + return ["success" => false, "error" => "Password must be at least 8 characters"]; + } + + $conn = getMysqlConnection(); + if (!$conn) { + return ["success" => false, "error" => "Cannot connect to MySQL"]; + } + + $dbUser = $conn->real_escape_string($dbUser); + $host = $conn->real_escape_string($host); + $password = $conn->real_escape_string($password); + + if (!$conn->query("ALTER USER '$dbUser'@'$host' IDENTIFIED BY '$password'")) { + $error = $conn->error; + $conn->close(); + return ["success" => false, "error" => "Failed to change password: $error"]; + } + + $conn->query("FLUSH PRIVILEGES"); + $conn->close(); + logger("Changed password for MySQL user: $dbUser@$host"); + return ["success" => true, "message" => "Password changed"]; +} + +function mysqlGrantPrivileges(array $params): array +{ + $username = $params["username"] ?? ""; + $dbUser = $params["db_user"] ?? ""; + $database = $params["database"] ?? ""; + $privileges = $params["privileges"] ?? ["ALL"]; + $host = $params["host"] ?? "localhost"; + + if (!validateUsername($username)) { + return ["success" => false, "error" => "Invalid username"]; + } + + $prefix = $username . "_"; + if ($username !== "admin") { + if (strpos($dbUser, $prefix) !== 0) { + return ["success" => false, "error" => "Access denied to user"]; + } + if (strpos($database, $prefix) !== 0 && $database !== "*") { + return ["success" => false, "error" => "Access denied to database"]; + } + } + + $conn = getMysqlConnection(); + if (!$conn) { + return ["success" => false, "error" => "Cannot connect to MySQL"]; + } + + $dbUser = $conn->real_escape_string($dbUser); + $host = $conn->real_escape_string($host); + $database = $conn->real_escape_string($database); + + $allowedPrivs = ["ALL", "SELECT", "INSERT", "UPDATE", "DELETE", "CREATE", "DROP", "INDEX", "ALTER", "EXECUTE", "CREATE VIEW", "SHOW VIEW"]; + $privList = []; + foreach ($privileges as $priv) { + $priv = strtoupper(trim($priv)); + if (in_array($priv, $allowedPrivs)) { + $privList[] = $priv; + } + } + + if (empty($privList)) { + $privList = ["ALL"]; + } + + $privString = implode(", ", $privList); + $dbTarget = $database === "*" ? "*.*" : "`$database`.*"; + + if (!$conn->query("GRANT $privString ON $dbTarget TO '$dbUser'@'$host'")) { + $error = $conn->error; + $conn->close(); + return ["success" => false, "error" => "Failed to grant privileges: $error"]; + } + + $conn->query("FLUSH PRIVILEGES"); + $conn->close(); + logger("Granted $privString on $database to $dbUser@$host"); + return ["success" => true, "message" => "Privileges granted"]; +} + +function mysqlRevokePrivileges(array $params): array +{ + $username = $params["username"] ?? ""; + $dbUser = $params["db_user"] ?? ""; + $database = $params["database"] ?? ""; + $host = $params["host"] ?? "localhost"; + + if (!validateUsername($username)) { + return ["success" => false, "error" => "Invalid username"]; + } + + $prefix = $username . "_"; + if ($username !== "admin") { + if (strpos($dbUser, $prefix) !== 0) { + return ["success" => false, "error" => "Access denied to user"]; + } + } + + $conn = getMysqlConnection(); + if (!$conn) { + return ["success" => false, "error" => "Cannot connect to MySQL"]; + } + + $dbUser = $conn->real_escape_string($dbUser); + $host = $conn->real_escape_string($host); + $database = $conn->real_escape_string($database); + + $dbTarget = $database === "*" ? "*.*" : "`$database`.*"; + + if (!$conn->query("REVOKE ALL PRIVILEGES ON $dbTarget FROM '$dbUser'@'$host'")) { + $error = $conn->error; + $conn->close(); + return ["success" => false, "error" => "Failed to revoke privileges: $error"]; + } + + $conn->query("FLUSH PRIVILEGES"); + $conn->close(); + logger("Revoked privileges on $database from $dbUser@$host"); + return ["success" => true, "message" => "Privileges revoked"]; +} + +function mysqlGetPrivileges(array $params): array +{ + $username = $params["username"] ?? ""; + $dbUser = $params["db_user"] ?? ""; + $host = $params["host"] ?? "localhost"; + + if (!validateUsername($username)) { + return ["success" => false, "error" => "Invalid username"]; + +// Signal handling for graceful shutdown +$shutdown = false; + +pcntl_async_signals(true); +pcntl_signal(SIGTERM, function() use (&$shutdown) { + global $socket; + $shutdown = true; + if ($socket) { + socket_close($socket); + } + exit(0); +}); +pcntl_signal(SIGINT, function() use (&$shutdown) { + global $socket; + $shutdown = true; + if ($socket) { + socket_close($socket); + } + exit(0); +}); + +while (!$shutdown) { + // Set socket timeout so it doesn't block forever + socket_set_option($socket, SOL_SOCKET, SO_RCVTIMEO, ['sec' => 1, 'usec' => 0]); + + $client = @socket_accept($socket); + + if ($client === false) { + // Timeout or error - check if we should shutdown + if ($shutdown) break; + continue; + } + + handleConnection($client); +} + +socket_close($socket); +unlink($socketPath); + } + + $conn = getMysqlConnection(); + if (!$conn) { + return ["success" => false, "error" => "Cannot connect to MySQL"]; + } + + $dbUser = $conn->real_escape_string($dbUser); + $host = $conn->real_escape_string($host); + + $rawPrivileges = []; + $parsedPrivileges = []; + + $result = $conn->query("SHOW GRANTS FOR '$dbUser'@'$host'"); + + if ($result) { + while ($row = $result->fetch_array()) { + $grant = $row[0]; + $rawPrivileges[] = $grant; + + // Parse: GRANT SELECT, INSERT ON `db`.* TO user + // Or: GRANT ALL PRIVILEGES ON `db`.* TO user + if (preg_match('/GRANT\s+(.+?)\s+ON\s+[`"\']*([^`"\'\.\s]+)[`"\']*\.\*\s+TO/i', $grant, $matches)) { + $privsStr = trim($matches[1]); + $db = trim($matches[2], "`\'\""); + + if ($db !== "*") { + $privList = []; + if (stripos($privsStr, "ALL PRIVILEGES") !== false) { + $privList = ["ALL PRIVILEGES"]; + } elseif (stripos($privsStr, "ALL") === 0) { + $privList = ["ALL PRIVILEGES"]; + } else { + $parts = explode(",", $privsStr); + foreach ($parts as $p) { + $p = trim($p); + if (!empty($p) && stripos($p, "GRANT OPTION") === false) { + $privList[] = strtoupper($p); + } + } + } + + if (!empty($privList)) { + $parsedPrivileges[] = [ + "database" => $db, + "privileges" => $privList + ]; + } + } + } + } + } + + $conn->close(); + return ["success" => true, "privileges" => $rawPrivileges, "parsed" => $parsedPrivileges]; +} + +main(); + +// Domain Management Functions + +function domainCreate(array $params): array +{ + $username = $params['username'] ?? ''; + $domain = $params['domain'] ?? ''; + + if (!validateUsername($username)) { + return ['success' => false, 'error' => 'Invalid username']; + } + + // Validate domain format + $domain = strtolower(trim($domain)); + if (!preg_match('/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*\.[a-z]{2,}$/', $domain)) { + return ['success' => false, 'error' => 'Invalid domain format']; + } + + // Check if domain already exists + $vhostFile = "/etc/nginx/sites-available/{$domain}.conf"; + if (file_exists($vhostFile)) { + return ['success' => false, 'error' => 'Domain already exists']; + } + + // Get user info + $userInfo = posix_getpwnam($username); + if (!$userInfo) { + return ['success' => false, 'error' => 'User not found']; + } + + $userHome = $userInfo['dir']; + $uid = $userInfo['uid']; + $gid = $userInfo['gid']; + + ensureJabaliNginxIncludeFiles(); + + // Create domain directories + $domainRoot = "{$userHome}/domains/{$domain}"; + $publicHtml = "{$domainRoot}/public_html"; + $logs = "{$domainRoot}/logs"; + + $dirs = [$domainRoot, $publicHtml, $logs]; + foreach ($dirs as $dir) { + if (!is_dir($dir)) { + if (!mkdir($dir, 0755, true)) { + return ['success' => false, 'error' => "Failed to create directory: {$dir}"]; + } + chown($dir, $uid); + chgrp($dir, $gid); + } + } + + // Create default index.html + $indexContent = ' + + + + + Welcome to ' . $domain . ' + + + +
+

Welcome!

+

Your website is ready. Upload your files to get started.

+ ' . $domain . ' +
+ +'; + + $indexFile = "{$publicHtml}/index.html"; + if (!file_exists($indexFile)) { + file_put_contents($indexFile, $indexContent); + chown($indexFile, $uid); + chgrp($indexFile, $gid); + } + + // Ensure FPM pool exists for this user (don't reload - caller handles that) + createFpmPool($username, false); + $fpmSocket = getFpmSocketPath($username); + + // Create Nginx virtual host configuration + $vhostContent = generateNginxVhost($domain, $publicHtml, $logs, $fpmSocket); + + if (file_put_contents($vhostFile, $vhostContent) === false) { + return ['success' => false, 'error' => 'Failed to create virtual host configuration']; + } + + // Enable the site (create symlink) + $enableCmd = "ln -sf " . escapeshellarg($vhostFile) . " /etc/nginx/sites-enabled/" . escapeshellarg("{$domain}.conf") . " 2>&1"; + exec($enableCmd, $output, $returnCode); + if ($returnCode !== 0) { + unlink($vhostFile); + return ['success' => false, 'error' => 'Failed to enable site: ' . implode("\n", $output)]; + } + + // Set ACLs for Nginx to access files + exec("setfacl -m u:www-data:x " . escapeshellarg($userHome)); + exec("setfacl -m u:www-data:x " . escapeshellarg("{$userHome}/domains")); + exec("setfacl -m u:www-data:rx " . escapeshellarg($domainRoot)); + exec("setfacl -R -m u:www-data:rx " . escapeshellarg($publicHtml)); + exec("setfacl -R -m u:www-data:rwx " . escapeshellarg($logs)); + exec("setfacl -R -d -m u:www-data:rx " . escapeshellarg($publicHtml)); + exec("setfacl -R -d -m u:www-data:rwx " . escapeshellarg($logs)); + + // Reload Nginx + exec("nginx -t 2>&1", $testOutput, $testCode); + if ($testCode !== 0) { + unlink($vhostFile); + unlink("/etc/nginx/sites-enabled/{$domain}.conf"); + return ['success' => false, 'error' => 'Nginx config test failed: ' . implode("\n", $testOutput)]; + } + + exec("systemctl reload nginx 2>&1", $reloadOutput, $reloadCode); + if ($reloadCode !== 0) { + return ['success' => false, 'error' => 'Site created but failed to reload Nginx: ' . implode("\n", $reloadOutput)]; + } + + // Store domain in user's domain list + $domainListFile = "{$userHome}/.domains"; + $domains = []; + if (file_exists($domainListFile)) { + $domains = json_decode(file_get_contents($domainListFile), true) ?: []; + } + $domains[$domain] = [ + 'created' => date('Y-m-d H:i:s'), + 'document_root' => $publicHtml, + 'ssl' => false + ]; + file_put_contents($domainListFile, json_encode($domains, JSON_PRETTY_PRINT)); + chown($domainListFile, $uid); + chgrp($domainListFile, $gid); + + return [ + 'success' => true, + 'domain' => $domain, + 'document_root' => $publicHtml, + 'message' => "Domain {$domain} created successfully" + ]; +} + +function updateVhostServerNames(string $vhostFile, callable $mutator): array +{ + if (!file_exists($vhostFile)) { + return ['success' => false, 'error' => 'Domain configuration not found']; + } + + $original = file_get_contents($vhostFile); + if ($original === false) { + return ['success' => false, 'error' => 'Failed to read virtual host configuration']; + } + + $updated = preg_replace_callback('/server_name\\s+([^;]+);/i', function ($matches) use ($mutator) { + $names = preg_split('/\\s+/', trim($matches[1])); + $names = array_values(array_filter($names)); + $names = $mutator($names); + $names = array_values(array_unique($names)); + + return ' server_name ' . implode(' ', $names) . ';'; + }, $original, -1, $count); + + if ($count === 0 || $updated === null) { + return ['success' => false, 'error' => 'Failed to update server_name entries']; + } + + if (file_put_contents($vhostFile, $updated) === false) { + return ['success' => false, 'error' => 'Failed to write virtual host configuration']; + } + + exec("nginx -t 2>&1", $testOutput, $testCode); + if ($testCode !== 0) { + // rollback + file_put_contents($vhostFile, $original); + return ['success' => false, 'error' => 'Nginx config test failed: ' . implode("\n", $testOutput)]; + } + + exec("systemctl reload nginx 2>&1", $reloadOutput, $reloadCode); + if ($reloadCode !== 0) { + return ['success' => false, 'error' => 'Failed to reload Nginx: ' . implode("\n", $reloadOutput)]; + } + + return ['success' => true]; +} + +function databaseGetVariables(array $params): array +{ + $names = $params['names'] ?? []; + if (!is_array($names) || $names === []) { + return ['success' => false, 'error' => 'No variables requested']; + } + + $safeNames = []; + foreach ($names as $name) { + if (preg_match('/^[a-zA-Z0-9_]+$/', (string) $name)) { + $safeNames[] = $name; + } + } + + if ($safeNames === []) { + return ['success' => false, 'error' => 'No valid variable names']; + } + + $inList = implode("','", array_map(fn($name) => str_replace("'", "\\'", (string) $name), $safeNames)); + $query = "SHOW VARIABLES WHERE Variable_name IN ('{$inList}')"; + $command = 'mysql --batch --skip-column-names -e ' . escapeshellarg($query) . ' 2>&1'; + + exec($command, $output, $code); + + if ($code !== 0) { + return ['success' => false, 'error' => implode("\n", $output)]; + } + + $variables = []; + foreach ($output as $line) { + $line = trim($line); + if ($line === '') { + continue; + } + $parts = explode("\t", $line, 2); + $name = $parts[0] ?? null; + if ($name === null || $name === '') { + continue; + } + $variables[] = [ + 'name' => $name, + 'value' => $parts[1] ?? '', + ]; + } + + return ['success' => true, 'variables' => $variables]; +} + +function databaseSetGlobal(array $params): array +{ + $name = $params['name'] ?? ''; + $value = (string) ($params['value'] ?? ''); + + if (!preg_match('/^[a-zA-Z0-9_]+$/', $name)) { + return ['success' => false, 'error' => 'Invalid variable name']; + } + + $escapedValue = addslashes($value); + $query = "SET GLOBAL {$name} = '{$escapedValue}'"; + $command = 'mysql -e ' . escapeshellarg($query) . ' 2>&1'; + + exec($command, $output, $code); + + if ($code !== 0) { + return ['success' => false, 'error' => implode("\n", $output)]; + } + + return ['success' => true]; +} + +function domainAliasAdd(array $params): array +{ + $username = $params['username'] ?? ''; + $domain = strtolower(trim($params['domain'] ?? '')); + $alias = strtolower(trim($params['alias'] ?? '')); + + if (!validateUsername($username)) { + return ['success' => false, 'error' => 'Invalid username']; + } + + if (!validateDomain($domain) || !validateDomain($alias)) { + return ['success' => false, 'error' => 'Invalid domain format']; + } + + if ($alias === $domain || $alias === "www.{$domain}") { + return ['success' => false, 'error' => 'Alias cannot match the primary domain']; + } + + $vhostFile = "/etc/nginx/sites-available/{$domain}.conf"; + + foreach (glob('/etc/nginx/sites-available/*.conf') as $file) { + if (!is_readable($file)) { + continue; + } + $content = file_get_contents($file); + if ($content === false) { + continue; + } + if (preg_match('/server_name\\s+[^;]*\\b' . preg_quote($alias, '/') . '\\b/i', $content) || + preg_match('/server_name\\s+[^;]*\\b' . preg_quote("www.{$alias}", '/') . '\\b/i', $content)) { + if ($file !== $vhostFile) { + return ['success' => false, 'error' => 'Alias already exists on another domain']; + } + } + } + + $result = updateVhostServerNames($vhostFile, function (array $names) use ($alias) { + if (!in_array($alias, $names, true)) { + $names[] = $alias; + } + $wwwAlias = "www.{$alias}"; + if (!in_array($wwwAlias, $names, true)) { + $names[] = $wwwAlias; + } + + return $names; + }); + + if (!($result['success'] ?? false)) { + return $result; + } + + return ['success' => true, 'message' => "Alias {$alias} added to {$domain}"]; +} + +function domainAliasRemove(array $params): array +{ + $username = $params['username'] ?? ''; + $domain = strtolower(trim($params['domain'] ?? '')); + $alias = strtolower(trim($params['alias'] ?? '')); + + if (!validateUsername($username)) { + return ['success' => false, 'error' => 'Invalid username']; + } + + if (!validateDomain($domain) || !validateDomain($alias)) { + return ['success' => false, 'error' => 'Invalid domain format']; + } + + $vhostFile = "/etc/nginx/sites-available/{$domain}.conf"; + + $result = updateVhostServerNames($vhostFile, function (array $names) use ($alias) { + $wwwAlias = "www.{$alias}"; + return array_values(array_filter($names, function ($name) use ($alias, $wwwAlias) { + return $name !== $alias && $name !== $wwwAlias; + })); + }); + + if (!($result['success'] ?? false)) { + return $result; + } + + return ['success' => true, 'message' => "Alias {$alias} removed from {$domain}"]; +} + +function domainEnsureErrorPages(array $params): array +{ + $username = $params['username'] ?? ''; + $domain = strtolower(trim($params['domain'] ?? '')); + + if (!validateUsername($username)) { + return ['success' => false, 'error' => 'Invalid username']; + } + + if (!validateDomain($domain)) { + return ['success' => false, 'error' => 'Invalid domain format']; + } + + $vhostFile = "/etc/nginx/sites-available/{$domain}.conf"; + if (!file_exists($vhostFile)) { + return ['success' => false, 'error' => 'Domain configuration not found']; + } + + $content = file_get_contents($vhostFile); + if ($content === false) { + return ['success' => false, 'error' => 'Failed to read virtual host configuration']; + } + + if (strpos($content, 'error_page 404') !== false) { + return ['success' => true, 'message' => 'Error page directives already configured']; + } + + $snippet = << false, 'error' => 'Failed to inject error page directives']; + } + + if (file_put_contents($vhostFile, $updated) === false) { + return ['success' => false, 'error' => 'Failed to write virtual host configuration']; + } + + exec("nginx -t 2>&1", $testOutput, $testCode); + if ($testCode !== 0) { + file_put_contents($vhostFile, $content); + return ['success' => false, 'error' => 'Nginx config test failed: ' . implode("\n", $testOutput)]; + } + + exec("systemctl reload nginx 2>&1", $reloadOutput, $reloadCode); + if ($reloadCode !== 0) { + return ['success' => false, 'error' => 'Failed to reload Nginx: ' . implode("\n", $reloadOutput)]; + } + + return ['success' => true, 'message' => 'Error pages enabled']; +} + +function domainDelete(array $params): array +{ + $username = $params['username'] ?? ''; + $domain = $params['domain'] ?? ''; + $deleteFiles = $params['delete_files'] ?? false; + + if (!validateUsername($username)) { + return ['success' => false, 'error' => 'Invalid username']; + } + + $domain = strtolower(trim($domain)); + + // Verify ownership + $userInfo = posix_getpwnam($username); + if (!$userInfo) { + return ['success' => false, 'error' => 'User not found']; + } + + $userHome = $userInfo['dir']; + $domainListFile = "{$userHome}/.domains"; + + // Check if user owns this domain + $domains = []; + if (file_exists($domainListFile)) { + $domains = json_decode(file_get_contents($domainListFile), true) ?: []; + } + + // Admin can delete any domain, regular users only their own + if ($username !== 'admin' && !isset($domains[$domain])) { + return ['success' => false, 'error' => 'Domain not found or access denied']; + } + + // Disable and remove the site + $vhostFile = "/etc/nginx/sites-available/{$domain}.conf"; + + if (file_exists("/etc/nginx/sites-enabled/{$domain}.conf")) { + exec("rm -f /etc/nginx/sites-enabled/" . escapeshellarg("{$domain}.conf") . " 2>&1", $output, $returnCode); + } + + if (file_exists($vhostFile)) { + unlink($vhostFile); + } + + // Reload Nginx + exec("nginx -t && systemctl reload nginx 2>&1"); + + // Delete DNS zone file and remove from named.conf.local + $zoneFile = "/etc/bind/zones/db.{$domain}"; + if (file_exists($zoneFile)) { + unlink($zoneFile); + logger("Deleted DNS zone file for {$domain}"); + } + + $namedConf = '/etc/bind/named.conf.local'; + if (file_exists($namedConf)) { + $content = file_get_contents($namedConf); + // Use [\s\S]*? to match any chars including newlines (handles nested braces like allow-transfer { none; }) + $pattern = '/\n?zone\s+"' . preg_quote($domain, '/') . '"\s*\{[\s\S]*?\n\};\n?/'; + $newContent = preg_replace($pattern, "\n", $content); + if ($newContent !== $content) { + file_put_contents($namedConf, trim($newContent) . "\n"); + exec('systemctl reload bind9 2>&1 || systemctl reload named 2>&1'); + logger("Removed DNS zone entry and reloaded BIND for {$domain}"); + } + } + + // Delete domain files if requested + if ($deleteFiles) { + $domainRoot = "{$userHome}/domains/{$domain}"; + if (is_dir($domainRoot)) { + exec("rm -rf " . escapeshellarg($domainRoot)); + } + } + + // Remove from domain list + unset($domains[$domain]); + file_put_contents($domainListFile, json_encode($domains, JSON_PRETTY_PRINT)); + + return [ + 'success' => true, + 'message' => "Domain {$domain} deleted successfully" + ]; +} + +function domainList(array $params): array +{ + $username = $params['username'] ?? ''; + + if (!validateUsername($username)) { + return ['success' => false, 'error' => 'Invalid username']; + } + + $userInfo = posix_getpwnam($username); + if (!$userInfo) { + return ['success' => false, 'error' => 'User not found']; + } + + $userHome = $userInfo['dir']; + $domainListFile = "{$userHome}/.domains"; + + $domains = []; + if (file_exists($domainListFile)) { + $domains = json_decode(file_get_contents($domainListFile), true) ?: []; + } + + // Enrich with additional info + $result = []; + foreach ($domains as $domain => $info) { + $docRoot = $info['document_root'] ?? "{$userHome}/domains/{$domain}/public_html"; + $result[] = [ + 'domain' => $domain, + 'document_root' => $docRoot, + 'created' => $info['created'] ?? 'Unknown', + 'ssl' => $info['ssl'] ?? false, + 'enabled' => file_exists("/etc/nginx/sites-enabled/{$domain}.conf") + ]; + } + + return ['success' => true, 'domains' => $result]; +} + +function domainToggle(array $params): array +{ + $username = $params['username'] ?? ''; + $domain = $params['domain'] ?? ''; + $enable = $params['enable'] ?? true; + + if (!validateUsername($username)) { + return ['success' => false, 'error' => 'Invalid username']; + } + + $domain = strtolower(trim($domain)); + $vhostFile = "/etc/nginx/sites-available/{$domain}.conf"; + + if (!file_exists($vhostFile)) { + return ['success' => false, 'error' => 'Domain configuration not found']; + } + + if ($enable) { + exec("ln -sf /etc/nginx/sites-available/" . escapeshellarg("{$domain}.conf") . " /etc/nginx/sites-enabled/" . escapeshellarg("{$domain}.conf") . " 2>&1", $output, $returnCode); + $action = 'enabled'; + } else { + exec("rm -f /etc/nginx/sites-enabled/" . escapeshellarg("{$domain}.conf") . " 2>&1", $output, $returnCode); + $action = 'disabled'; + } + + exec("nginx -t && systemctl reload nginx 2>&1"); + + return ['success' => true, 'message' => "Domain {$domain} {$action}"]; +} + +function domainSetRedirects(array $params): array +{ + $username = $params['username'] ?? ''; + $domain = $params['domain'] ?? ''; + $redirects = $params['redirects'] ?? []; + + if (!validateUsername($username)) { + return ['success' => false, 'error' => 'Invalid username']; + } + + $domain = strtolower(trim($domain)); + $vhostFile = "/etc/nginx/sites-available/{$domain}.conf"; + + if (!file_exists($vhostFile)) { + return ['success' => false, 'error' => 'Domain configuration not found']; + } + + $vhostContent = file_get_contents($vhostFile); + + // Remove any existing Jabali redirect markers and content (from ALL server blocks) + $vhostContent = preg_replace('/\n\s*# JABALI_REDIRECTS_START.*?# JABALI_REDIRECTS_END\n/s', "\n", $vhostContent); + + // Check if there's a domain-wide redirect + $domainWideRedirect = null; + $pageRedirects = []; + + foreach ($redirects as $redirect) { + $source = $redirect['source'] ?? ''; + $destination = $redirect['destination'] ?? ''; + $type = $redirect['type'] ?? '301'; + $wildcard = $redirect['wildcard'] ?? false; + + if (empty($source) || empty($destination)) { + continue; + } + + // Sanitize destination URL + $destination = filter_var($destination, FILTER_SANITIZE_URL); + $type = in_array($type, ['301', '302']) ? $type : '301'; + + if ($source === '/*' || $source === '*' || $source === '/') { + // Domain-wide redirect + $domainWideRedirect = [ + 'destination' => $destination, + 'type' => $type, + ]; + } else { + // Sanitize source path + $source = preg_replace('/[^a-zA-Z0-9\/_\-\.\*]/', '', $source); + $pageRedirects[] = [ + 'source' => $source, + 'destination' => $destination, + 'type' => $type, + 'wildcard' => $wildcard, + ]; + } + } + + // Build redirect configuration + $redirectConfig = "\n # JABALI_REDIRECTS_START\n"; + + if ($domainWideRedirect) { + // For domain-wide redirect, use return at server level (before location blocks) + $redirectConfig .= " # Domain-wide redirect - all requests go to: {$domainWideRedirect['destination']}\n"; + $redirectConfig .= " return {$domainWideRedirect['type']} {$domainWideRedirect['destination']}\$request_uri;\n"; + } else { + // Add page-specific redirects using rewrite rules (works before location matching) + foreach ($pageRedirects as $redirect) { + $source = $redirect['source']; + $destination = $redirect['destination']; + $type = $redirect['type']; + $wildcard = $redirect['wildcard']; + + $redirectConfig .= " # Redirect: {$source}\n"; + if ($wildcard) { + // Wildcard: match path and everything after + $escapedSource = preg_quote($source, '/'); + $redirectConfig .= " rewrite ^{$escapedSource}(.*)\$ {$destination}\$1 permanent;\n"; + } else { + // Exact match + $escapedSource = preg_quote($source, '/'); + $flag = $type === '301' ? 'permanent' : 'redirect'; + $redirectConfig .= " rewrite ^{$escapedSource}\$ {$destination} {$flag};\n"; + } + } + } + + $redirectConfig .= " # JABALI_REDIRECTS_END\n"; + + // Insert redirect config after EVERY server_name line (both HTTP and HTTPS blocks) + if (!empty($redirects)) { + $pattern = '/(server_name\s+' . preg_quote($domain, '/') . '[^;]*;)/'; + $vhostContent = preg_replace( + $pattern, + "$1\n{$redirectConfig}", + $vhostContent + ); + } + + // Write updated vhost + file_put_contents($vhostFile, $vhostContent); + + // Test and reload nginx + exec("nginx -t 2>&1", $testOutput, $testCode); + if ($testCode !== 0) { + // Restore original file on failure + return ['success' => false, 'error' => 'Nginx configuration test failed: ' . implode("\n", $testOutput)]; + } + + exec("systemctl reload nginx 2>&1", $reloadOutput, $reloadCode); + + return [ + 'success' => $reloadCode === 0, + 'message' => 'Redirects updated successfully', + 'redirects_count' => count($redirects), + ]; +} + +function domainSetHotlinkProtection(array $params): array +{ + $username = $params['username'] ?? ''; + $domain = $params['domain'] ?? ''; + $enabled = $params['enabled'] ?? false; + $allowedDomains = $params['allowed_domains'] ?? []; + $blockBlankReferrer = $params['block_blank_referrer'] ?? true; + $protectedExtensions = $params['protected_extensions'] ?? ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'mp4', 'mp3', 'pdf']; + $redirectUrl = $params['redirect_url'] ?? null; + + if (!validateUsername($username)) { + return ['success' => false, 'error' => 'Invalid username']; + } + + $domain = strtolower(trim($domain)); + $vhostFile = "/etc/nginx/sites-available/{$domain}.conf"; + + if (!file_exists($vhostFile)) { + return ['success' => false, 'error' => 'Domain configuration not found']; + } + + $vhostContent = file_get_contents($vhostFile); + + // Remove any existing hotlink protection markers + $vhostContent = preg_replace('/\n\s*# JABALI_HOTLINK_START.*?# JABALI_HOTLINK_END\n/s', "\n", $vhostContent); + + if ($enabled && !empty($protectedExtensions)) { + // Build the hotlink protection config + $extensionsPattern = implode('|', array_map('preg_quote', $protectedExtensions)); + + // Build valid referers list using nginx valid_referers syntax + // server_names matches the server's own names from server_name directive + // Use regex patterns (~pattern) to match domains in the referer URL + $validReferers = ['server_names']; + + // Add the domain itself (exact and with subdomains) + // Referer format: https://domain.com/path or https://sub.domain.com/path + $escapedDomain = str_replace('.', '\.', $domain); + $validReferers[] = "~{$escapedDomain}"; + + // Add user-specified allowed domains + foreach ($allowedDomains as $allowedDomain) { + $allowedDomain = trim($allowedDomain); + if (!empty($allowedDomain)) { + $escapedAllowed = str_replace('.', '\.', $allowedDomain); + $validReferers[] = "~{$escapedAllowed}"; + } + } + + // Handle blank referrer + if (!$blockBlankReferrer) { + array_unshift($validReferers, 'none'); + } + + $validReferersStr = implode(' ', $validReferers); + + // Determine the action for invalid referrers + if (!empty($redirectUrl)) { + $action = "return 301 {$redirectUrl}"; + } else { + $action = "return 403"; + } + + $hotlinkConfig = <<&1", $testOutput, $testCode); + if ($testCode !== 0) { + return ['success' => false, 'error' => 'Nginx configuration test failed: ' . implode("\n", $testOutput)]; + } + + exec("systemctl reload nginx 2>&1", $reloadOutput, $reloadCode); + + return [ + 'success' => $reloadCode === 0, + 'message' => $enabled ? 'Hotlink protection enabled' : 'Hotlink protection disabled', + ]; +} + +function domainSetDirectoryIndex(array $params): array +{ + $username = $params['username'] ?? ''; + $domain = $params['domain'] ?? ''; + $directoryIndex = $params['directory_index'] ?? 'index.php index.html'; + + if (!validateUsername($username)) { + return ['success' => false, 'error' => 'Invalid username']; + } + + $domain = strtolower(trim($domain)); + $vhostFile = "/etc/nginx/sites-available/{$domain}.conf"; + + if (!file_exists($vhostFile)) { + return ['success' => false, 'error' => 'Domain configuration not found']; + } + + // Validate and sanitize directory index value + $validIndexFiles = ['index.php', 'index.html', 'index.htm']; + $indexParts = preg_split('/\s+/', trim($directoryIndex)); + $sanitizedParts = []; + foreach ($indexParts as $part) { + if (in_array($part, $validIndexFiles)) { + $sanitizedParts[] = $part; + } + } + + if (empty($sanitizedParts)) { + $sanitizedParts = ['index.php', 'index.html']; + } + + $newIndex = implode(' ', $sanitizedParts); + + $vhostContent = file_get_contents($vhostFile); + + // Replace the existing index directive + $vhostContent = preg_replace( + '/(\s+index\s+)[^;]+;/', + "$1{$newIndex};", + $vhostContent + ); + + // Write updated vhost + file_put_contents($vhostFile, $vhostContent); + + // Test and reload nginx + exec("nginx -t 2>&1", $testOutput, $testCode); + if ($testCode !== 0) { + return ['success' => false, 'error' => 'Nginx configuration test failed: ' . implode("\n", $testOutput)]; + } + + exec("systemctl reload nginx 2>&1", $reloadOutput, $reloadCode); + + return [ + 'success' => $reloadCode === 0, + 'message' => 'Directory index updated', + 'directory_index' => $newIndex, + ]; +} + +/** + * List protected directories for a domain + */ +function domainListProtectedDirs(array $params): array +{ + $username = $params['username'] ?? ''; + $domain = $params['domain'] ?? ''; + + if (!validateUsername($username)) { + return ['success' => false, 'error' => 'Invalid username']; + } + + $domain = strtolower(trim($domain)); + $userInfo = posix_getpwnam($username); + if (!$userInfo) { + return ['success' => false, 'error' => 'User not found']; + } + + $userHome = $userInfo['dir']; + $protectedDir = "{$userHome}/domains/{$domain}/.protected"; + + $directories = []; + + if (is_dir($protectedDir)) { + $files = glob("{$protectedDir}/*.conf"); + foreach ($files as $confFile) { + $config = parse_ini_file($confFile); + if ($config === false) { + continue; + } + + $path = $config['path'] ?? ''; + $name = $config['name'] ?? 'Protected Area'; + $htpasswdFile = "{$protectedDir}/" . md5($path) . ".htpasswd"; + + $users = []; + if (file_exists($htpasswdFile)) { + $lines = file($htpasswdFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + foreach ($lines as $line) { + if (strpos($line, ':') !== false) { + [$authUser] = explode(':', $line, 2); + $users[] = $authUser; + } + } + } + + $directories[] = [ + 'path' => $path, + 'name' => $name, + 'users' => $users, + 'users_count' => count($users), + ]; + } + } + + return [ + 'success' => true, + 'directories' => $directories, + ]; +} + +/** + * Add protection to a directory + */ +function domainAddProtectedDir(array $params): array +{ + $username = $params['username'] ?? ''; + $domain = $params['domain'] ?? ''; + $path = $params['path'] ?? ''; + $name = $params['name'] ?? 'Protected Area'; + $authUsername = $params['auth_username'] ?? ''; + $authPassword = $params['auth_password'] ?? ''; + + if (!validateUsername($username)) { + return ['success' => false, 'error' => 'Invalid username']; + } + + if (empty($path) || empty($authUsername) || empty($authPassword)) { + return ['success' => false, 'error' => 'Missing required parameters']; + } + + // Validate path - must start with / and not contain .. + $path = '/' . ltrim($path, '/'); + if (strpos($path, '..') !== false || !preg_match('#^/[a-zA-Z0-9/_-]*$#', $path)) { + return ['success' => false, 'error' => 'Invalid path']; + } + + // Validate auth username + if (!preg_match('/^[a-zA-Z0-9_]+$/', $authUsername)) { + return ['success' => false, 'error' => 'Invalid username format']; + } + + $domain = strtolower(trim($domain)); + $userInfo = posix_getpwnam($username); + if (!$userInfo) { + return ['success' => false, 'error' => 'User not found']; + } + + $userHome = $userInfo['dir']; + $uid = $userInfo['uid']; + $gid = $userInfo['gid']; + $domainRoot = "{$userHome}/domains/{$domain}"; + $protectedDir = "{$domainRoot}/.protected"; + + if (!is_dir($domainRoot)) { + return ['success' => false, 'error' => 'Domain not found']; + } + + // Create .protected directory + if (!is_dir($protectedDir)) { + mkdir($protectedDir, 0750, true); + chown($protectedDir, $uid); + chgrp($protectedDir, $gid); + // Set ACL for nginx to read + exec("setfacl -m u:www-data:rx " . escapeshellarg($protectedDir)); + } + + $pathHash = md5($path); + $confFile = "{$protectedDir}/{$pathHash}.conf"; + $htpasswdFile = "{$protectedDir}/{$pathHash}.htpasswd"; + + // Check if already protected + if (file_exists($confFile)) { + return ['success' => false, 'error' => 'This directory is already protected']; + } + + // Create config file + $configContent = "path=\"{$path}\"\nname=\"{$name}\"\n"; + file_put_contents($confFile, $configContent); + chown($confFile, $uid); + chgrp($confFile, $gid); + chmod($confFile, 0640); + + // Create htpasswd file with first user + $hashedPassword = password_hash($authPassword, PASSWORD_BCRYPT); + file_put_contents($htpasswdFile, "{$authUsername}:{$hashedPassword}\n"); + chown($htpasswdFile, $uid); + chgrp($htpasswdFile, $gid); + chmod($htpasswdFile, 0640); + // Set ACL for nginx to read htpasswd + exec("setfacl -m u:www-data:r " . escapeshellarg($htpasswdFile)); + + // Update nginx configuration + $result = updateNginxProtectedDirs($domain, $domainRoot); + if (!$result['success']) { + // Rollback + unlink($confFile); + unlink($htpasswdFile); + return $result; + } + + return [ + 'success' => true, + 'message' => "Directory {$path} is now protected", + ]; +} + +/** + * Remove protection from a directory + */ +function domainRemoveProtectedDir(array $params): array +{ + $username = $params['username'] ?? ''; + $domain = $params['domain'] ?? ''; + $path = $params['path'] ?? ''; + + if (!validateUsername($username)) { + return ['success' => false, 'error' => 'Invalid username']; + } + + $domain = strtolower(trim($domain)); + $userInfo = posix_getpwnam($username); + if (!$userInfo) { + return ['success' => false, 'error' => 'User not found']; + } + + $userHome = $userInfo['dir']; + $domainRoot = "{$userHome}/domains/{$domain}"; + $protectedDir = "{$domainRoot}/.protected"; + + $pathHash = md5($path); + $confFile = "{$protectedDir}/{$pathHash}.conf"; + $htpasswdFile = "{$protectedDir}/{$pathHash}.htpasswd"; + + if (!file_exists($confFile)) { + return ['success' => false, 'error' => 'Protected directory not found']; + } + + // Remove files + if (file_exists($confFile)) { + unlink($confFile); + } + if (file_exists($htpasswdFile)) { + unlink($htpasswdFile); + } + + // Update nginx configuration + $result = updateNginxProtectedDirs($domain, $domainRoot); + + return [ + 'success' => true, + 'message' => "Protection removed from {$path}", + ]; +} + +/** + * Add a user to a protected directory + */ +function domainAddProtectedDirUser(array $params): array +{ + $username = $params['username'] ?? ''; + $domain = $params['domain'] ?? ''; + $path = $params['path'] ?? ''; + $authUsername = $params['auth_username'] ?? ''; + $authPassword = $params['auth_password'] ?? ''; + + if (!validateUsername($username)) { + return ['success' => false, 'error' => 'Invalid username']; + } + + if (empty($authUsername) || empty($authPassword)) { + return ['success' => false, 'error' => 'Username and password are required']; + } + + if (!preg_match('/^[a-zA-Z0-9_]+$/', $authUsername)) { + return ['success' => false, 'error' => 'Invalid username format']; + } + + $domain = strtolower(trim($domain)); + $userInfo = posix_getpwnam($username); + if (!$userInfo) { + return ['success' => false, 'error' => 'User not found']; + } + + $userHome = $userInfo['dir']; + $uid = $userInfo['uid']; + $gid = $userInfo['gid']; + $domainRoot = "{$userHome}/domains/{$domain}"; + $protectedDir = "{$domainRoot}/.protected"; + + $pathHash = md5($path); + $htpasswdFile = "{$protectedDir}/{$pathHash}.htpasswd"; + + if (!file_exists($htpasswdFile)) { + return ['success' => false, 'error' => 'Protected directory not found']; + } + + // Check if user already exists + $lines = file($htpasswdFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + foreach ($lines as $line) { + if (strpos($line, ':') !== false) { + [$existingUser] = explode(':', $line, 2); + if ($existingUser === $authUsername) { + return ['success' => false, 'error' => 'User already exists']; + } + } + } + + // Add new user + $hashedPassword = password_hash($authPassword, PASSWORD_BCRYPT); + file_put_contents($htpasswdFile, "{$authUsername}:{$hashedPassword}\n", FILE_APPEND); + chown($htpasswdFile, $uid); + chgrp($htpasswdFile, $gid); + + return [ + 'success' => true, + 'message' => "User {$authUsername} added", + ]; +} + +/** + * Remove a user from a protected directory + */ +function domainRemoveProtectedDirUser(array $params): array +{ + $username = $params['username'] ?? ''; + $domain = $params['domain'] ?? ''; + $path = $params['path'] ?? ''; + $authUsername = $params['auth_username'] ?? ''; + + if (!validateUsername($username)) { + return ['success' => false, 'error' => 'Invalid username']; + } + + $domain = strtolower(trim($domain)); + $userInfo = posix_getpwnam($username); + if (!$userInfo) { + return ['success' => false, 'error' => 'User not found']; + } + + $userHome = $userInfo['dir']; + $uid = $userInfo['uid']; + $gid = $userInfo['gid']; + $domainRoot = "{$userHome}/domains/{$domain}"; + $protectedDir = "{$domainRoot}/.protected"; + + $pathHash = md5($path); + $htpasswdFile = "{$protectedDir}/{$pathHash}.htpasswd"; + + if (!file_exists($htpasswdFile)) { + return ['success' => false, 'error' => 'Protected directory not found']; + } + + // Remove user from htpasswd file + $lines = file($htpasswdFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + $newLines = []; + $found = false; + + foreach ($lines as $line) { + if (strpos($line, ':') !== false) { + [$existingUser] = explode(':', $line, 2); + if ($existingUser === $authUsername) { + $found = true; + continue; + } + } + $newLines[] = $line; + } + + if (!$found) { + return ['success' => false, 'error' => 'User not found']; + } + + file_put_contents($htpasswdFile, implode("\n", $newLines) . (count($newLines) > 0 ? "\n" : "")); + chown($htpasswdFile, $uid); + chgrp($htpasswdFile, $gid); + + return [ + 'success' => true, + 'message' => "User {$authUsername} removed", + ]; +} + +/** + * Update nginx configuration for protected directories + */ +function updateNginxProtectedDirs(string $domain, string $domainRoot): array +{ + $vhostFile = "/etc/nginx/sites-available/{$domain}.conf"; + if (!file_exists($vhostFile)) { + return ['success' => false, 'error' => 'Domain nginx configuration not found']; + } + + $protectedDir = "{$domainRoot}/.protected"; + $vhostContent = file_get_contents($vhostFile); + + // Remove existing protected directory blocks + $vhostContent = preg_replace('/\n\s*# Protected directory:[^\n]*\n\s*location[^}]+auth_basic[^}]+\}\n?/s', '', $vhostContent); + + // Build new protected directory blocks + $protectedBlocks = ''; + if (is_dir($protectedDir)) { + $files = glob("{$protectedDir}/*.conf"); + foreach ($files as $confFile) { + $config = parse_ini_file($confFile); + if ($config === false) { + continue; + } + + $path = $config['path'] ?? ''; + $name = addslashes($config['name'] ?? 'Protected Area'); + $pathHash = md5($path); + $htpasswdFile = "{$protectedDir}/{$pathHash}.htpasswd"; + + if (!file_exists($htpasswdFile)) { + continue; + } + + // Escape path for nginx location + $escapedPath = preg_quote($path, '/'); + + $protectedBlocks .= <<