From 27a9dfa84dc12d307bf93a10ba40f486ee3b050d Mon Sep 17 00:00:00 2001 From: codex Date: Mon, 2 Feb 2026 03:11:45 +0200 Subject: [PATCH] Initial commit --- .editorconfig | 18 + .env.example | 62 + .git-authorized-committers | 1 + .git-authorized-remotes | 3 + .gitattributes | 11 + .githooks/pre-commit | 58 + .gitignore | 24 + .mcp.json | 36 + AGENT.md | 2019 ++ AGENTS.md | 25 + CONTEXT.md | 35 + DECISIONS.md | 8 + LICENSE | 75 + Makefile | 122 + README.md | 160 + TODO.md | 8 + VERSION | 1 + app/Actions/Fortify/CreateNewUser.php | 35 + .../Fortify/PasswordValidationRules.php | 25 + app/Actions/Fortify/ResetUserPassword.php | 29 + app/Actions/Fortify/UpdateUserPassword.php | 32 + .../Fortify/UpdateUserProfileInformation.php | 56 + app/Actions/Jetstream/DeleteUser.php | 19 + app/Console/Commands/CheckDiskQuotas.php | 79 + app/Console/Commands/CheckFail2banAlerts.php | 81 + app/Console/Commands/CheckFileIntegrity.php | 251 + app/Console/Commands/Jabali/DomainCommand.php | 105 + .../Commands/Jabali/ImportProcessCommand.php | 389 + .../Jabali/MigrateRedisUsersCommand.php | 124 + .../Commands/Jabali/SslCheckCommand.php | 463 + .../Commands/Jabali/UpgradeCommand.php | 808 + app/Console/Commands/Jabali/UserCommand.php | 106 + app/Console/Commands/ManageGitProtection.php | 233 + app/Console/Commands/NotifyHighLoad.php | 66 + app/Console/Commands/NotifyServiceHealth.php | 75 + app/Console/Commands/NotifySshLogin.php | 25 + app/Console/Commands/RunBackupSchedules.php | 282 + app/Console/Commands/RunUserCronJobs.php | 121 + app/Console/Commands/SyncMailboxQuotas.php | 58 + app/Filament/Admin/Pages/Auth/Login.php | 58 + .../Admin/Pages/Auth/TwoFactorChallenge.php | 188 + app/Filament/Admin/Pages/AutomationApi.php | 152 + app/Filament/Admin/Pages/Backups.php | 1610 + app/Filament/Admin/Pages/CpanelMigration.php | 2417 ++ app/Filament/Admin/Pages/Dashboard.php | 105 + app/Filament/Admin/Pages/DatabaseTuning.php | 154 + app/Filament/Admin/Pages/DnsZones.php | 975 + app/Filament/Admin/Pages/EmailLogs.php | 395 + app/Filament/Admin/Pages/EmailQueue.php | 176 + app/Filament/Admin/Pages/IpAddresses.php | 330 + app/Filament/Admin/Pages/Migration.php | 85 + app/Filament/Admin/Pages/PhpManager.php | 291 + app/Filament/Admin/Pages/Security.php | 2689 ++ app/Filament/Admin/Pages/ServerSettings.php | 1482 + app/Filament/Admin/Pages/ServerStatus.php | 363 + app/Filament/Admin/Pages/ServerUpdates.php | 318 + app/Filament/Admin/Pages/Services.php | 328 + app/Filament/Admin/Pages/SslManager.php | 589 + app/Filament/Admin/Pages/Waf.php | 689 + app/Filament/Admin/Pages/WhmMigration.php | 1267 + .../GeoBlockRules/GeoBlockRuleResource.php | 60 + .../Pages/CreateGeoBlockRule.php | 34 + .../GeoBlockRules/Pages/EditGeoBlockRule.php | 48 + .../GeoBlockRules/Pages/ListGeoBlockRules.php | 141 + .../Schemas/GeoBlockRuleForm.php | 45 + .../Tables/GeoBlockRulesTable.php | 54 + .../HostingPackageResource.php | 60 + .../Pages/CreateHostingPackage.php | 13 + .../Pages/EditHostingPackage.php | 21 + .../Pages/ListHostingPackages.php | 21 + .../Schemas/HostingPackageForm.php | 66 + .../Tables/HostingPackagesTable.php | 57 + .../Resources/Users/Pages/CreateUser.php | 108 + .../Admin/Resources/Users/Pages/EditUser.php | 140 + .../Admin/Resources/Users/Pages/ListUsers.php | 19 + .../Resources/Users/Schemas/UserForm.php | 178 + .../Resources/Users/Tables/UsersTable.php | 225 + .../Admin/Resources/Users/UserResource.php | 65 + .../Pages/CreateWebhookEndpoint.php | 13 + .../Pages/EditWebhookEndpoint.php | 21 + .../Pages/ListWebhookEndpoints.php | 21 + .../Schemas/WebhookEndpointForm.php | 53 + .../Tables/WebhookEndpointsTable.php | 100 + .../WebhookEndpointResource.php | 60 + .../Admin/Widgets/AdminStatsOverview.php | 51 + .../Widgets/Dashboard/RecentActivityTable.php | 68 + .../Admin/Widgets/DashboardStatsWidget.php | 54 + .../Admin/Widgets/DiskUsageWidget.php | 25 + .../Admin/Widgets/DnsPendingAddsTable.php | 92 + .../Widgets/DomainIpAssignmentsTable.php | 321 + app/Filament/Admin/Widgets/MemoryWidget.php | 51 + .../Admin/Widgets/NetworkTableWidget.php | 88 + .../Admin/Widgets/ProcessesWidget.php | 25 + app/Filament/Admin/Widgets/QuickActions.php | 67 + .../Admin/Widgets/Security/AuditLogsTable.php | 83 + .../Admin/Widgets/Security/BannedIpsTable.php | 134 + .../Widgets/Security/Fail2banLogsTable.php | 70 + .../Admin/Widgets/Security/JailsTable.php | 121 + .../Widgets/Security/LynisResultsTable.php | 66 + .../Widgets/Security/NiktoResultsTable.php | 66 + .../Security/QuarantinedFilesTable.php | 94 + .../Admin/Widgets/Security/ThreatsTable.php | 59 + .../Widgets/Security/WpscanResultsTable.php | 72 + .../Admin/Widgets/ServerChartsWidget.php | 481 + .../Admin/Widgets/ServerInfoWidget.php | 16 + .../Admin/Widgets/ServerStatsOverview.php | 116 + .../Admin/Widgets/ServicesTableWidget.php | 199 + .../Widgets/Settings/DatabaseTuningTable.php | 151 + .../Admin/Widgets/Settings/DnssecTable.php | 217 + .../Widgets/Settings/NotificationLogTable.php | 164 + .../Admin/Widgets/SslStatsOverview.php | 68 + .../Admin/Widgets/SystemInfoTableWidget.php | 216 + .../Admin/Widgets/WhmAccountConfigTable.php | 119 + .../Admin/Widgets/WhmAccountsTable.php | 146 + .../Admin/Widgets/WhmMigrationStatusTable.php | 182 + .../InitialsAvatarProvider.php | 54 + app/Filament/Jabali/Pages/Auth/Login.php | 96 + .../Jabali/Pages/Auth/TwoFactorChallenge.php | 173 + app/Filament/Jabali/Pages/Backups.php | 1264 + app/Filament/Jabali/Pages/CdnIntegration.php | 169 + app/Filament/Jabali/Pages/CpanelMigration.php | 1734 ++ app/Filament/Jabali/Pages/CronJobs.php | 505 + app/Filament/Jabali/Pages/Dashboard.php | 62 + app/Filament/Jabali/Pages/Databases.php | 985 + app/Filament/Jabali/Pages/DnsRecords.php | 882 + app/Filament/Jabali/Pages/Domains.php | 1010 + app/Filament/Jabali/Pages/Email.php | 1658 + app/Filament/Jabali/Pages/Files.php | 1215 + app/Filament/Jabali/Pages/GitDeployment.php | 314 + .../Jabali/Pages/ImageOptimization.php | 155 + app/Filament/Jabali/Pages/Logs.php | 312 + app/Filament/Jabali/Pages/MailingLists.php | 123 + app/Filament/Jabali/Pages/PhpSettings.php | 322 + app/Filament/Jabali/Pages/PostgreSQL.php | 315 + .../Jabali/Pages/ProtectedDirectories.php | 319 + app/Filament/Jabali/Pages/SshKeys.php | 464 + app/Filament/Jabali/Pages/Ssl.php | 502 + app/Filament/Jabali/Pages/WordPress.php | 1458 + .../Jabali/Widgets/DiskUsageWidget.php | 60 + .../Jabali/Widgets/DnsPendingAddsTable.php | 92 + app/Filament/Jabali/Widgets/DomainsWidget.php | 94 + .../Jabali/Widgets/EmailStatsWidget.php | 72 + .../Jabali/Widgets/MailboxesWidget.php | 86 + .../Jabali/Widgets/QuickActionsWidget.php | 72 + .../Jabali/Widgets/RecentBackupsWidget.php | 109 + app/Filament/Jabali/Widgets/StatsOverview.php | 105 + app/Filament/Jabali/Widgets/TrashTable.php | 198 + .../Controllers/AutoDiscoverController.php | 244 + app/Http/Controllers/AutoconfigController.php | 304 + .../Controllers/AutomationApiController.php | 128 + .../Controllers/BackupDownloadController.php | 120 + app/Http/Controllers/Controller.php | 8 + app/Http/Controllers/GitWebhookController.php | 28 + .../Controllers/ImpersonationController.php | 120 + app/Http/Controllers/LanguageController.php | 31 + .../Middleware/RedirectAdminFromUserPanel.php | 32 + app/Http/Middleware/SecurityHeaders.php | 48 + app/Http/Middleware/SetLocale.php | 95 + app/Jobs/IndexRemoteBackups.php | 162 + app/Jobs/IssueSslCertificate.php | 117 + app/Jobs/RunCpanelRestore.php | 127 + app/Jobs/RunGitDeployment.php | 70 + app/Jobs/RunServerBackup.php | 274 + app/Jobs/RunWhmMigrationBatch.php | 410 + app/Listeners/AuthEventListener.php | 74 + app/Livewire/Admin/SecurityWafPanel.php | 693 + app/Livewire/Admin/WafWhitelistTable.php | 257 + app/Livewire/DatabaseUsersTable.php | 316 + app/Models/AuditLog.php | 193 + app/Models/Autoresponder.php | 66 + app/Models/Backup.php | 262 + app/Models/BackupDestination.php | 116 + app/Models/BackupRestore.php | 182 + app/Models/BackupSchedule.php | 215 + app/Models/CloudflareZone.php | 49 + app/Models/CronJob.php | 77 + app/Models/DnsRecord.php | 29 + app/Models/DnsSetting.php | 43 + app/Models/Domain.php | 154 + app/Models/DomainAlias.php | 21 + app/Models/DomainHotlinkSetting.php | 52 + app/Models/DomainRedirect.php | 44 + app/Models/EmailDomain.php | 77 + app/Models/EmailForwarder.php | 44 + app/Models/GeoBlockRule.php | 24 + app/Models/GitDeployment.php | 40 + app/Models/HostingPackage.php | 34 + app/Models/ImpersonationToken.php | 87 + app/Models/Mailbox.php | 164 + app/Models/MysqlCredential.php | 26 + app/Models/NotificationLog.php | 86 + app/Models/ServerImport.php | 92 + app/Models/ServerImportAccount.php | 90 + app/Models/ServerProcess.php | 78 + app/Models/Setting.php | 49 + app/Models/SslCertificate.php | 148 + app/Models/User.php | 232 + app/Models/UserRemoteBackup.php | 75 + app/Models/UserSetting.php | 65 + app/Models/WebhookEndpoint.php | 29 + app/Observers/DomainObserver.php | 198 + app/Providers/AppServiceProvider.php | 34 + app/Providers/Filament/AdminPanelProvider.php | 205 + .../Filament/JabaliPanelProvider.php | 227 + app/Providers/FortifyServiceProvider.php | 48 + app/Providers/JetstreamServiceProvider.php | 43 + app/Services/AdminNotificationService.php | 185 + app/Services/Agent/AgentClient.php | 1409 + app/Services/JabaliSshKey.php | 153 + app/Services/Migration/CpanelApiService.php | 1306 + .../Migration/MigrationDnsSyncService.php | 291 + app/Services/Migration/WhmApiService.php | 962 + .../Migration/WhmMigrationStatusStore.php | 149 + app/Services/SysstatMetrics.php | 606 + app/Services/System/GeoBlockService.php | 30 + app/Services/System/LinuxUserService.php | 74 + .../System/MailRoutingSyncService.php | 68 + app/View/Components/AppLayout.php | 17 + app/View/Components/GuestLayout.php | 17 + artisan | 18 + bin/jabali | 3280 ++ bin/jabali-agent | 25218 ++++++++++++++++ bin/jabali-health-monitor | 568 + bin/publish-github | 50 + bin/screenshot | 65 + boost.json | 9 + bootstrap/app.php | 19 + bootstrap/cache/.gitignore | 2 + bootstrap/providers.php | 7 + composer.json | 99 + composer.lock | 11350 +++++++ config/app.php | 126 + config/auth.php | 119 + config/cache.php | 117 + config/database.php | 183 + config/filament.php | 120 + config/file-manager.php | 61 + config/filesystems.php | 45 + config/fortify.php | 159 + config/jabali.php | 8 + config/jetstream.php | 81 + config/languages.php | 80 + config/livewire.php | 277 + config/logging.php | 132 + config/mail.php | 118 + config/queue.php | 129 + config/sanctum.php | 84 + config/services.php | 38 + config/session.php | 217 + database/.gitignore | 1 + database/factories/DomainFactory.php | 51 + database/factories/UserFactory.php | 95 + .../0001_01_01_000000_create_users_table.php | 51 + .../0001_01_01_000001_create_cache_table.php | 35 + .../0001_01_01_000002_create_jobs_table.php | 57 + ..._09_000000_add_username_to_users_table.php | 24 + ...000001_add_admin_fields_to_users_table.php | 23 + ...0003_add_home_directory_to_users_table.php | 24 + ...00004_add_sftp_password_to_users_table.php | 22 + ..._add_two_factor_columns_to_users_table.php | 42 + ...54_create_personal_access_tokens_table.php | 33 + ...2026_01_10_000000_create_domains_table.php | 25 + ..._01_10_000001_create_dns_records_table.php | 28 + ...01_10_000002_create_dns_settings_table.php | 32 + ..._051822_create_mysql_credentials_table.php | 26 + ...1_11_025915_create_email_domains_table.php | 38 + ...26_01_11_030025_create_mailboxes_table.php | 42 + ...026_01_11_053000_create_settings_table.php | 31 + ...00_add_password_encrypted_to_mailboxes.php | 28 + ...1_060000_add_maildir_path_to_mailboxes.php | 30 + ...6_01_11_070000_create_audit_logs_table.php | 35 + ..._11_175121_create_server_imports_table.php | 61 + ...2227_create_impersonation_tokens_table.php | 35 + ...1_210000_create_ssl_certificates_table.php | 50 + ...20000_create_backup_destinations_table.php | 34 + ...2026_01_11_220001_create_backups_table.php | 50 + ...1_220002_create_backup_schedules_table.php | 47 + ...11_220003_create_backup_restores_table.php | 40 + ...add_metadata_to_backup_schedules_table.php | 25 + ...14015_add_schedule_id_to_backups_table.php | 26 + ...2_020000_create_email_forwarders_table.php | 28 + ...26_01_12_100000_create_cron_jobs_table.php | 33 + ...2_143327_add_disk_quota_to_users_table.php | 29 + ...2_211558_create_server_processes_table.php | 29 + ...01_13_003208_add_locale_to_users_table.php | 28 + ...7_000001_create_domain_redirects_table.php | 29 + ...2_create_domain_hotlink_settings_table.php | 27 + ...3_add_directory_index_to_domains_table.php | 22 + ..._014430_create_notification_logs_table.php | 36 + ...dd_page_cache_enabled_to_domains_table.php | 28 + ..._19_130838_create_autoresponders_table.php | 34 + ...31929_create_user_remote_backups_table.php | 38 + ...0000_add_ip_addresses_to_domains_table.php | 23 + ...add_default_ipv6_to_dns_settings_table.php | 20 + ...1_27_050000_create_user_settings_table.php | 28 + ..._27_060000_create_domain_aliases_table.php | 28 + ...27_070000_create_git_deployments_table.php | 36 + ...7_073000_create_cloudflare_zones_table.php | 30 + ...7_080000_create_hosting_packages_table.php | 31 + ..._add_hosting_package_id_to_users_table.php | 29 + ..._081000_create_webhook_endpoints_table.php | 30 + ...27_082000_create_geo_block_rules_table.php | 27 + ..._000001_purge_resource_usage_artifacts.php | 57 + database/seeders/DatabaseSeeder.php | 25 + docs/agent-functions.yaml | 603 + docs/architecture/control-panel-blueprint.md | 122 + docs/archive-notes.md | 12 + docs/installation.md | 58 + docs/screenshots/README.md | 13 + docs/screenshots/admin-backups.png | Bin 0 -> 93727 bytes docs/screenshots/admin-dashboard.png | Bin 0 -> 92075 bytes docs/screenshots/admin-dns-zones.png | Bin 0 -> 74866 bytes docs/screenshots/admin-security.png | Bin 0 -> 82816 bytes docs/screenshots/admin-server-settings.png | Bin 0 -> 76364 bytes docs/screenshots/admin-server-status.png | Bin 0 -> 109830 bytes docs/screenshots/admin-services.png | Bin 0 -> 98077 bytes docs/screenshots/admin-ssl-manager.png | Bin 0 -> 98999 bytes docs/screenshots/admin-users.png | Bin 0 -> 101260 bytes docs/screenshots/user-backups.png | Bin 0 -> 115147 bytes docs/screenshots/user-cpanel-migration.png | Bin 0 -> 128586 bytes docs/screenshots/user-dashboard.png | Bin 0 -> 143038 bytes docs/screenshots/user-domains.png | Bin 0 -> 142869 bytes install.sh | 3844 +++ lang/ar.json | 882 + lang/en.json | 637 + lang/en/validation.php | 8 + lang/es.json | 1173 + lang/fr.json | 889 + lang/he.json | 882 + lang/pt.json | 882 + lang/ru.json | 889 + mcp-docs-server/.gitignore | 2 + mcp-docs-server/README.md | 72 + mcp-docs-server/package-lock.json | 1161 + mcp-docs-server/package.json | 19 + mcp-docs-server/src/index.ts | 333 + mcp-docs-server/tsconfig.json | 16 + package-lock.json | 3480 +++ package.json | 24 + packaging/jabali-deps/DEBIAN/control | 26 + packaging/jabali-panel/DEBIAN/control | 10 + packaging/jabali-panel/DEBIAN/postinst | 53 + packaging/jabali-panel/DEBIAN/postrm | 8 + packaging/jabali-panel/DEBIAN/prerm | 9 + .../etc/systemd/system/jabali-agent.service | 14 + .../system/jabali-health-monitor.service | 23 + .../etc/systemd/system/jabali-queue.service | 19 + phpunit.xml | 35 + postcss.config.js | 5 + public/.htaccess | 25 + public/android-chrome-192x192.png | Bin 0 -> 7134 bytes public/apple-touch-icon.png | Bin 0 -> 6527 bytes public/css/filament/filament/app.css | 2 + .../css/mwguerra/filemanager/filemanager.css | 2 + public/favicon.ico | Bin 0 -> 4286 bytes public/favicon.png | Bin 0 -> 905 bytes .../fonts/filament/filament/inter/index.css | 1 + ...er-cyrillic-ext-wght-normal-ASVAGXXE.woff2 | Bin 0 -> 25888 bytes ...er-cyrillic-ext-wght-normal-IYF56FF6.woff2 | Bin 0 -> 25960 bytes ...er-cyrillic-ext-wght-normal-XKHXBTUO.woff2 | Bin 0 -> 27284 bytes .../inter-cyrillic-wght-normal-EWLSKVKN.woff2 | Bin 0 -> 18740 bytes .../inter-cyrillic-wght-normal-JEOLYBOO.woff2 | Bin 0 -> 18748 bytes .../inter-cyrillic-wght-normal-R5CMSONN.woff2 | Bin 0 -> 17600 bytes ...inter-greek-ext-wght-normal-7GGTF7EK.woff2 | Bin 0 -> 11200 bytes ...inter-greek-ext-wght-normal-EOVOK2B5.woff2 | Bin 0 -> 11232 bytes ...inter-greek-ext-wght-normal-ZEVLMORV.woff2 | Bin 0 -> 12732 bytes .../inter-greek-wght-normal-AXVTPQD5.woff2 | Bin 0 -> 22480 bytes .../inter-greek-wght-normal-IRE366VL.woff2 | Bin 0 -> 18996 bytes .../inter-greek-wght-normal-N43DBLU2.woff2 | Bin 0 -> 19072 bytes ...inter-latin-ext-wght-normal-5SRY4DMZ.woff2 | Bin 0 -> 74328 bytes ...inter-latin-ext-wght-normal-GZCIV3NH.woff2 | Bin 0 -> 79940 bytes ...inter-latin-ext-wght-normal-HA22NDSG.woff2 | Bin 0 -> 85068 bytes .../inter-latin-wght-normal-NRMW37G5.woff2 | Bin 0 -> 48256 bytes .../inter-latin-wght-normal-O25CN4JL.woff2 | Bin 0 -> 48444 bytes .../inter-latin-wght-normal-OPIJAQLS.woff2 | Bin 0 -> 46704 bytes ...nter-vietnamese-wght-normal-CE5GGD3W.woff2 | Bin 0 -> 10252 bytes ...nter-vietnamese-wght-normal-TWG5UU7E.woff2 | Bin 0 -> 10540 bytes public/images/jabali_logo.svg | 3 + public/images/jabali_logo_dark.svg | 3 + public/images/og-image.png | Bin 0 -> 43230 bytes public/index.php | 20 + public/js/filament/actions/actions.js | 1 + public/js/filament/filament/app.js | 1 + public/js/filament/filament/echo.js | 13 + .../forms/components/checkbox-list.js | 1 + .../filament/forms/components/code-editor.js | 38 + .../filament/forms/components/color-picker.js | 1 + .../forms/components/date-time-picker.js | 1 + .../filament/forms/components/file-upload.js | 123 + .../js/filament/forms/components/key-value.js | 1 + .../forms/components/markdown-editor.js | 51 + .../filament/forms/components/rich-editor.js | 144 + public/js/filament/forms/components/select.js | 11 + public/js/filament/forms/components/slider.js | 1 + .../filament/forms/components/tags-input.js | 1 + .../js/filament/forms/components/textarea.js | 1 + .../filament/notifications/notifications.js | 1 + .../js/filament/schemas/components/actions.js | 1 + public/js/filament/schemas/components/tabs.js | 1 + .../js/filament/schemas/components/wizard.js | 1 + public/js/filament/schemas/schemas.js | 1 + public/js/filament/support/support.js | 46 + .../tables/components/columns/checkbox.js | 1 + .../tables/components/columns/select.js | 11 + .../tables/components/columns/text-input.js | 1 + .../tables/components/columns/toggle.js | 1 + public/js/filament/tables/tables.js | 1 + .../js/filament/widgets/components/chart.js | 30 + .../components/stats-overview/stat/chart.js | 22 + public/robots.txt | 2 + public/vendor/codemirror/codemirror-bundle.js | 15588 ++++++++++ public/vendor/codemirror/codemirror.css | 479 + resources/css/app.css | 9 + resources/js/app.js | 1 + resources/js/bootstrap.js | 4 + resources/js/server-charts.js | 4 + resources/markdown/policy.md | 3 + resources/markdown/terms.md | 3 + .../views/api/api-token-manager.blade.php | 169 + resources/views/api/index.blade.php | 13 + .../views/auth/confirm-password.blade.php | 28 + .../views/auth/forgot-password.blade.php | 34 + resources/views/auth/login.blade.php | 48 + resources/views/auth/register.blade.php | 60 + resources/views/auth/reset-password.blade.php | 36 + .../views/auth/two-factor-challenge.blade.php | 58 + resources/views/auth/verify-email.blade.php | 45 + .../views/components/action-message.blade.php | 10 + .../views/components/action-section.blade.php | 12 + .../components/application-logo.blade.php | 5 + .../components/application-mark.blade.php | 4 + .../authentication-card-logo.blade.php | 6 + .../components/authentication-card.blade.php | 9 + resources/views/components/banner.blade.php | 48 + resources/views/components/button.blade.php | 3 + resources/views/components/checkbox.blade.php | 1 + .../components/confirmation-modal.blade.php | 27 + .../components/confirms-password.blade.php | 46 + .../views/components/danger-button.blade.php | 3 + .../views/components/dialog-modal.blade.php | 17 + .../views/components/dropdown-link.blade.php | 1 + resources/views/components/dropdown.blade.php | 37 + .../views/components/form-section.blade.php | 24 + .../views/components/input-error.blade.php | 5 + resources/views/components/input.blade.php | 3 + resources/views/components/label.blade.php | 5 + .../components/language-switcher.blade.php | 58 + resources/views/components/modal.blade.php | 43 + resources/views/components/nav-link.blade.php | 11 + .../components/responsive-nav-link.blade.php | 11 + .../components/secondary-button.blade.php | 3 + .../views/components/section-border.blade.php | 5 + .../views/components/section-title.blade.php | 13 + .../components/switchable-team.blade.php | 21 + .../components/validation-errors.blade.php | 11 + resources/views/components/welcome.blade.php | 96 + resources/views/dashboard.blade.php | 15 + .../views/emails/team-invitation.blade.php | 23 + .../admin/columns/backup-status.blade.php | 11 + .../admin/columns/schedule-status.blade.php | 19 + .../admin/components/backup-table.blade.php | 1 + .../components/dnssec-ds-records.blade.php | 59 + .../notification-log-detail.blade.php | 79 + .../components/process-details.blade.php | 50 + .../security-recommendations.blade.php | 20 + .../admin/components/security-waf.blade.php | 1 + .../pages/auth/two-factor-challenge.blade.php | 23 + .../admin/pages/automation-api.blade.php | 30 + .../admin/pages/backups-tab-table.blade.php | 3 + .../filament/admin/pages/backups.blade.php | 5 + .../admin/pages/cpanel-migration.blade.php | 5 + .../filament/admin/pages/dashboard.blade.php | 5 + .../admin/pages/database-tuning.blade.php | 14 + .../filament/admin/pages/dns-zones.blade.php | 81 + .../filament/admin/pages/email-logs.blade.php | 5 + .../admin/pages/email-queue.blade.php | 5 + .../admin/pages/ip-addresses.blade.php | 31 + .../pages/migration-cpanel-tab.blade.php | 1 + .../admin/pages/migration-whm-tab.blade.php | 1 + .../filament/admin/pages/migration.blade.php | 3 + .../admin/pages/php-manager.blade.php | 7 + .../filament/admin/pages/security.blade.php | 5 + .../admin/pages/server-settings.blade.php | 3 + .../admin/pages/server-status.blade.php | 38 + .../pages/server-updates-output.blade.php | 13 + .../admin/pages/server-updates.blade.php | 124 + .../filament/admin/pages/services.blade.php | 19 + .../admin/pages/ssl-log-modal.blade.php | 14 + .../admin/pages/ssl-manager.blade.php | 25 + .../views/filament/admin/pages/waf.blade.php | 41 + .../pages/whm-account-config-table.blade.php | 5 + .../admin/pages/whm-accounts-table.blade.php | 4 + .../whm-migration-status-table.blade.php | 6 + .../admin/pages/whm-migration.blade.php | 5 + .../admin/widgets/dashboard-stats.blade.php | 19 + .../admin/widgets/disk-usage.blade.php | 32 + .../admin/widgets/processes.blade.php | 59 + .../admin/widgets/quick-actions.blade.php | 17 + .../admin/widgets/server-charts.blade.php | 974 + .../admin/widgets/server-info.blade.php | 23 + .../admin/widgets/ssl-stats.blade.php | 19 + resources/views/filament/brand.blade.php | 3 + .../columns/wordpress-screenshot.blade.php | 49 + .../jabali/columns/wordpress-site.blade.php | 43 + .../jabali/components/backup-table.blade.php | 1 + .../jabali/components/cron-output.blade.php | 7 + .../jabali/components/email-table.blade.php | 1 + .../jabali/components/image-viewer.blade.php | 19 + .../components/protected-dir-users.blade.php | 38 + .../components/trash-table-embed.blade.php | 3 + .../jabali/components/trash-viewer.blade.php | 86 + .../pages/auth/two-factor-challenge.blade.php | 23 + .../jabali/pages/backups-tab-table.blade.php | 3 + .../filament/jabali/pages/backups.blade.php | 5 + .../jabali/pages/cdn-integration.blade.php | 5 + .../jabali/pages/cpanel-migration.blade.php | 5 + .../filament/jabali/pages/cron-jobs.blade.php | 57 + .../filament/jabali/pages/dashboard.blade.php | 3 + .../filament/jabali/pages/databases.blade.php | 61 + .../jabali/pages/dns-records.blade.php | 95 + .../filament/jabali/pages/domains.blade.php | 18 + .../jabali/pages/email-tab-spam.blade.php | 9 + .../jabali/pages/email-tab-table.blade.php | 3 + .../filament/jabali/pages/email.blade.php | 7 + .../filament/jabali/pages/files.blade.php | 98 + .../jabali/pages/git-deployment.blade.php | 5 + .../jabali/pages/image-optimization.blade.php | 11 + .../jabali/pages/logs-tab-activity.blade.php | 35 + .../jabali/pages/logs-tab-logs.blade.php | 96 + .../jabali/pages/logs-tab-stats.blade.php | 59 + .../filament/jabali/pages/logs.blade.php | 5 + .../jabali/pages/mailing-lists.blade.php | 16 + .../jabali/pages/php-settings.blade.php | 73 + .../pages/postgresql-tab-table.blade.php | 3 + .../jabali/pages/postgresql.blade.php | 5 + .../pages/protected-directories.blade.php | 75 + .../filament/jabali/pages/ssh-keys.blade.php | 162 + .../views/filament/jabali/pages/ssl.blade.php | 21 + .../filament/jabali/pages/wordpress.blade.php | 143 + .../tables/columns/user-privileges.blade.php | 44 + .../jabali/widgets/disk-usage.blade.php | 126 + .../jabali/widgets/email-stats.blade.php | 19 + .../jabali/widgets/quick-actions.blade.php | 17 + .../jabali/widgets/stats-overview.blade.php | 19 + .../jabali/widgets/trash-table.blade.php | 3 + resources/views/layouts/app.blade.php | 45 + resources/views/layouts/guest.blade.php | 27 + .../admin/security-waf-panel.blade.php | 36 + .../admin/waf-whitelist-table.blade.php | 5 + .../livewire/database-users-table.blade.php | 5 + resources/views/navigation-menu.blade.php | 219 + resources/views/policy.blade.php | 13 + .../views/profile/delete-user-form.blade.php | 53 + ...gout-other-browser-sessions-form.blade.php | 98 + resources/views/profile/show.blade.php | 45 + .../two-factor-authentication-form.blade.php | 124 + .../profile/update-password-form.blade.php | 39 + .../update-profile-information-form.blade.php | 95 + resources/views/terms.blade.php | 13 + .../notifications.blade.php | 43 + .../components/brand.blade.php | 3 + .../components/footer.blade.php | 33 + .../filament-panels/components/logo.blade.php | 64 + .../views/vendor/filament/assets.blade.php | 19 + .../views/webmail-password-required.blade.php | 102 + resources/views/welcome.blade.php | 277 + .../wordpress/jabali-cache/jabali-cache.php | 2532 ++ .../wordpress/jabali-cache/object-cache.php | 574 + resources/wordpress/jabali-cache/readme.txt | 119 + routes/api.php | 177 + routes/console.php | 102 + routes/web.php | 166 + scripts/build-jabali-deps-deb.sh | 23 + scripts/build-jabali-panel-deb.sh | 52 + storage/app/.gitignore | 4 + storage/app/private/.gitignore | 2 + storage/app/public/.gitignore | 2 + storage/framework/.gitignore | 9 + storage/framework/cache/.gitignore | 3 + storage/framework/sessions/.gitignore | 2 + storage/framework/testing/.gitignore | 2 + storage/framework/views/.gitignore | 2 + storage/logs/.gitignore | 2 + tailwind.config.js | 23 + tests/Feature/AdminGeoBlockRulesPageTest.php | 23 + tests/Feature/AdminTwoFactorChallengeTest.php | 54 + tests/Feature/AdminUserDeletionGuardTest.php | 36 + tests/Feature/ApiTokenPermissionsTest.php | 45 + tests/Feature/AuthenticationTest.php | 68 + tests/Feature/BackupDownloadSecurityTest.php | 71 + tests/Feature/BrowserSessionsTest.php | 24 + tests/Feature/Cli/JabaliCliHelpTest.php | 37 + tests/Feature/CreateApiTokenTest.php | 39 + tests/Feature/DeleteApiTokenTest.php | 37 + tests/Feature/DiskUsageWidgetTest.php | 42 + tests/Feature/EmailVerificationTest.php | 72 + tests/Feature/ExampleTest.php | 41 + .../AdminSecurityAntivirusToggleTest.php | 86 + .../AdminSecurityFail2banStartTest.php | 92 + ...dminServerSettingsResolverTemplateTest.php | 112 + .../Filament/AdminServerStatusRefreshTest.php | 95 + .../Filament/AdminWidgetsRenderTest.php | 51 + .../Filament/DatabaseTuningPageTest.php | 60 + tests/Feature/Filament/DomainPageTest.php | 87 + tests/Feature/Filament/EmailPageTest.php | 26 + .../Filament/QuickActionsWidgetTest.php | 39 + .../Feature/Filament/UserPagesRenderTest.php | 90 + tests/Feature/Filament/ViewComponentsTest.php | 34 + .../Migration/MigrationDnsSyncServiceTest.php | 137 + tests/Feature/PasswordConfirmationTest.php | 44 + tests/Feature/PasswordResetTest.php | 94 + .../StatsOverviewDatabaseCountTest.php | 51 + .../TwoFactorAuthenticationSettingsTest.php | 76 + tests/Feature/WebmailSsoViewTest.php | 74 + tests/TestCase.php | 10 + tests/Unit/AdminUserDeleteVisibilityTest.php | 42 + tests/Unit/AgentAllowedServicesTest.php | 18 + tests/Unit/AgentResolversTest.php | 20 + tests/Unit/ClamavLightModeConfigTest.php | 30 + tests/Unit/ClamavSignatureCountingTest.php | 59 + tests/Unit/CpanelMailboxPasswordHashTest.php | 32 + tests/Unit/DnsInstallerZoneTest.php | 34 + tests/Unit/DnsPendingAddsTest.php | 135 + tests/Unit/ExampleTest.php | 16 + tests/Unit/HealthMonitorServicesTest.php | 19 + tests/Unit/ImpersonationTokenTest.php | 30 + tests/Unit/InstallPanelFpmServiceTest.php | 19 + tests/Unit/InstallerUninstallTest.php | 52 + tests/Unit/MigrationDnsSyncServiceTest.php | 74 + tests/Unit/MigrationTabsTest.php | 58 + tests/Unit/Models/DnsSettingTest.php | 49 + tests/Unit/NginxVhostRetryTest.php | 30 + tests/Unit/PublishGithubScriptTest.php | 25 + tests/Unit/ReadmeScreenshotsTest.php | 31 + tests/Unit/RoundcubeSsoMasterConfigTest.php | 22 + tests/Unit/RunCpanelRestoreTest.php | 108 + tests/Unit/ServerChartsWidgetTest.php | 34 + tests/Unit/ServerSettingsExportImportTest.php | 105 + .../Unit/ServerSettingsPhpFpmServiceTest.php | 19 + tests/Unit/ServerStatusBulkActionsTest.php | 19 + tests/Unit/Services/AgentClientTest.php | 28 + tests/Unit/SslStatsOverviewViewTest.php | 18 + tests/Unit/SysstatMetricsTest.php | 37 + tests/Unit/UpgradeCommandTest.php | 206 + tests/Unit/UserCpanelMigrationTest.php | 46 + tests/Unit/VersionFileTest.php | 20 + tests/Unit/WhmMigrationReloadTest.php | 29 + tests/Unit/WhmMigrationStatusStoreTest.php | 34 + tests/Unit/WhmMigrationVisibilityTest.php | 29 + tests/populate-demo-data | 421 + tests/take-screenshots.cjs | 183 + vite.config.js | 17 + 652 files changed, 144899 insertions(+) create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .git-authorized-committers create mode 100644 .git-authorized-remotes create mode 100644 .gitattributes create mode 100755 .githooks/pre-commit create mode 100644 .gitignore create mode 100644 .mcp.json create mode 100644 AGENT.md create mode 100644 AGENTS.md create mode 100644 CONTEXT.md create mode 100644 DECISIONS.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 TODO.md create mode 100644 VERSION create mode 100644 app/Actions/Fortify/CreateNewUser.php create mode 100644 app/Actions/Fortify/PasswordValidationRules.php create mode 100644 app/Actions/Fortify/ResetUserPassword.php create mode 100644 app/Actions/Fortify/UpdateUserPassword.php create mode 100644 app/Actions/Fortify/UpdateUserProfileInformation.php create mode 100644 app/Actions/Jetstream/DeleteUser.php create mode 100644 app/Console/Commands/CheckDiskQuotas.php create mode 100644 app/Console/Commands/CheckFail2banAlerts.php create mode 100644 app/Console/Commands/CheckFileIntegrity.php create mode 100644 app/Console/Commands/Jabali/DomainCommand.php create mode 100644 app/Console/Commands/Jabali/ImportProcessCommand.php create mode 100644 app/Console/Commands/Jabali/MigrateRedisUsersCommand.php create mode 100644 app/Console/Commands/Jabali/SslCheckCommand.php create mode 100644 app/Console/Commands/Jabali/UpgradeCommand.php create mode 100644 app/Console/Commands/Jabali/UserCommand.php create mode 100644 app/Console/Commands/ManageGitProtection.php create mode 100644 app/Console/Commands/NotifyHighLoad.php create mode 100644 app/Console/Commands/NotifyServiceHealth.php create mode 100644 app/Console/Commands/NotifySshLogin.php create mode 100644 app/Console/Commands/RunBackupSchedules.php create mode 100644 app/Console/Commands/RunUserCronJobs.php create mode 100644 app/Console/Commands/SyncMailboxQuotas.php create mode 100644 app/Filament/Admin/Pages/Auth/Login.php create mode 100644 app/Filament/Admin/Pages/Auth/TwoFactorChallenge.php create mode 100644 app/Filament/Admin/Pages/AutomationApi.php create mode 100644 app/Filament/Admin/Pages/Backups.php create mode 100644 app/Filament/Admin/Pages/CpanelMigration.php create mode 100644 app/Filament/Admin/Pages/Dashboard.php create mode 100644 app/Filament/Admin/Pages/DatabaseTuning.php create mode 100644 app/Filament/Admin/Pages/DnsZones.php create mode 100644 app/Filament/Admin/Pages/EmailLogs.php create mode 100644 app/Filament/Admin/Pages/EmailQueue.php create mode 100644 app/Filament/Admin/Pages/IpAddresses.php create mode 100644 app/Filament/Admin/Pages/Migration.php create mode 100644 app/Filament/Admin/Pages/PhpManager.php create mode 100644 app/Filament/Admin/Pages/Security.php create mode 100644 app/Filament/Admin/Pages/ServerSettings.php create mode 100644 app/Filament/Admin/Pages/ServerStatus.php create mode 100644 app/Filament/Admin/Pages/ServerUpdates.php create mode 100644 app/Filament/Admin/Pages/Services.php create mode 100644 app/Filament/Admin/Pages/SslManager.php create mode 100644 app/Filament/Admin/Pages/Waf.php create mode 100644 app/Filament/Admin/Pages/WhmMigration.php create mode 100644 app/Filament/Admin/Resources/GeoBlockRules/GeoBlockRuleResource.php create mode 100644 app/Filament/Admin/Resources/GeoBlockRules/Pages/CreateGeoBlockRule.php create mode 100644 app/Filament/Admin/Resources/GeoBlockRules/Pages/EditGeoBlockRule.php create mode 100644 app/Filament/Admin/Resources/GeoBlockRules/Pages/ListGeoBlockRules.php create mode 100644 app/Filament/Admin/Resources/GeoBlockRules/Schemas/GeoBlockRuleForm.php create mode 100644 app/Filament/Admin/Resources/GeoBlockRules/Tables/GeoBlockRulesTable.php create mode 100644 app/Filament/Admin/Resources/HostingPackages/HostingPackageResource.php create mode 100644 app/Filament/Admin/Resources/HostingPackages/Pages/CreateHostingPackage.php create mode 100644 app/Filament/Admin/Resources/HostingPackages/Pages/EditHostingPackage.php create mode 100644 app/Filament/Admin/Resources/HostingPackages/Pages/ListHostingPackages.php create mode 100644 app/Filament/Admin/Resources/HostingPackages/Schemas/HostingPackageForm.php create mode 100644 app/Filament/Admin/Resources/HostingPackages/Tables/HostingPackagesTable.php create mode 100644 app/Filament/Admin/Resources/Users/Pages/CreateUser.php create mode 100644 app/Filament/Admin/Resources/Users/Pages/EditUser.php create mode 100644 app/Filament/Admin/Resources/Users/Pages/ListUsers.php create mode 100644 app/Filament/Admin/Resources/Users/Schemas/UserForm.php create mode 100644 app/Filament/Admin/Resources/Users/Tables/UsersTable.php create mode 100644 app/Filament/Admin/Resources/Users/UserResource.php create mode 100644 app/Filament/Admin/Resources/WebhookEndpoints/Pages/CreateWebhookEndpoint.php create mode 100644 app/Filament/Admin/Resources/WebhookEndpoints/Pages/EditWebhookEndpoint.php create mode 100644 app/Filament/Admin/Resources/WebhookEndpoints/Pages/ListWebhookEndpoints.php create mode 100644 app/Filament/Admin/Resources/WebhookEndpoints/Schemas/WebhookEndpointForm.php create mode 100644 app/Filament/Admin/Resources/WebhookEndpoints/Tables/WebhookEndpointsTable.php create mode 100644 app/Filament/Admin/Resources/WebhookEndpoints/WebhookEndpointResource.php create mode 100644 app/Filament/Admin/Widgets/AdminStatsOverview.php create mode 100644 app/Filament/Admin/Widgets/Dashboard/RecentActivityTable.php create mode 100644 app/Filament/Admin/Widgets/DashboardStatsWidget.php create mode 100644 app/Filament/Admin/Widgets/DiskUsageWidget.php create mode 100644 app/Filament/Admin/Widgets/DnsPendingAddsTable.php create mode 100644 app/Filament/Admin/Widgets/DomainIpAssignmentsTable.php create mode 100644 app/Filament/Admin/Widgets/MemoryWidget.php create mode 100644 app/Filament/Admin/Widgets/NetworkTableWidget.php create mode 100644 app/Filament/Admin/Widgets/ProcessesWidget.php create mode 100644 app/Filament/Admin/Widgets/QuickActions.php create mode 100644 app/Filament/Admin/Widgets/Security/AuditLogsTable.php create mode 100644 app/Filament/Admin/Widgets/Security/BannedIpsTable.php create mode 100644 app/Filament/Admin/Widgets/Security/Fail2banLogsTable.php create mode 100644 app/Filament/Admin/Widgets/Security/JailsTable.php create mode 100644 app/Filament/Admin/Widgets/Security/LynisResultsTable.php create mode 100644 app/Filament/Admin/Widgets/Security/NiktoResultsTable.php create mode 100644 app/Filament/Admin/Widgets/Security/QuarantinedFilesTable.php create mode 100644 app/Filament/Admin/Widgets/Security/ThreatsTable.php create mode 100644 app/Filament/Admin/Widgets/Security/WpscanResultsTable.php create mode 100644 app/Filament/Admin/Widgets/ServerChartsWidget.php create mode 100644 app/Filament/Admin/Widgets/ServerInfoWidget.php create mode 100644 app/Filament/Admin/Widgets/ServerStatsOverview.php create mode 100644 app/Filament/Admin/Widgets/ServicesTableWidget.php create mode 100644 app/Filament/Admin/Widgets/Settings/DatabaseTuningTable.php create mode 100644 app/Filament/Admin/Widgets/Settings/DnssecTable.php create mode 100644 app/Filament/Admin/Widgets/Settings/NotificationLogTable.php create mode 100644 app/Filament/Admin/Widgets/SslStatsOverview.php create mode 100644 app/Filament/Admin/Widgets/SystemInfoTableWidget.php create mode 100644 app/Filament/Admin/Widgets/WhmAccountConfigTable.php create mode 100644 app/Filament/Admin/Widgets/WhmAccountsTable.php create mode 100644 app/Filament/Admin/Widgets/WhmMigrationStatusTable.php create mode 100644 app/Filament/AvatarProviders/InitialsAvatarProvider.php create mode 100644 app/Filament/Jabali/Pages/Auth/Login.php create mode 100644 app/Filament/Jabali/Pages/Auth/TwoFactorChallenge.php create mode 100644 app/Filament/Jabali/Pages/Backups.php create mode 100644 app/Filament/Jabali/Pages/CdnIntegration.php create mode 100644 app/Filament/Jabali/Pages/CpanelMigration.php create mode 100644 app/Filament/Jabali/Pages/CronJobs.php create mode 100644 app/Filament/Jabali/Pages/Dashboard.php create mode 100644 app/Filament/Jabali/Pages/Databases.php create mode 100644 app/Filament/Jabali/Pages/DnsRecords.php create mode 100644 app/Filament/Jabali/Pages/Domains.php create mode 100644 app/Filament/Jabali/Pages/Email.php create mode 100644 app/Filament/Jabali/Pages/Files.php create mode 100644 app/Filament/Jabali/Pages/GitDeployment.php create mode 100644 app/Filament/Jabali/Pages/ImageOptimization.php create mode 100644 app/Filament/Jabali/Pages/Logs.php create mode 100644 app/Filament/Jabali/Pages/MailingLists.php create mode 100644 app/Filament/Jabali/Pages/PhpSettings.php create mode 100644 app/Filament/Jabali/Pages/PostgreSQL.php create mode 100644 app/Filament/Jabali/Pages/ProtectedDirectories.php create mode 100644 app/Filament/Jabali/Pages/SshKeys.php create mode 100644 app/Filament/Jabali/Pages/Ssl.php create mode 100644 app/Filament/Jabali/Pages/WordPress.php create mode 100644 app/Filament/Jabali/Widgets/DiskUsageWidget.php create mode 100644 app/Filament/Jabali/Widgets/DnsPendingAddsTable.php create mode 100644 app/Filament/Jabali/Widgets/DomainsWidget.php create mode 100644 app/Filament/Jabali/Widgets/EmailStatsWidget.php create mode 100644 app/Filament/Jabali/Widgets/MailboxesWidget.php create mode 100644 app/Filament/Jabali/Widgets/QuickActionsWidget.php create mode 100644 app/Filament/Jabali/Widgets/RecentBackupsWidget.php create mode 100644 app/Filament/Jabali/Widgets/StatsOverview.php create mode 100644 app/Filament/Jabali/Widgets/TrashTable.php create mode 100644 app/Http/Controllers/AutoDiscoverController.php create mode 100644 app/Http/Controllers/AutoconfigController.php create mode 100644 app/Http/Controllers/AutomationApiController.php create mode 100644 app/Http/Controllers/BackupDownloadController.php create mode 100644 app/Http/Controllers/Controller.php create mode 100644 app/Http/Controllers/GitWebhookController.php create mode 100644 app/Http/Controllers/ImpersonationController.php create mode 100644 app/Http/Controllers/LanguageController.php create mode 100644 app/Http/Middleware/RedirectAdminFromUserPanel.php create mode 100644 app/Http/Middleware/SecurityHeaders.php create mode 100644 app/Http/Middleware/SetLocale.php create mode 100644 app/Jobs/IndexRemoteBackups.php create mode 100644 app/Jobs/IssueSslCertificate.php create mode 100644 app/Jobs/RunCpanelRestore.php create mode 100644 app/Jobs/RunGitDeployment.php create mode 100644 app/Jobs/RunServerBackup.php create mode 100644 app/Jobs/RunWhmMigrationBatch.php create mode 100644 app/Listeners/AuthEventListener.php create mode 100644 app/Livewire/Admin/SecurityWafPanel.php create mode 100644 app/Livewire/Admin/WafWhitelistTable.php create mode 100644 app/Livewire/DatabaseUsersTable.php create mode 100644 app/Models/AuditLog.php create mode 100644 app/Models/Autoresponder.php create mode 100644 app/Models/Backup.php create mode 100644 app/Models/BackupDestination.php create mode 100644 app/Models/BackupRestore.php create mode 100644 app/Models/BackupSchedule.php create mode 100644 app/Models/CloudflareZone.php create mode 100644 app/Models/CronJob.php create mode 100644 app/Models/DnsRecord.php create mode 100644 app/Models/DnsSetting.php create mode 100644 app/Models/Domain.php create mode 100644 app/Models/DomainAlias.php create mode 100644 app/Models/DomainHotlinkSetting.php create mode 100644 app/Models/DomainRedirect.php create mode 100644 app/Models/EmailDomain.php create mode 100644 app/Models/EmailForwarder.php create mode 100644 app/Models/GeoBlockRule.php create mode 100644 app/Models/GitDeployment.php create mode 100644 app/Models/HostingPackage.php create mode 100644 app/Models/ImpersonationToken.php create mode 100644 app/Models/Mailbox.php create mode 100644 app/Models/MysqlCredential.php create mode 100644 app/Models/NotificationLog.php create mode 100644 app/Models/ServerImport.php create mode 100644 app/Models/ServerImportAccount.php create mode 100644 app/Models/ServerProcess.php create mode 100644 app/Models/Setting.php create mode 100644 app/Models/SslCertificate.php create mode 100644 app/Models/User.php create mode 100644 app/Models/UserRemoteBackup.php create mode 100644 app/Models/UserSetting.php create mode 100644 app/Models/WebhookEndpoint.php create mode 100644 app/Observers/DomainObserver.php create mode 100644 app/Providers/AppServiceProvider.php create mode 100644 app/Providers/Filament/AdminPanelProvider.php create mode 100644 app/Providers/Filament/JabaliPanelProvider.php create mode 100644 app/Providers/FortifyServiceProvider.php create mode 100644 app/Providers/JetstreamServiceProvider.php create mode 100644 app/Services/AdminNotificationService.php create mode 100644 app/Services/Agent/AgentClient.php create mode 100644 app/Services/JabaliSshKey.php create mode 100644 app/Services/Migration/CpanelApiService.php create mode 100644 app/Services/Migration/MigrationDnsSyncService.php create mode 100644 app/Services/Migration/WhmApiService.php create mode 100644 app/Services/Migration/WhmMigrationStatusStore.php create mode 100644 app/Services/SysstatMetrics.php create mode 100644 app/Services/System/GeoBlockService.php create mode 100644 app/Services/System/LinuxUserService.php create mode 100644 app/Services/System/MailRoutingSyncService.php create mode 100644 app/View/Components/AppLayout.php create mode 100644 app/View/Components/GuestLayout.php create mode 100755 artisan create mode 100755 bin/jabali create mode 100755 bin/jabali-agent create mode 100755 bin/jabali-health-monitor create mode 100755 bin/publish-github create mode 100755 bin/screenshot create mode 100644 boost.json create mode 100644 bootstrap/app.php create mode 100755 bootstrap/cache/.gitignore create mode 100644 bootstrap/providers.php create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 config/app.php create mode 100644 config/auth.php create mode 100644 config/cache.php create mode 100644 config/database.php create mode 100644 config/filament.php create mode 100644 config/file-manager.php create mode 100644 config/filesystems.php create mode 100644 config/fortify.php create mode 100644 config/jabali.php create mode 100644 config/jetstream.php create mode 100644 config/languages.php create mode 100644 config/livewire.php create mode 100644 config/logging.php create mode 100644 config/mail.php create mode 100644 config/queue.php create mode 100644 config/sanctum.php create mode 100644 config/services.php create mode 100644 config/session.php create mode 100644 database/.gitignore create mode 100644 database/factories/DomainFactory.php create mode 100644 database/factories/UserFactory.php create mode 100644 database/migrations/0001_01_01_000000_create_users_table.php create mode 100644 database/migrations/0001_01_01_000001_create_cache_table.php create mode 100644 database/migrations/0001_01_01_000002_create_jobs_table.php create mode 100644 database/migrations/2025_01_09_000000_add_username_to_users_table.php create mode 100644 database/migrations/2025_01_09_000001_add_admin_fields_to_users_table.php create mode 100644 database/migrations/2025_01_09_000003_add_home_directory_to_users_table.php create mode 100644 database/migrations/2025_01_09_000004_add_sftp_password_to_users_table.php create mode 100644 database/migrations/2026_01_09_161943_add_two_factor_columns_to_users_table.php create mode 100644 database/migrations/2026_01_09_161954_create_personal_access_tokens_table.php create mode 100644 database/migrations/2026_01_10_000000_create_domains_table.php create mode 100644 database/migrations/2026_01_10_000001_create_dns_records_table.php create mode 100644 database/migrations/2026_01_10_000002_create_dns_settings_table.php create mode 100644 database/migrations/2026_01_10_051822_create_mysql_credentials_table.php create mode 100644 database/migrations/2026_01_11_025915_create_email_domains_table.php create mode 100644 database/migrations/2026_01_11_030025_create_mailboxes_table.php create mode 100644 database/migrations/2026_01_11_053000_create_settings_table.php create mode 100644 database/migrations/2026_01_11_054000_add_password_encrypted_to_mailboxes.php create mode 100644 database/migrations/2026_01_11_060000_add_maildir_path_to_mailboxes.php create mode 100644 database/migrations/2026_01_11_070000_create_audit_logs_table.php create mode 100644 database/migrations/2026_01_11_175121_create_server_imports_table.php create mode 100644 database/migrations/2026_01_11_202227_create_impersonation_tokens_table.php create mode 100644 database/migrations/2026_01_11_210000_create_ssl_certificates_table.php create mode 100644 database/migrations/2026_01_11_220000_create_backup_destinations_table.php create mode 100644 database/migrations/2026_01_11_220001_create_backups_table.php create mode 100644 database/migrations/2026_01_11_220002_create_backup_schedules_table.php create mode 100644 database/migrations/2026_01_11_220003_create_backup_restores_table.php create mode 100644 database/migrations/2026_01_12_012346_add_metadata_to_backup_schedules_table.php create mode 100644 database/migrations/2026_01_12_014015_add_schedule_id_to_backups_table.php create mode 100644 database/migrations/2026_01_12_020000_create_email_forwarders_table.php create mode 100644 database/migrations/2026_01_12_100000_create_cron_jobs_table.php create mode 100644 database/migrations/2026_01_12_143327_add_disk_quota_to_users_table.php create mode 100644 database/migrations/2026_01_12_211558_create_server_processes_table.php create mode 100644 database/migrations/2026_01_13_003208_add_locale_to_users_table.php create mode 100644 database/migrations/2026_01_17_000001_create_domain_redirects_table.php create mode 100644 database/migrations/2026_01_17_000002_create_domain_hotlink_settings_table.php create mode 100644 database/migrations/2026_01_17_000003_add_directory_index_to_domains_table.php create mode 100644 database/migrations/2026_01_18_014430_create_notification_logs_table.php create mode 100644 database/migrations/2026_01_18_230907_add_page_cache_enabled_to_domains_table.php create mode 100644 database/migrations/2026_01_19_130838_create_autoresponders_table.php create mode 100644 database/migrations/2026_01_19_231929_create_user_remote_backups_table.php create mode 100644 database/migrations/2026_01_20_000000_add_ip_addresses_to_domains_table.php create mode 100644 database/migrations/2026_01_20_000001_add_default_ipv6_to_dns_settings_table.php create mode 100644 database/migrations/2026_01_27_050000_create_user_settings_table.php create mode 100644 database/migrations/2026_01_27_060000_create_domain_aliases_table.php create mode 100644 database/migrations/2026_01_27_070000_create_git_deployments_table.php create mode 100644 database/migrations/2026_01_27_073000_create_cloudflare_zones_table.php create mode 100644 database/migrations/2026_01_27_080000_create_hosting_packages_table.php create mode 100644 database/migrations/2026_01_27_080500_add_hosting_package_id_to_users_table.php create mode 100644 database/migrations/2026_01_27_081000_create_webhook_endpoints_table.php create mode 100644 database/migrations/2026_01_27_082000_create_geo_block_rules_table.php create mode 100644 database/migrations/2026_01_28_000001_purge_resource_usage_artifacts.php create mode 100644 database/seeders/DatabaseSeeder.php create mode 100644 docs/agent-functions.yaml create mode 100644 docs/architecture/control-panel-blueprint.md create mode 100644 docs/archive-notes.md create mode 100644 docs/installation.md create mode 100644 docs/screenshots/README.md create mode 100644 docs/screenshots/admin-backups.png create mode 100644 docs/screenshots/admin-dashboard.png create mode 100644 docs/screenshots/admin-dns-zones.png create mode 100644 docs/screenshots/admin-security.png create mode 100644 docs/screenshots/admin-server-settings.png create mode 100644 docs/screenshots/admin-server-status.png create mode 100644 docs/screenshots/admin-services.png create mode 100644 docs/screenshots/admin-ssl-manager.png create mode 100644 docs/screenshots/admin-users.png create mode 100644 docs/screenshots/user-backups.png create mode 100644 docs/screenshots/user-cpanel-migration.png create mode 100644 docs/screenshots/user-dashboard.png create mode 100644 docs/screenshots/user-domains.png create mode 100755 install.sh create mode 100644 lang/ar.json create mode 100644 lang/en.json create mode 100644 lang/en/validation.php create mode 100644 lang/es.json create mode 100644 lang/fr.json create mode 100644 lang/he.json create mode 100644 lang/pt.json create mode 100644 lang/ru.json create mode 100644 mcp-docs-server/.gitignore create mode 100644 mcp-docs-server/README.md create mode 100644 mcp-docs-server/package-lock.json create mode 100644 mcp-docs-server/package.json create mode 100644 mcp-docs-server/src/index.ts create mode 100644 mcp-docs-server/tsconfig.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 packaging/jabali-deps/DEBIAN/control create mode 100644 packaging/jabali-panel/DEBIAN/control create mode 100755 packaging/jabali-panel/DEBIAN/postinst create mode 100755 packaging/jabali-panel/DEBIAN/postrm create mode 100755 packaging/jabali-panel/DEBIAN/prerm create mode 100644 packaging/jabali-panel/etc/systemd/system/jabali-agent.service create mode 100644 packaging/jabali-panel/etc/systemd/system/jabali-health-monitor.service create mode 100644 packaging/jabali-panel/etc/systemd/system/jabali-queue.service create mode 100644 phpunit.xml create mode 100644 postcss.config.js create mode 100644 public/.htaccess create mode 100644 public/android-chrome-192x192.png create mode 100644 public/apple-touch-icon.png create mode 100644 public/css/filament/filament/app.css create mode 100644 public/css/mwguerra/filemanager/filemanager.css create mode 100644 public/favicon.ico create mode 100644 public/favicon.png create mode 100644 public/fonts/filament/filament/inter/index.css create mode 100644 public/fonts/filament/filament/inter/inter-cyrillic-ext-wght-normal-ASVAGXXE.woff2 create mode 100644 public/fonts/filament/filament/inter/inter-cyrillic-ext-wght-normal-IYF56FF6.woff2 create mode 100644 public/fonts/filament/filament/inter/inter-cyrillic-ext-wght-normal-XKHXBTUO.woff2 create mode 100644 public/fonts/filament/filament/inter/inter-cyrillic-wght-normal-EWLSKVKN.woff2 create mode 100644 public/fonts/filament/filament/inter/inter-cyrillic-wght-normal-JEOLYBOO.woff2 create mode 100644 public/fonts/filament/filament/inter/inter-cyrillic-wght-normal-R5CMSONN.woff2 create mode 100644 public/fonts/filament/filament/inter/inter-greek-ext-wght-normal-7GGTF7EK.woff2 create mode 100644 public/fonts/filament/filament/inter/inter-greek-ext-wght-normal-EOVOK2B5.woff2 create mode 100644 public/fonts/filament/filament/inter/inter-greek-ext-wght-normal-ZEVLMORV.woff2 create mode 100644 public/fonts/filament/filament/inter/inter-greek-wght-normal-AXVTPQD5.woff2 create mode 100644 public/fonts/filament/filament/inter/inter-greek-wght-normal-IRE366VL.woff2 create mode 100644 public/fonts/filament/filament/inter/inter-greek-wght-normal-N43DBLU2.woff2 create mode 100644 public/fonts/filament/filament/inter/inter-latin-ext-wght-normal-5SRY4DMZ.woff2 create mode 100644 public/fonts/filament/filament/inter/inter-latin-ext-wght-normal-GZCIV3NH.woff2 create mode 100644 public/fonts/filament/filament/inter/inter-latin-ext-wght-normal-HA22NDSG.woff2 create mode 100644 public/fonts/filament/filament/inter/inter-latin-wght-normal-NRMW37G5.woff2 create mode 100644 public/fonts/filament/filament/inter/inter-latin-wght-normal-O25CN4JL.woff2 create mode 100644 public/fonts/filament/filament/inter/inter-latin-wght-normal-OPIJAQLS.woff2 create mode 100644 public/fonts/filament/filament/inter/inter-vietnamese-wght-normal-CE5GGD3W.woff2 create mode 100644 public/fonts/filament/filament/inter/inter-vietnamese-wght-normal-TWG5UU7E.woff2 create mode 100644 public/images/jabali_logo.svg create mode 100644 public/images/jabali_logo_dark.svg create mode 100644 public/images/og-image.png create mode 100644 public/index.php create mode 100644 public/js/filament/actions/actions.js create mode 100644 public/js/filament/filament/app.js create mode 100644 public/js/filament/filament/echo.js create mode 100644 public/js/filament/forms/components/checkbox-list.js create mode 100644 public/js/filament/forms/components/code-editor.js create mode 100644 public/js/filament/forms/components/color-picker.js create mode 100644 public/js/filament/forms/components/date-time-picker.js create mode 100644 public/js/filament/forms/components/file-upload.js create mode 100644 public/js/filament/forms/components/key-value.js create mode 100644 public/js/filament/forms/components/markdown-editor.js create mode 100644 public/js/filament/forms/components/rich-editor.js create mode 100644 public/js/filament/forms/components/select.js create mode 100644 public/js/filament/forms/components/slider.js create mode 100644 public/js/filament/forms/components/tags-input.js create mode 100644 public/js/filament/forms/components/textarea.js create mode 100644 public/js/filament/notifications/notifications.js create mode 100644 public/js/filament/schemas/components/actions.js create mode 100644 public/js/filament/schemas/components/tabs.js create mode 100644 public/js/filament/schemas/components/wizard.js create mode 100644 public/js/filament/schemas/schemas.js create mode 100644 public/js/filament/support/support.js create mode 100644 public/js/filament/tables/components/columns/checkbox.js create mode 100644 public/js/filament/tables/components/columns/select.js create mode 100644 public/js/filament/tables/components/columns/text-input.js create mode 100644 public/js/filament/tables/components/columns/toggle.js create mode 100644 public/js/filament/tables/tables.js create mode 100644 public/js/filament/widgets/components/chart.js create mode 100644 public/js/filament/widgets/components/stats-overview/stat/chart.js create mode 100644 public/robots.txt create mode 100644 public/vendor/codemirror/codemirror-bundle.js create mode 100644 public/vendor/codemirror/codemirror.css create mode 100644 resources/css/app.css create mode 100644 resources/js/app.js create mode 100644 resources/js/bootstrap.js create mode 100644 resources/js/server-charts.js create mode 100644 resources/markdown/policy.md create mode 100644 resources/markdown/terms.md create mode 100644 resources/views/api/api-token-manager.blade.php create mode 100644 resources/views/api/index.blade.php create mode 100644 resources/views/auth/confirm-password.blade.php create mode 100644 resources/views/auth/forgot-password.blade.php create mode 100644 resources/views/auth/login.blade.php create mode 100644 resources/views/auth/register.blade.php create mode 100644 resources/views/auth/reset-password.blade.php create mode 100644 resources/views/auth/two-factor-challenge.blade.php create mode 100644 resources/views/auth/verify-email.blade.php create mode 100644 resources/views/components/action-message.blade.php create mode 100644 resources/views/components/action-section.blade.php create mode 100644 resources/views/components/application-logo.blade.php create mode 100644 resources/views/components/application-mark.blade.php create mode 100644 resources/views/components/authentication-card-logo.blade.php create mode 100644 resources/views/components/authentication-card.blade.php create mode 100644 resources/views/components/banner.blade.php create mode 100644 resources/views/components/button.blade.php create mode 100644 resources/views/components/checkbox.blade.php create mode 100644 resources/views/components/confirmation-modal.blade.php create mode 100644 resources/views/components/confirms-password.blade.php create mode 100644 resources/views/components/danger-button.blade.php create mode 100644 resources/views/components/dialog-modal.blade.php create mode 100644 resources/views/components/dropdown-link.blade.php create mode 100644 resources/views/components/dropdown.blade.php create mode 100644 resources/views/components/form-section.blade.php create mode 100644 resources/views/components/input-error.blade.php create mode 100644 resources/views/components/input.blade.php create mode 100644 resources/views/components/label.blade.php create mode 100644 resources/views/components/language-switcher.blade.php create mode 100644 resources/views/components/modal.blade.php create mode 100644 resources/views/components/nav-link.blade.php create mode 100644 resources/views/components/responsive-nav-link.blade.php create mode 100644 resources/views/components/secondary-button.blade.php create mode 100644 resources/views/components/section-border.blade.php create mode 100644 resources/views/components/section-title.blade.php create mode 100644 resources/views/components/switchable-team.blade.php create mode 100644 resources/views/components/validation-errors.blade.php create mode 100644 resources/views/components/welcome.blade.php create mode 100644 resources/views/dashboard.blade.php create mode 100644 resources/views/emails/team-invitation.blade.php create mode 100644 resources/views/filament/admin/columns/backup-status.blade.php create mode 100644 resources/views/filament/admin/columns/schedule-status.blade.php create mode 100644 resources/views/filament/admin/components/backup-table.blade.php create mode 100644 resources/views/filament/admin/components/dnssec-ds-records.blade.php create mode 100644 resources/views/filament/admin/components/notification-log-detail.blade.php create mode 100644 resources/views/filament/admin/components/process-details.blade.php create mode 100644 resources/views/filament/admin/components/security-recommendations.blade.php create mode 100644 resources/views/filament/admin/components/security-waf.blade.php create mode 100644 resources/views/filament/admin/pages/auth/two-factor-challenge.blade.php create mode 100644 resources/views/filament/admin/pages/automation-api.blade.php create mode 100644 resources/views/filament/admin/pages/backups-tab-table.blade.php create mode 100644 resources/views/filament/admin/pages/backups.blade.php create mode 100644 resources/views/filament/admin/pages/cpanel-migration.blade.php create mode 100644 resources/views/filament/admin/pages/dashboard.blade.php create mode 100644 resources/views/filament/admin/pages/database-tuning.blade.php create mode 100644 resources/views/filament/admin/pages/dns-zones.blade.php create mode 100644 resources/views/filament/admin/pages/email-logs.blade.php create mode 100644 resources/views/filament/admin/pages/email-queue.blade.php create mode 100644 resources/views/filament/admin/pages/ip-addresses.blade.php create mode 100644 resources/views/filament/admin/pages/migration-cpanel-tab.blade.php create mode 100644 resources/views/filament/admin/pages/migration-whm-tab.blade.php create mode 100644 resources/views/filament/admin/pages/migration.blade.php create mode 100644 resources/views/filament/admin/pages/php-manager.blade.php create mode 100644 resources/views/filament/admin/pages/security.blade.php create mode 100644 resources/views/filament/admin/pages/server-settings.blade.php create mode 100644 resources/views/filament/admin/pages/server-status.blade.php create mode 100644 resources/views/filament/admin/pages/server-updates-output.blade.php create mode 100644 resources/views/filament/admin/pages/server-updates.blade.php create mode 100644 resources/views/filament/admin/pages/services.blade.php create mode 100644 resources/views/filament/admin/pages/ssl-log-modal.blade.php create mode 100644 resources/views/filament/admin/pages/ssl-manager.blade.php create mode 100644 resources/views/filament/admin/pages/waf.blade.php create mode 100644 resources/views/filament/admin/pages/whm-account-config-table.blade.php create mode 100644 resources/views/filament/admin/pages/whm-accounts-table.blade.php create mode 100644 resources/views/filament/admin/pages/whm-migration-status-table.blade.php create mode 100644 resources/views/filament/admin/pages/whm-migration.blade.php create mode 100644 resources/views/filament/admin/widgets/dashboard-stats.blade.php create mode 100644 resources/views/filament/admin/widgets/disk-usage.blade.php create mode 100644 resources/views/filament/admin/widgets/processes.blade.php create mode 100644 resources/views/filament/admin/widgets/quick-actions.blade.php create mode 100644 resources/views/filament/admin/widgets/server-charts.blade.php create mode 100644 resources/views/filament/admin/widgets/server-info.blade.php create mode 100644 resources/views/filament/admin/widgets/ssl-stats.blade.php create mode 100644 resources/views/filament/brand.blade.php create mode 100644 resources/views/filament/jabali/columns/wordpress-screenshot.blade.php create mode 100644 resources/views/filament/jabali/columns/wordpress-site.blade.php create mode 100644 resources/views/filament/jabali/components/backup-table.blade.php create mode 100644 resources/views/filament/jabali/components/cron-output.blade.php create mode 100644 resources/views/filament/jabali/components/email-table.blade.php create mode 100644 resources/views/filament/jabali/components/image-viewer.blade.php create mode 100644 resources/views/filament/jabali/components/protected-dir-users.blade.php create mode 100644 resources/views/filament/jabali/components/trash-table-embed.blade.php create mode 100644 resources/views/filament/jabali/components/trash-viewer.blade.php create mode 100644 resources/views/filament/jabali/pages/auth/two-factor-challenge.blade.php create mode 100644 resources/views/filament/jabali/pages/backups-tab-table.blade.php create mode 100644 resources/views/filament/jabali/pages/backups.blade.php create mode 100644 resources/views/filament/jabali/pages/cdn-integration.blade.php create mode 100644 resources/views/filament/jabali/pages/cpanel-migration.blade.php create mode 100644 resources/views/filament/jabali/pages/cron-jobs.blade.php create mode 100644 resources/views/filament/jabali/pages/dashboard.blade.php create mode 100644 resources/views/filament/jabali/pages/databases.blade.php create mode 100644 resources/views/filament/jabali/pages/dns-records.blade.php create mode 100644 resources/views/filament/jabali/pages/domains.blade.php create mode 100644 resources/views/filament/jabali/pages/email-tab-spam.blade.php create mode 100644 resources/views/filament/jabali/pages/email-tab-table.blade.php create mode 100644 resources/views/filament/jabali/pages/email.blade.php create mode 100644 resources/views/filament/jabali/pages/files.blade.php create mode 100644 resources/views/filament/jabali/pages/git-deployment.blade.php create mode 100644 resources/views/filament/jabali/pages/image-optimization.blade.php create mode 100644 resources/views/filament/jabali/pages/logs-tab-activity.blade.php create mode 100644 resources/views/filament/jabali/pages/logs-tab-logs.blade.php create mode 100644 resources/views/filament/jabali/pages/logs-tab-stats.blade.php create mode 100644 resources/views/filament/jabali/pages/logs.blade.php create mode 100644 resources/views/filament/jabali/pages/mailing-lists.blade.php create mode 100644 resources/views/filament/jabali/pages/php-settings.blade.php create mode 100644 resources/views/filament/jabali/pages/postgresql-tab-table.blade.php create mode 100644 resources/views/filament/jabali/pages/postgresql.blade.php create mode 100644 resources/views/filament/jabali/pages/protected-directories.blade.php create mode 100644 resources/views/filament/jabali/pages/ssh-keys.blade.php create mode 100644 resources/views/filament/jabali/pages/ssl.blade.php create mode 100644 resources/views/filament/jabali/pages/wordpress.blade.php create mode 100644 resources/views/filament/jabali/tables/columns/user-privileges.blade.php create mode 100644 resources/views/filament/jabali/widgets/disk-usage.blade.php create mode 100644 resources/views/filament/jabali/widgets/email-stats.blade.php create mode 100644 resources/views/filament/jabali/widgets/quick-actions.blade.php create mode 100644 resources/views/filament/jabali/widgets/stats-overview.blade.php create mode 100644 resources/views/filament/jabali/widgets/trash-table.blade.php create mode 100644 resources/views/layouts/app.blade.php create mode 100644 resources/views/layouts/guest.blade.php create mode 100644 resources/views/livewire/admin/security-waf-panel.blade.php create mode 100644 resources/views/livewire/admin/waf-whitelist-table.blade.php create mode 100644 resources/views/livewire/database-users-table.blade.php create mode 100644 resources/views/navigation-menu.blade.php create mode 100644 resources/views/policy.blade.php create mode 100644 resources/views/profile/delete-user-form.blade.php create mode 100644 resources/views/profile/logout-other-browser-sessions-form.blade.php create mode 100644 resources/views/profile/show.blade.php create mode 100644 resources/views/profile/two-factor-authentication-form.blade.php create mode 100644 resources/views/profile/update-password-form.blade.php create mode 100644 resources/views/profile/update-profile-information-form.blade.php create mode 100644 resources/views/terms.blade.php create mode 100644 resources/views/vendor/filament-notifications/notifications.blade.php create mode 100644 resources/views/vendor/filament-panels/components/brand.blade.php create mode 100644 resources/views/vendor/filament-panels/components/footer.blade.php create mode 100644 resources/views/vendor/filament-panels/components/logo.blade.php create mode 100644 resources/views/vendor/filament/assets.blade.php create mode 100644 resources/views/webmail-password-required.blade.php create mode 100644 resources/views/welcome.blade.php create mode 100644 resources/wordpress/jabali-cache/jabali-cache.php create mode 100644 resources/wordpress/jabali-cache/object-cache.php create mode 100644 resources/wordpress/jabali-cache/readme.txt create mode 100644 routes/api.php create mode 100644 routes/console.php create mode 100644 routes/web.php create mode 100755 scripts/build-jabali-deps-deb.sh create mode 100755 scripts/build-jabali-panel-deb.sh create mode 100755 storage/app/.gitignore create mode 100755 storage/app/private/.gitignore create mode 100755 storage/app/public/.gitignore create mode 100755 storage/framework/.gitignore create mode 100755 storage/framework/cache/.gitignore create mode 100755 storage/framework/sessions/.gitignore create mode 100755 storage/framework/testing/.gitignore create mode 100755 storage/framework/views/.gitignore create mode 100755 storage/logs/.gitignore create mode 100644 tailwind.config.js create mode 100644 tests/Feature/AdminGeoBlockRulesPageTest.php create mode 100644 tests/Feature/AdminTwoFactorChallengeTest.php create mode 100644 tests/Feature/AdminUserDeletionGuardTest.php create mode 100644 tests/Feature/ApiTokenPermissionsTest.php create mode 100644 tests/Feature/AuthenticationTest.php create mode 100644 tests/Feature/BackupDownloadSecurityTest.php create mode 100644 tests/Feature/BrowserSessionsTest.php create mode 100644 tests/Feature/Cli/JabaliCliHelpTest.php create mode 100644 tests/Feature/CreateApiTokenTest.php create mode 100644 tests/Feature/DeleteApiTokenTest.php create mode 100644 tests/Feature/DiskUsageWidgetTest.php create mode 100644 tests/Feature/EmailVerificationTest.php create mode 100644 tests/Feature/ExampleTest.php create mode 100644 tests/Feature/Filament/AdminSecurityAntivirusToggleTest.php create mode 100644 tests/Feature/Filament/AdminSecurityFail2banStartTest.php create mode 100644 tests/Feature/Filament/AdminServerSettingsResolverTemplateTest.php create mode 100644 tests/Feature/Filament/AdminServerStatusRefreshTest.php create mode 100644 tests/Feature/Filament/AdminWidgetsRenderTest.php create mode 100644 tests/Feature/Filament/DatabaseTuningPageTest.php create mode 100644 tests/Feature/Filament/DomainPageTest.php create mode 100644 tests/Feature/Filament/EmailPageTest.php create mode 100644 tests/Feature/Filament/QuickActionsWidgetTest.php create mode 100644 tests/Feature/Filament/UserPagesRenderTest.php create mode 100644 tests/Feature/Filament/ViewComponentsTest.php create mode 100644 tests/Feature/Migration/MigrationDnsSyncServiceTest.php create mode 100644 tests/Feature/PasswordConfirmationTest.php create mode 100644 tests/Feature/PasswordResetTest.php create mode 100644 tests/Feature/StatsOverviewDatabaseCountTest.php create mode 100644 tests/Feature/TwoFactorAuthenticationSettingsTest.php create mode 100644 tests/Feature/WebmailSsoViewTest.php create mode 100644 tests/TestCase.php create mode 100644 tests/Unit/AdminUserDeleteVisibilityTest.php create mode 100644 tests/Unit/AgentAllowedServicesTest.php create mode 100644 tests/Unit/AgentResolversTest.php create mode 100644 tests/Unit/ClamavLightModeConfigTest.php create mode 100644 tests/Unit/ClamavSignatureCountingTest.php create mode 100644 tests/Unit/CpanelMailboxPasswordHashTest.php create mode 100644 tests/Unit/DnsInstallerZoneTest.php create mode 100644 tests/Unit/DnsPendingAddsTest.php create mode 100644 tests/Unit/ExampleTest.php create mode 100644 tests/Unit/HealthMonitorServicesTest.php create mode 100644 tests/Unit/ImpersonationTokenTest.php create mode 100644 tests/Unit/InstallPanelFpmServiceTest.php create mode 100644 tests/Unit/InstallerUninstallTest.php create mode 100644 tests/Unit/MigrationDnsSyncServiceTest.php create mode 100644 tests/Unit/MigrationTabsTest.php create mode 100644 tests/Unit/Models/DnsSettingTest.php create mode 100644 tests/Unit/NginxVhostRetryTest.php create mode 100644 tests/Unit/PublishGithubScriptTest.php create mode 100644 tests/Unit/ReadmeScreenshotsTest.php create mode 100644 tests/Unit/RoundcubeSsoMasterConfigTest.php create mode 100644 tests/Unit/RunCpanelRestoreTest.php create mode 100644 tests/Unit/ServerChartsWidgetTest.php create mode 100644 tests/Unit/ServerSettingsExportImportTest.php create mode 100644 tests/Unit/ServerSettingsPhpFpmServiceTest.php create mode 100644 tests/Unit/ServerStatusBulkActionsTest.php create mode 100644 tests/Unit/Services/AgentClientTest.php create mode 100644 tests/Unit/SslStatsOverviewViewTest.php create mode 100644 tests/Unit/SysstatMetricsTest.php create mode 100644 tests/Unit/UpgradeCommandTest.php create mode 100644 tests/Unit/UserCpanelMigrationTest.php create mode 100644 tests/Unit/VersionFileTest.php create mode 100644 tests/Unit/WhmMigrationReloadTest.php create mode 100644 tests/Unit/WhmMigrationStatusStoreTest.php create mode 100644 tests/Unit/WhmMigrationVisibilityTest.php create mode 100755 tests/populate-demo-data create mode 100644 tests/take-screenshots.cjs create mode 100644 vite.config.js 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 .= <<