From b49e3937a5ab3c73f26ce41d9534a6e5ad313a9a Mon Sep 17 00:00:00 2001 From: root Date: Sat, 24 Jan 2026 19:36:46 +0200 Subject: [PATCH] Initial release --- .editorconfig | 18 + .env.example | 62 + .git-authorized-committers | 1 + .git-authorized-remotes | 1 + .gitattributes | 11 + .githooks/pre-commit | 58 + .gitignore | 20 + .mcp.json | 36 + LICENSE | 75 + Makefile | 122 + README.md | 161 + 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 | 509 + 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/Backups.php | 1559 ++ app/Filament/Admin/Pages/CpanelMigration.php | 2417 ++ app/Filament/Admin/Pages/Dashboard.php | 123 + app/Filament/Admin/Pages/DnsZones.php | 892 + app/Filament/Admin/Pages/IpAddresses.php | 330 + app/Filament/Admin/Pages/Migration.php | 85 + app/Filament/Admin/Pages/PhpManager.php | 294 + app/Filament/Admin/Pages/Security.php | 2454 ++ app/Filament/Admin/Pages/ServerSettings.php | 1228 + app/Filament/Admin/Pages/ServerStatus.php | 393 + app/Filament/Admin/Pages/Services.php | 332 + app/Filament/Admin/Pages/SslManager.php | 592 + app/Filament/Admin/Pages/WhmMigration.php | 1267 + .../Resources/Users/Pages/CreateUser.php | 90 + .../Admin/Resources/Users/Pages/EditUser.php | 116 + .../Admin/Resources/Users/Pages/ListUsers.php | 23 + .../Resources/Users/Schemas/UserForm.php | 217 + .../Resources/Users/Tables/UsersTable.php | 212 + .../Admin/Resources/Users/UserResource.php | 65 + .../Admin/Widgets/AdminStatsOverview.php | 51 + .../Widgets/Dashboard/RecentActivityTable.php | 68 + .../Admin/Widgets/DashboardStatsWidget.php | 67 + .../Admin/Widgets/DiskUsageWidget.php | 25 + .../Admin/Widgets/DnsPendingAddsTable.php | 92 + .../Widgets/DomainIpAssignmentsTable.php | 319 + 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 + .../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 | 77 + .../Admin/Widgets/ServerInfoWidget.php | 16 + .../Admin/Widgets/ServerStatsOverview.php | 116 + .../Admin/Widgets/ServicesTableWidget.php | 199 + .../Admin/Widgets/Settings/DnssecTable.php | 217 + .../Widgets/Settings/NotificationLogTable.php | 164 + .../Admin/Widgets/SslStatsOverview.php | 64 + .../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/Concerns/HasPageTour.php | 19 + app/Filament/Jabali/Pages/Auth/Login.php | 51 + .../Jabali/Pages/Auth/TwoFactorChallenge.php | 173 + app/Filament/Jabali/Pages/Backups.php | 1241 + app/Filament/Jabali/Pages/CpanelMigration.php | 1734 ++ app/Filament/Jabali/Pages/CronJobs.php | 508 + app/Filament/Jabali/Pages/Dashboard.php | 62 + app/Filament/Jabali/Pages/Databases.php | 966 + app/Filament/Jabali/Pages/DnsRecords.php | 799 + app/Filament/Jabali/Pages/Domains.php | 795 + app/Filament/Jabali/Pages/Email.php | 1447 + app/Filament/Jabali/Pages/Files.php | 1128 + app/Filament/Jabali/Pages/Logs.php | 241 + app/Filament/Jabali/Pages/PhpSettings.php | 325 + .../Jabali/Pages/ProtectedDirectories.php | 319 + app/Filament/Jabali/Pages/SshKeys.php | 449 + app/Filament/Jabali/Pages/Ssl.php | 505 + app/Filament/Jabali/Pages/WordPress.php | 1404 + .../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/BackupDownloadController.php | 120 + app/Http/Controllers/Controller.php | 8 + .../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/RunServerBackup.php | 274 + app/Jobs/RunWhmMigrationBatch.php | 410 + app/Listeners/AuthEventListener.php | 74 + 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/CronJob.php | 77 + app/Models/DnsRecord.php | 29 + app/Models/DnsSetting.php | 43 + app/Models/Domain.php | 146 + app/Models/DomainHotlinkSetting.php | 52 + app/Models/DomainRedirect.php | 44 + app/Models/EmailDomain.php | 77 + app/Models/EmailForwarder.php | 44 + 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 | 65 + app/Models/Setting.php | 49 + app/Models/SslCertificate.php | 148 + app/Models/User.php | 225 + app/Models/UserRemoteBackup.php | 75 + app/Observers/DomainObserver.php | 113 + app/Providers/AppServiceProvider.php | 29 + app/Providers/Filament/AdminPanelProvider.php | 197 + .../Filament/JabaliPanelProvider.php | 228 + app/Providers/FortifyServiceProvider.php | 48 + app/Providers/JetstreamServiceProvider.php | 43 + app/Services/AdminNotificationService.php | 185 + app/Services/Agent/AgentClient.php | 1154 + app/Services/JabaliSshKey.php | 153 + app/Services/Migration/CpanelApiService.php | 1306 + .../Migration/MigrationDnsSyncService.php | 206 + app/Services/Migration/WhmApiService.php | 962 + .../Migration/WhmMigrationStatusStore.php | 149 + app/Services/System/LinuxUserService.php | 70 + app/View/Components/AppLayout.php | 17 + app/View/Components/GuestLayout.php | 17 + artisan | 18 + bin/jabali | 3280 +++ bin/jabali-agent | 22622 ++++++++++++++++ bin/jabali-health-monitor | 513 + 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 | 186 + 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 + database/seeders/DatabaseSeeder.php | 25 + docs/agent-functions.yaml | 603 + docs/architecture/control-panel-blueprint.md | 121 + docs/archive-notes.md | 12 + docs/screenshots/admin-dashboard.png | Bin 0 -> 148786 bytes docs/screenshots/admin-security.png | Bin 0 -> 77770 bytes docs/screenshots/admin-server-settings.png | Bin 0 -> 96960 bytes docs/screenshots/admin-server-status.png | Bin 0 -> 276187 bytes docs/screenshots/backups.svg | 10 + docs/screenshots/migration-progress.svg | 12 + docs/screenshots/user-dashboard.svg | 11 + docs/screenshots/user-domains.svg | 10 + install.sh | 3256 +++ 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 | 3564 +++ package.json | 26 + 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-custom.css | 132 + 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/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/admin-tour.js | 279 + resources/js/app.js | 1 + resources/js/bootstrap.js | 4 + resources/js/server-charts.js | 4 + resources/js/tours/admin-tours.js | 114 + resources/js/tours/user-tours.js | 121 + 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 + .../views/components/admin-tour.blade.php | 42 + .../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 + .../views/components/user-tour.blade.php | 41 + .../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 | 24 + .../admin/columns/schedule-status.blade.php | 31 + .../admin/components/backup-table.blade.php | 1 + .../components/backup-tabs-nav.blade.php | 39 + .../components/dnssec-ds-records.blade.php | 59 + .../notification-log-detail.blade.php | 79 + .../components/process-details.blade.php | 50 + .../components/security-tabs-nav.blade.php | 42 + .../server-settings-tabs-nav.blade.php | 42 + .../pages/auth/two-factor-challenge.blade.php | 23 + .../filament/admin/pages/backups.blade.php | 9 + .../admin/pages/cpanel-migration.blade.php | 5 + .../filament/admin/pages/dashboard.blade.php | 5 + .../filament/admin/pages/dns-zones.blade.php | 81 + .../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 + .../filament/admin/pages/services.blade.php | 19 + .../admin/pages/ssl-log-modal.blade.php | 49 + .../admin/pages/ssl-manager.blade.php | 25 + .../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 | 32 + .../admin/widgets/disk-usage.blade.php | 31 + .../admin/widgets/processes.blade.php | 59 + .../admin/widgets/quick-actions.blade.php | 17 + .../admin/widgets/server-charts.blade.php | 490 + .../admin/widgets/server-info.blade.php | 23 + resources/views/filament/brand.blade.php | 9 + .../components/panel-styles.blade.php | 338 + .../columns/wordpress-screenshot.blade.php | 49 + .../jabali/columns/wordpress-site.blade.php | 43 + .../jabali/components/backup-table.blade.php | 1 + .../components/backup-tabs-nav.blade.php | 40 + .../jabali/components/cron-output.blade.php | 7 + .../jabali/components/email-table.blade.php | 1 + .../components/email-tabs-nav.blade.php | 41 + .../jabali/components/image-viewer.blade.php | 18 + .../components/protected-dir-users.blade.php | 36 + .../components/trash-table-embed.blade.php | 3 + .../jabali/components/trash-viewer.blade.php | 85 + .../pages/auth/two-factor-challenge.blade.php | 23 + .../filament/jabali/pages/backups.blade.php | 9 + .../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 + .../filament/jabali/pages/email.blade.php | 11 + .../filament/jabali/pages/files.blade.php | 97 + .../filament/jabali/pages/logs.blade.php | 141 + .../jabali/pages/php-settings.blade.php | 72 + .../pages/protected-directories.blade.php | 74 + .../filament/jabali/pages/ssh-keys.blade.php | 159 + .../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 | 178 + .../jabali/widgets/email-stats.blade.php | 32 + .../jabali/widgets/quick-actions.blade.php | 17 + .../jabali/widgets/stats-overview.blade.php | 32 + .../jabali/widgets/trash-table.blade.php | 3 + resources/views/layouts/app.blade.php | 45 + resources/views/layouts/guest.blade.php | 27 + .../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 + .../components/brand.blade.php | 9 + .../components/footer.blade.php | 33 + .../filament-panels/components/logo.blade.php | 13 + .../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 | 165 + routes/console.php | 102 + routes/web.php | 166 + 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/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 + .../Filament/AdminServerStatusRefreshTest.php | 95 + .../Filament/AdminWidgetsRenderTest.php | 39 + tests/Feature/Filament/DomainPageTest.php | 87 + .../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 | 67 + tests/TestCase.php | 10 + tests/Unit/AdminUserDeleteVisibilityTest.php | 42 + 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/ImpersonationTokenTest.php | 30 + tests/Unit/InstallerUninstallTest.php | 52 + tests/Unit/MigrationTabsTest.php | 58 + tests/Unit/Models/DnsSettingTest.php | 49 + tests/Unit/NginxVhostRetryTest.php | 30 + tests/Unit/RoundcubeSsoMasterConfigTest.php | 22 + tests/Unit/RunCpanelRestoreTest.php | 89 + tests/Unit/ServerStatusBulkActionsTest.php | 19 + tests/Unit/Services/AgentClientTest.php | 28 + tests/Unit/UpgradeCommandTest.php | 101 + 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 | 99 + vite.config.js | 18 + 541 files changed, 129544 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 LICENSE create mode 100644 Makefile create mode 100644 README.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/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/DnsZones.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/Services.php create mode 100644 app/Filament/Admin/Pages/SslManager.php create mode 100644 app/Filament/Admin/Pages/WhmMigration.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/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/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/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/Concerns/HasPageTour.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/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/Logs.php create mode 100644 app/Filament/Jabali/Pages/PhpSettings.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/BackupDownloadController.php create mode 100644 app/Http/Controllers/Controller.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/RunServerBackup.php create mode 100644 app/Jobs/RunWhmMigrationBatch.php create mode 100644 app/Listeners/AuthEventListener.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/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/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/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/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/System/LinuxUserService.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/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/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/screenshots/admin-dashboard.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/backups.svg create mode 100644 docs/screenshots/migration-progress.svg create mode 100644 docs/screenshots/user-dashboard.svg create mode 100644 docs/screenshots/user-domains.svg 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 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-custom.css 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/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/admin-tour.js 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/js/tours/admin-tours.js create mode 100644 resources/js/tours/user-tours.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/admin-tour.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/user-tour.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/backup-tabs-nav.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-tabs-nav.blade.php create mode 100644 resources/views/filament/admin/components/server-settings-tabs-nav.blade.php create mode 100644 resources/views/filament/admin/pages/auth/two-factor-challenge.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/dns-zones.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/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/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/brand.blade.php create mode 100644 resources/views/filament/components/panel-styles.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/backup-tabs-nav.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/email-tabs-nav.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.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.blade.php create mode 100644 resources/views/filament/jabali/pages/files.blade.php create mode 100644 resources/views/filament/jabali/pages/logs.blade.php create mode 100644 resources/views/filament/jabali/pages/php-settings.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/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-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/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 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/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/AdminServerStatusRefreshTest.php create mode 100644 tests/Feature/Filament/AdminWidgetsRenderTest.php create mode 100644 tests/Feature/Filament/DomainPageTest.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/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/ImpersonationTokenTest.php create mode 100644 tests/Unit/InstallerUninstallTest.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/RoundcubeSsoMasterConfigTest.php create mode 100644 tests/Unit/RunCpanelRestoreTest.php create mode 100644 tests/Unit/ServerStatusBulkActionsTest.php create mode 100644 tests/Unit/Services/AgentClientTest.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..c37fe74 --- /dev/null +++ b/.git-authorized-remotes @@ -0,0 +1 @@ +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..751ba4a --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +/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 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/LICENSE b/LICENSE new file mode 100644 index 0000000..7639a86 --- /dev/null +++ b/LICENSE @@ -0,0 +1,75 @@ +JABALI PROPRIETARY LICENSE +Version 1.0 + +Copyright (c) 2024-2026 Jabali. All Rights Reserved. + +TERMS AND CONDITIONS + +1. DEFINITIONS + "Software" refers to Jabali and all associated source code, documentation, + and related materials. + "Licensor" refers to the copyright holder of Jabali. + "User" refers to any individual or entity using the Software. + +2. GRANT OF LICENSE + Subject to the terms of this license, the Licensor grants you a limited, + non-exclusive, non-transferable license to: + - Use the Software for your own hosting purposes + - Install the Software on servers you own or control + +3. RESTRICTIONS + You are expressly prohibited from: + + a) FORKING: Creating any derivative work, fork, or copy of the Software + for distribution or public availability. + + b) REDISTRIBUTION: Distributing, selling, sublicensing, or transferring + the Software or any portion thereof to any third party. + + c) MODIFICATION FOR DISTRIBUTION: Modifying the Software for the purpose + of creating a competing product or service. + + d) REVERSE ENGINEERING: Decompiling, disassembling, or reverse engineering + the Software for the purpose of creating a similar product. + + e) REMOVAL OF NOTICES: Removing, altering, or obscuring any copyright, + trademark, or other proprietary notices from the Software. + + f) PUBLIC REPOSITORIES: Publishing the Software or any derivative work + to any public repository (GitHub, GitLab, Bitbucket, etc.). + +4. INTELLECTUAL PROPERTY + The Software and all copies thereof are proprietary to the Licensor and + title thereto remains exclusively with the Licensor. All rights in the + Software not specifically granted in this license are reserved to the + Licensor. + +5. NO WARRANTY + THE SOFTWARE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND, EXPRESS + OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. + +6. LIMITATION OF LIABILITY + IN NO EVENT SHALL THE LICENSOR BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER + LIABILITY ARISING FROM THE USE OF THE SOFTWARE. + +7. TERMINATION + This license is effective until terminated. It will terminate automatically + if you fail to comply with any term of this license. Upon termination, you + must destroy all copies of the Software in your possession. + +8. ENFORCEMENT + Any unauthorized use, reproduction, or distribution of the Software may + result in civil and criminal penalties, and will be prosecuted to the + maximum extent possible under the law. + +9. GOVERNING LAW + This license shall be governed by and construed in accordance with + applicable copyright and intellectual property laws. + +For licensing inquiries, contact: license@jabali.io + +--- + +BY USING THIS SOFTWARE, YOU ACKNOWLEDGE THAT YOU HAVE READ THIS LICENSE, +UNDERSTAND IT, AND AGREE TO BE BOUND BY ITS TERMS AND CONDITIONS. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fe5c6e1 --- /dev/null +++ b/Makefile @@ -0,0 +1,122 @@ +# Jabali Web Hosting Panel - Development Makefile + +.PHONY: dev test lint fix fresh migrate seed install build clean agent-restart agent-logs + +# Development +dev: + composer dev + +serve: + php artisan serve + +queue: + php artisan queue:listen --tries=1 + +logs: + php artisan pail --timeout=0 + +# Testing +test: + php artisan test + +test-filter: + @read -p "Filter: " filter && php artisan test --filter=$$filter + +test-coverage: + php artisan test --coverage + +# Code Quality +lint: + ./vendor/bin/pint --test + +fix: + ./vendor/bin/pint + +analyze: + ./vendor/bin/phpstan analyse --memory-limit=512M 2>/dev/null || echo "PHPStan not installed" + +# Database +migrate: + php artisan migrate + +migrate-fresh: + php artisan migrate:fresh + +seed: + php artisan db:seed + +fresh: migrate-fresh seed + +rollback: + php artisan migrate:rollback + +# Build +build: + npm run build + +build-dev: + npm run dev + +install: + composer install + npm install + +update: + composer update + npm update + +# Cache +cache: + php artisan config:cache + php artisan route:cache + php artisan view:cache + +clear: + php artisan config:clear + php artisan route:clear + php artisan view:clear + php artisan cache:clear + +# Jabali Agent +agent-restart: + sudo systemctl restart jabali-agent + +agent-status: + sudo systemctl status jabali-agent + +agent-logs: + sudo tail -f /var/log/jabali/agent.log + +agent-test: + @echo '{"action":"ping"}' | sudo socat - UNIX-CONNECT:/var/run/jabali/agent.sock + +# Filament +filament-assets: + php artisan filament:assets + +# Tinker +tinker: + php artisan tinker + +# Cleanup +clean: + rm -rf node_modules + rm -rf vendor + rm -rf bootstrap/cache/*.php + rm -rf storage/framework/cache/data/* + rm -rf storage/framework/sessions/* + rm -rf storage/framework/views/* + +# Help +help: + @echo "Available targets:" + @echo " dev - Start development servers (serve, queue, pail, vite)" + @echo " test - Run PHPUnit tests" + @echo " lint - Check code style with Pint" + @echo " fix - Fix code style with Pint" + @echo " migrate - Run database migrations" + @echo " fresh - Fresh migrate and seed" + @echo " build - Build frontend assets" + @echo " cache - Cache config, routes, views" + @echo " clear - Clear all caches" + @echo " agent-* - Jabali agent management" diff --git a/README.md b/README.md new file mode 100644 index 0000000..e3f72f1 --- /dev/null +++ b/README.md @@ -0,0 +1,161 @@ +# Jabali Panel + +A modern web hosting control panel for WordPress and general PHP hosting. Built with Laravel 12, Filament v5, Livewire 4, and Tailwind CSS v4. + +Version: 0.9-rc (initial release) + +This is a release candidate. Expect rapid iteration and breaking changes until 1.0. + +## Highlights + +- Multi-tenant isolation with per-user Linux accounts and PHP-FPM pools +- Agent-driven automation for domains, SSL, mail, DNS, backups, and migrations +- cPanel and WHM migrations with detailed, step-by-step logs +- Built-in mail stack (Postfix, Dovecot, Rspamd) with webmail SSO +- DNS management with templates and optional DNSSEC +- Backups for users and servers with schedules, retention, and remote targets +- Security controls including firewall, Fail2ban, and ClamAV scanning + +## Screenshots + +Admin panel: + +- Dashboard: ![Admin Dashboard](docs/screenshots/admin-dashboard.png) +- Server Status: ![Server Status](docs/screenshots/admin-server-status.png) +- Server Settings: ![Server Settings](docs/screenshots/admin-server-settings.png) +- Security Center: ![Security Center](docs/screenshots/admin-security.png) + +User panel and flows (placeholder screenshots; replace with real captures): + +- User Dashboard: ![User Dashboard](docs/screenshots/user-dashboard.svg) +- Domain Management: ![User Domains](docs/screenshots/user-domains.svg) +- Migration Progress: ![Migration Progress](docs/screenshots/migration-progress.svg) +- Backups: ![Backups](docs/screenshots/backups.svg) + +Regenerate admin screenshots with: + +``` +node tests/take-screenshots.cjs --output-dir=docs/screenshots +``` + +## Feature Map + +### Admin Panel + +- Server dashboard with stats, health, and recent activity +- User management with suspension and quota tracking +- Service manager for systemd services +- PHP version manager and PHP-FPM pool management +- DNS zones, templates, and DNSSEC management +- SSL issuance, renewal, and certificate inventory +- IP address management (default and per-domain assignments) +- Backups and restores (local + remote, schedules + retention) +- Migrations (cPanel restore and WHM downloads) +- Security center (firewall, Fail2ban, ClamAV, security scans) +- Audit logs and email notifications + +### User Panel + +- Domains and redirects with automatic Nginx config +- DNS records editor +- Mail domains, mailboxes, and forwarders +- Webmail SSO (Roundcube) +- WordPress manager (install, scan, updates, SSO) +- File manager plus SFTP/SSH key management +- Databases and user permissions +- PHP settings per account +- SSL certificates and renewals +- Cron jobs +- Backups and restore +- Logs and statistics +- Protected directories + +### Platform + +- Root-level agent for privileged operations +- Queue-backed jobs for long-running tasks +- Self-healing service monitor and alerts +- Redis ACL isolation for WordPress caching +- Multi-language UI + +## Architecture + +- Control plane: Laravel app with Filament panels +- Data plane: root agent handling privileged operations +- Job queue: async tasks and migration steps +- Logging: agent and panel logs for troubleshooting + +Service stack (single-node default): + +- Nginx + PHP-FPM +- MariaDB (user databases) +- SQLite (panel metadata by default) +- Postfix, Dovecot, Rspamd +- BIND9 (DNS) +- Redis +- Fail2ban and ClamAV (optional) + +## Requirements + +- Fresh Debian 12 or 13 install (no pre-existing web or mail stack) +- A domain for panel and mail (with glue records if hosting DNS) +- PTR (reverse DNS) for mail hostname +- Open ports: 22, 80, 443, 25, 465, 587, 993, 995, 53 + +## Installation + +Quick install: + +``` +curl -fsSL https://raw.githubusercontent.com/shukiv/jabali-panel/main/install.sh | sudo bash +``` + +Optional flags: + +- `JABALI_MINIMAL=1` for core-only install +- `JABALI_FULL=1` to force all optional components + +After install: + +- Admin panel: `https://your-host/jabali-admin` +- User panel: `https://your-host/jabali-panel` +- Webmail: `https://your-host/webmail` + +## Upgrades + +``` +cd /var/www/jabali +php artisan jabali:upgrade +``` + +Check for updates only: + +``` +php artisan jabali:upgrade --check +``` + +## CLI + +``` +jabali --help +jabali backup create +jabali backup restore --user= +jabali cpanel analyze +jabali cpanel restore +``` + +## Development + +``` +composer dev +php artisan test --compact +./vendor/bin/pint +``` + +## Initial Release + +- 0.9-rc: initial release candidate with core hosting, mail, DNS, SSL, backups, and migrations. + +## License + +MIT diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..0d7ea3b --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +VERSION=0.9-rc diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php new file mode 100644 index 0000000..566e51d --- /dev/null +++ b/app/Actions/Fortify/CreateNewUser.php @@ -0,0 +1,35 @@ + $input + */ + public function create(array $input): User + { + Validator::make($input, [ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'string', 'email', 'max:255', 'unique:users'], + 'password' => $this->passwordRules(), + 'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '', + ])->validate(); + + return User::create([ + 'name' => $input['name'], + 'email' => $input['email'], + 'password' => Hash::make($input['password']), + ]); + } +} diff --git a/app/Actions/Fortify/PasswordValidationRules.php b/app/Actions/Fortify/PasswordValidationRules.php new file mode 100644 index 0000000..89c24b4 --- /dev/null +++ b/app/Actions/Fortify/PasswordValidationRules.php @@ -0,0 +1,25 @@ +|string> + */ + protected function passwordRules(): array + { + return [ + 'required', + 'string', + Password::min(8) + ->mixedCase() + ->numbers(), + 'confirmed', + ]; + } +} diff --git a/app/Actions/Fortify/ResetUserPassword.php b/app/Actions/Fortify/ResetUserPassword.php new file mode 100644 index 0000000..7a57c50 --- /dev/null +++ b/app/Actions/Fortify/ResetUserPassword.php @@ -0,0 +1,29 @@ + $input + */ + public function reset(User $user, array $input): void + { + Validator::make($input, [ + 'password' => $this->passwordRules(), + ])->validate(); + + $user->forceFill([ + 'password' => Hash::make($input['password']), + ])->save(); + } +} diff --git a/app/Actions/Fortify/UpdateUserPassword.php b/app/Actions/Fortify/UpdateUserPassword.php new file mode 100644 index 0000000..7005639 --- /dev/null +++ b/app/Actions/Fortify/UpdateUserPassword.php @@ -0,0 +1,32 @@ + $input + */ + public function update(User $user, array $input): void + { + Validator::make($input, [ + 'current_password' => ['required', 'string', 'current_password:web'], + 'password' => $this->passwordRules(), + ], [ + 'current_password.current_password' => __('The provided password does not match your current password.'), + ])->validateWithBag('updatePassword'); + + $user->forceFill([ + 'password' => Hash::make($input['password']), + ])->save(); + } +} diff --git a/app/Actions/Fortify/UpdateUserProfileInformation.php b/app/Actions/Fortify/UpdateUserProfileInformation.php new file mode 100644 index 0000000..9738772 --- /dev/null +++ b/app/Actions/Fortify/UpdateUserProfileInformation.php @@ -0,0 +1,56 @@ + $input + */ + public function update(User $user, array $input): void + { + Validator::make($input, [ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($user->id)], + 'photo' => ['nullable', 'mimes:jpg,jpeg,png', 'max:1024'], + ])->validateWithBag('updateProfileInformation'); + + if (isset($input['photo'])) { + $user->updateProfilePhoto($input['photo']); + } + + if ($input['email'] !== $user->email && + $user instanceof MustVerifyEmail) { + $this->updateVerifiedUser($user, $input); + } else { + $user->forceFill([ + 'name' => $input['name'], + 'email' => $input['email'], + ])->save(); + } + } + + /** + * Update the given verified user's profile information. + * + * @param array $input + */ + protected function updateVerifiedUser(User $user, array $input): void + { + $user->forceFill([ + 'name' => $input['name'], + 'email' => $input['email'], + 'email_verified_at' => null, + ])->save(); + + $user->sendEmailVerificationNotification(); + } +} diff --git a/app/Actions/Jetstream/DeleteUser.php b/app/Actions/Jetstream/DeleteUser.php new file mode 100644 index 0000000..083159e --- /dev/null +++ b/app/Actions/Jetstream/DeleteUser.php @@ -0,0 +1,19 @@ +deleteProfilePhoto(); + $user->tokens->each->delete(); + $user->delete(); + } +} diff --git a/app/Console/Commands/CheckDiskQuotas.php b/app/Console/Commands/CheckDiskQuotas.php new file mode 100644 index 0000000..459ab8a --- /dev/null +++ b/app/Console/Commands/CheckDiskQuotas.php @@ -0,0 +1,79 @@ +info('Disk quotas are not enabled.'); + return Command::SUCCESS; + } + + $threshold = (int) $this->option('threshold'); + $this->info("Checking disk quotas (threshold: {$threshold}%)..."); + + $users = User::where('is_admin', false)->get(); + $warnings = 0; + + foreach ($users as $user) { + $usage = $this->getUserQuotaUsage($user->username); + + if ($usage === null) { + continue; + } + + if ($usage['percent'] >= $threshold) { + $this->warn("User {$user->username} at {$usage['percent']}% quota usage"); + AdminNotificationService::diskQuotaWarning($user->username, $usage['percent']); + $warnings++; + } + } + + $this->info("Quota check complete. {$warnings} warning(s) sent."); + return Command::SUCCESS; + } + + private function getUserQuotaUsage(string $username): ?array + { + $output = []; + $returnVar = 0; + + exec("quota -u {$username} 2>/dev/null", $output, $returnVar); + + if ($returnVar !== 0 || empty($output)) { + return null; + } + + // Parse quota output + foreach ($output as $line) { + if (preg_match('/^\s*\/\S+\s+(\d+)\s+(\d+)\s+(\d+)/', $line, $matches)) { + $used = (int) $matches[1]; + $soft = (int) $matches[2]; + $hard = (int) $matches[3]; + + $limit = $soft > 0 ? $soft : $hard; + if ($limit > 0) { + return [ + 'used' => $used, + 'limit' => $limit, + 'percent' => (int) round(($used / $limit) * 100), + ]; + } + } + } + + return null; + } +} diff --git a/app/Console/Commands/CheckFail2banAlerts.php b/app/Console/Commands/CheckFail2banAlerts.php new file mode 100644 index 0000000..728daaf --- /dev/null +++ b/app/Console/Commands/CheckFail2banAlerts.php @@ -0,0 +1,81 @@ +info('Checking fail2ban for recent bans...'); + + $logFile = '/var/log/fail2ban.log'; + + if (!file_exists($logFile)) { + $this->info('Fail2ban log not found.'); + return Command::SUCCESS; + } + + // Get last check position + $lastPosition = (int) Cache::get('fail2ban_check_position', 0); + $currentSize = filesize($logFile); + + // If log was rotated, start from beginning + if ($currentSize < $lastPosition) { + $lastPosition = 0; + } + + $handle = fopen($logFile, 'r'); + if (!$handle) { + $this->error('Cannot open fail2ban log.'); + return Command::FAILURE; + } + + fseek($handle, $lastPosition); + $banCount = 0; + $bans = []; + + while (($line = fgets($handle)) !== false) { + // Match fail2ban ban entries + // Format: 2024-01-15 10:30:45,123 fail2ban.actions [12345]: NOTICE [sshd] Ban 192.168.1.100 + if (preg_match('/\[([^\]]+)\]\s+Ban\s+(\S+)/', $line, $matches)) { + $service = $matches[1]; + $ip = $matches[2]; + + $key = "{$service}:{$ip}"; + if (!isset($bans[$key])) { + $bans[$key] = [ + 'service' => $service, + 'ip' => $ip, + 'count' => 0, + ]; + } + $bans[$key]['count']++; + $banCount++; + } + } + + $newPosition = ftell($handle); + fclose($handle); + + // Save new position + Cache::put('fail2ban_check_position', $newPosition, now()->addDays(7)); + + // Send notifications for each unique IP/service combination + foreach ($bans as $ban) { + $this->warn("Ban detected: {$ban['ip']} on {$ban['service']} ({$ban['count']} times)"); + AdminNotificationService::loginFailure($ban['ip'], $ban['service'], $ban['count']); + } + + $this->info("Fail2ban check complete. {$banCount} ban(s) found."); + return Command::SUCCESS; + } +} diff --git a/app/Console/Commands/CheckFileIntegrity.php b/app/Console/Commands/CheckFileIntegrity.php new file mode 100644 index 0000000..2a71cef --- /dev/null +++ b/app/Console/Commands/CheckFileIntegrity.php @@ -0,0 +1,251 @@ +error('Not a Git repository. File integrity checking requires Git.'); + return 1; + } + + $this->info('Checking file integrity...'); + + // Get modified files from Git + $modifiedFiles = $this->getModifiedFiles($basePath); + $untrackedFiles = $this->getUntrackedFiles($basePath); + + // Filter to only protected paths + $modifiedFiles = $this->filterProtectedPaths($modifiedFiles); + $untrackedFiles = $this->filterProtectedPaths($untrackedFiles); + + $hasChanges = !empty($modifiedFiles) || !empty($untrackedFiles); + + if (!$hasChanges) { + $this->info('All core files are intact. No unauthorized modifications detected.'); + DnsSetting::set('last_integrity_check', now()->toIso8601String()); + DnsSetting::set('last_integrity_status', 'clean'); + return 0; + } + + // Report modified files + if (!empty($modifiedFiles)) { + $this->warn('Modified files detected:'); + $this->table(['File', 'Status'], array_map(fn($f) => [$f['file'], $f['status']], $modifiedFiles)); + } + + // Report untracked files in protected directories + if (!empty($untrackedFiles)) { + $this->warn('Untracked files in protected directories:'); + foreach ($untrackedFiles as $file) { + $this->line(" - $file"); + } + } + + // Store status + DnsSetting::set('last_integrity_check', now()->toIso8601String()); + DnsSetting::set('last_integrity_status', 'modified'); + DnsSetting::set('integrity_modified_files', json_encode($modifiedFiles)); + DnsSetting::set('integrity_untracked_files', json_encode($untrackedFiles)); + + // Send notification if requested + if ($this->option('notify')) { + $this->sendNotification($modifiedFiles, $untrackedFiles); + } + + // Restore files if requested + if ($this->option('fix')) { + return $this->restoreFiles($basePath, $modifiedFiles); + } + + $this->newLine(); + $this->warn('Run with --fix to restore modified files from Git.'); + + return 1; + } + + protected function getModifiedFiles(string $basePath): array + { + $output = []; + exec("cd $basePath && git status --porcelain 2>/dev/null", $output); + + $files = []; + foreach ($output as $line) { + if (strlen($line) < 3) continue; + + $status = trim(substr($line, 0, 2)); + $file = trim(substr($line, 3)); + + // Skip untracked files (handled separately) + if ($status === '??') continue; + + // Map status codes + $statusMap = [ + 'M' => 'Modified', + 'A' => 'Added', + 'D' => 'Deleted', + 'R' => 'Renamed', + 'C' => 'Copied', + 'MM' => 'Modified (staged + unstaged)', + 'AM' => 'Added + Modified', + ]; + + $files[] = [ + 'file' => $file, + 'status' => $statusMap[$status] ?? $status, + 'raw_status' => $status, + ]; + } + + return $files; + } + + protected function getUntrackedFiles(string $basePath): array + { + $output = []; + exec("cd $basePath && git status --porcelain 2>/dev/null | grep '^??' | cut -c4-", $output); + return $output; + } + + protected function filterProtectedPaths(array $files): array + { + return array_filter($files, function ($item) { + $file = is_array($item) ? $item['file'] : $item; + + // Check if in ignored paths + foreach ($this->ignoredPaths as $ignored) { + if (str_starts_with($file, $ignored)) { + return false; + } + } + + // Check if in protected paths + foreach ($this->protectedPaths as $protected) { + if (str_starts_with($file, $protected)) { + return true; + } + } + + return false; + }); + } + + protected function restoreFiles(string $basePath, array $modifiedFiles): int + { + if (empty($modifiedFiles)) { + $this->info('No files to restore.'); + return 0; + } + + $this->warn('Restoring modified files from Git...'); + + foreach ($modifiedFiles as $file) { + $filePath = $file['file']; + $status = $file['raw_status']; + + // Skip deleted files - they need to be restored + if (str_contains($status, 'D')) { + exec("cd $basePath && git checkout HEAD -- " . escapeshellarg($filePath) . " 2>&1", $output, $code); + } else { + // Reset modifications + exec("cd $basePath && git checkout -- " . escapeshellarg($filePath) . " 2>&1", $output, $code); + } + + if ($code === 0) { + $this->info(" Restored: $filePath"); + } else { + $this->error(" Failed to restore: $filePath"); + } + } + + // Clear caches after restoration + $this->call('cache:clear'); + $this->call('config:clear'); + $this->call('view:clear'); + + DnsSetting::set('last_integrity_status', 'restored'); + $this->info('File restoration complete.'); + + return 0; + } + + protected function sendNotification(array $modifiedFiles, array $untrackedFiles): void + { + $recipients = DnsSetting::get('admin_email_recipients'); + if (empty($recipients)) { + $this->warn('No admin email recipients configured. Skipping notification.'); + return; + } + + $hostname = gethostname(); + $subject = "[Jabali Security] File integrity alert on $hostname"; + + $message = "File integrity check detected unauthorized modifications:\n\n"; + + if (!empty($modifiedFiles)) { + $message .= "MODIFIED FILES:\n"; + foreach ($modifiedFiles as $file) { + $message .= " - {$file['file']} ({$file['status']})\n"; + } + $message .= "\n"; + } + + if (!empty($untrackedFiles)) { + $message .= "UNTRACKED FILES IN PROTECTED DIRECTORIES:\n"; + foreach ($untrackedFiles as $file) { + $message .= " - $file\n"; + } + $message .= "\n"; + } + + $message .= "To restore files, run: php artisan jabali:check-integrity --fix\n"; + $message .= "\nTime: " . now()->toDateTimeString(); + + foreach (explode(',', $recipients) as $email) { + $email = trim($email); + if (filter_var($email, FILTER_VALIDATE_EMAIL)) { + mail($email, $subject, $message); + } + } + + $this->info('Notification sent to admin.'); + } +} diff --git a/app/Console/Commands/Jabali/DomainCommand.php b/app/Console/Commands/Jabali/DomainCommand.php new file mode 100644 index 0000000..7b525ec --- /dev/null +++ b/app/Console/Commands/Jabali/DomainCommand.php @@ -0,0 +1,105 @@ +argument('action')) { + 'list' => $this->listDomains(), + 'create' => $this->createDomain(), + 'show' => $this->showDomain(), + 'delete' => $this->deleteDomain(), + default => $this->error("Unknown action. Use: list, create, show, delete") ?? 1, + }; + } + + private function listDomains(): int { + $domains = Domain::with('user')->get(); + if ($domains->isEmpty()) { $this->warn('No domains found.'); return 0; } + $this->table(['ID', 'Domain', 'User', 'Document Root', 'SSL', 'Created'], $domains->map(fn($d) => [$d->id, $d->domain, $d->user->email ?? '-', $d->document_root ?? '/var/www/'.$d->domain, $d->ssl_enabled ? '✓' : '✗', $d->created_at->format('Y-m-d')])->toArray()); + return 0; + } + + private function createDomain(): int { + $domain = $this->option('domain') ?? $this->ask('Domain name'); + + // Clean and validate domain + $domain = $this->cleanDomain($domain); + + // Validate FQDN format + if (!$this->isValidFqdn($domain)) { + $this->error("Invalid domain format: '$domain'"); + $this->line("Domain must be a valid FQDN (e.g., example.com, sub.example.com)"); + return 1; + } + + $userId = $this->option('user') ?? $this->ask('User ID or email'); + $user = is_numeric($userId) ? User::find($userId) : User::where('email', $userId)->first(); + if (!$user) { $this->error("User not found: $userId"); return 1; } + if (Domain::where('domain', $domain)->exists()) { $this->error("Domain already exists!"); return 1; } + + // Use proper document root structure + $documentRoot = "/home/{$user->username}/domains/{$domain}/public_html"; + + $d = Domain::create(['domain' => $domain, 'user_id' => $user->id, 'document_root' => $documentRoot]); + $this->info("✓ Created domain #{$d->id}: {$domain}"); + $this->line(" Document root: {$documentRoot}"); + return 0; + } + + private function cleanDomain(string $domain): string { + // Remove protocol + $domain = preg_replace('#^https?://#i', '', $domain); + // Remove trailing slash and path + $domain = strtok($domain, '/'); + // Remove port if present + $domain = strtok($domain, ':'); + // Lowercase + return strtolower(trim($domain)); + } + + private function isValidFqdn(string $domain): bool { + // Check basic structure + if (empty($domain) || strlen($domain) > 253) return false; + if (strpos($domain, ' ') !== false) return false; + if (!preg_match('/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+$/', $domain)) return false; + + // Must have at least one dot (e.g., example.com) + if (strpos($domain, '.') === false) return false; + + // TLD must be at least 2 chars + $parts = explode('.', $domain); + $tld = end($parts); + if (strlen($tld) < 2) return false; + + return true; + } + + private function showDomain(): int { + $domain = $this->findDomain(); + if (!$domain) return 1; + $this->table(['Field', 'Value'], [['ID', $domain->id], ['Domain', $domain->domain], ['User', $domain->user->email ?? '-'], ['Document Root', $domain->document_root], ['SSL', $domain->ssl_enabled ? 'Yes' : 'No'], ['Created', $domain->created_at]]); + return 0; + } + + private function deleteDomain(): int { + $domain = $this->findDomain(); + if (!$domain) return 1; + if (!$this->option('force') && !$this->confirm("Delete {$domain->domain}?")) return 0; + $domain->delete(); + $this->info("✓ Deleted domain #{$domain->id}"); + return 0; + } + + private function findDomain(): ?Domain { + $id = $this->option('id') ?? $this->ask('Domain ID or name'); + $domain = is_numeric($id) ? Domain::find($id) : Domain::where('domain', $id)->first(); + if (!$domain) $this->error("Domain not found: $id"); + return $domain; + } +} diff --git a/app/Console/Commands/Jabali/ImportProcessCommand.php b/app/Console/Commands/Jabali/ImportProcessCommand.php new file mode 100644 index 0000000..6fc3e0f --- /dev/null +++ b/app/Console/Commands/Jabali/ImportProcessCommand.php @@ -0,0 +1,389 @@ +argument('import_id'); + + $import = ServerImport::with('accounts')->find($importId); + if (!$import) { + $this->error("Import not found: $importId"); + return 1; + } + + $this->info("Processing import: {$import->name} (ID: {$import->id})"); + + $selectedAccountIds = $import->selected_accounts ?? []; + $options = $import->import_options ?? []; + + if (empty($selectedAccountIds)) { + $import->update([ + 'status' => 'failed', + 'current_task' => null, + ]); + $import->addError('No accounts selected for import'); + return 1; + } + + $accounts = ServerImportAccount::whereIn('id', $selectedAccountIds) + ->where('server_import_id', $import->id) + ->get(); + + $totalAccounts = $accounts->count(); + $completedAccounts = 0; + + $import->addLog("Starting import of $totalAccounts account(s)"); + + foreach ($accounts as $account) { + try { + $this->processAccount($import, $account, $options); + $completedAccounts++; + + $progress = (int) (($completedAccounts / $totalAccounts) * 100); + $import->update(['progress' => $progress]); + } catch (Exception $e) { + $account->update([ + 'status' => 'failed', + 'error' => $e->getMessage(), + ]); + $account->addLog("Import failed: " . $e->getMessage()); + $import->addError("Account {$account->source_username}: " . $e->getMessage()); + } + } + + $failedCount = $accounts->where('status', 'failed')->count(); + + if ($failedCount === $totalAccounts) { + $import->update([ + 'status' => 'failed', + 'current_task' => null, + 'completed_at' => now(), + 'progress' => 100, + ]); + } elseif ($failedCount > 0) { + $import->update([ + 'status' => 'completed', + 'current_task' => null, + 'completed_at' => now(), + 'progress' => 100, + ]); + $import->addLog("Completed with $failedCount failed account(s)"); + } else { + $import->update([ + 'status' => 'completed', + 'current_task' => null, + 'completed_at' => now(), + 'progress' => 100, + ]); + $import->addLog("All accounts imported successfully"); + } + + $this->info("Import completed. Success: " . ($totalAccounts - $failedCount) . ", Failed: $failedCount"); + + return 0; + } + + private function getAgent(): AgentClient + { + if ($this->agent === null) { + $this->agent = new AgentClient(); + } + return $this->agent; + } + + private function processAccount(ServerImport $import, ServerImportAccount $account, array $options): void + { + $account->update([ + 'status' => 'importing', + 'progress' => 0, + 'current_task' => 'Starting import...', + ]); + $account->addLog("Starting import for account: {$account->source_username}"); + + $import->update(['current_task' => "Importing account: {$account->source_username}"]); + + // Step 1: Create user + $account->update(['current_task' => 'Creating user...', 'progress' => 10]); + $user = $this->createUser($account); + $account->addLog("Created user: {$user->email}"); + + // Step 2: Create domains + if ($account->main_domain) { + $account->update(['current_task' => 'Creating domains...', 'progress' => 20]); + $this->createDomains($account, $user); + $account->addLog("Created domains"); + } + + // Step 3: Import files + if ($options['files'] ?? true) { + $account->update(['current_task' => 'Importing files...', 'progress' => 40]); + $this->importFiles($import, $account, $user); + $account->addLog("Files imported"); + } + + // Step 4: Import databases + if (($options['databases'] ?? true) && !empty($account->databases)) { + $account->update(['current_task' => 'Importing databases...', 'progress' => 60]); + $this->importDatabases($import, $account, $user); + $account->addLog("Databases imported"); + } + + // Step 5: Import emails + if (($options['emails'] ?? true) && !empty($account->email_accounts)) { + $account->update(['current_task' => 'Importing email accounts...', 'progress' => 80]); + $this->importEmails($import, $account, $user); + $account->addLog("Email accounts imported"); + } + + $account->update([ + 'status' => 'completed', + 'progress' => 100, + 'current_task' => null, + ]); + $account->addLog("Import completed successfully"); + } + + private function createUser(ServerImportAccount $account): User + { + // Check if user already exists with this username + $existingUser = User::where('username', $account->target_username)->first(); + if ($existingUser) { + $account->addLog("User already exists: {$account->target_username}"); + return $existingUser; + } + + // Generate a temporary password + $password = Str::random(16); + + // Create user via agent + $result = $this->getAgent()->createUser($account->target_username, $password); + + if (!($result['success'] ?? false)) { + throw new Exception("Failed to create system user: " . ($result['error'] ?? 'Unknown error')); + } + + // Create user in database + $user = User::create([ + 'name' => $account->target_username, + 'email' => $account->email ?: "{$account->target_username}@localhost", + 'username' => $account->target_username, + 'password' => Hash::make($password), + ]); + + $account->addLog("Created user with temporary password. User should reset password."); + + return $user; + } + + private function createDomains(ServerImportAccount $account, User $user): void + { + // Create main domain + if ($account->main_domain) { + $existingDomain = Domain::where('domain', $account->main_domain)->first(); + if (!$existingDomain) { + $result = $this->getAgent()->domainCreate($user->username, $account->main_domain); + + if ($result['success'] ?? false) { + Domain::create([ + 'domain' => $account->main_domain, + 'user_id' => $user->id, + 'document_root' => "/home/{$user->username}/domains/{$account->main_domain}/public", + 'is_active' => true, + ]); + $account->addLog("Created main domain: {$account->main_domain}"); + } else { + $account->addLog("Warning: Failed to create main domain: " . ($result['error'] ?? 'Unknown')); + } + } else { + $account->addLog("Main domain already exists: {$account->main_domain}"); + } + } + + // Create addon domains + foreach ($account->addon_domains ?? [] as $domain) { + $existingDomain = Domain::where('domain', $domain)->first(); + if (!$existingDomain) { + $result = $this->getAgent()->domainCreate($user->username, $domain); + + if ($result['success'] ?? false) { + Domain::create([ + 'domain' => $domain, + 'user_id' => $user->id, + 'document_root' => "/home/{$user->username}/domains/{$domain}/public", + 'is_active' => true, + ]); + $account->addLog("Created addon domain: {$domain}"); + } else { + $account->addLog("Warning: Failed to create addon domain: {$domain}"); + } + } + } + } + + private function importFiles(ServerImport $import, ServerImportAccount $account, User $user): void + { + if ($import->import_method !== 'backup_file' || !$import->backup_path) { + $account->addLog("File import skipped - not a backup file import"); + return; + } + + $backupPath = Storage::disk('local')->path($import->backup_path); + if (!file_exists($backupPath)) { + $account->addLog("Warning: Backup file not found"); + return; + } + + $extractDir = "/tmp/import_{$import->id}_{$account->id}_" . time(); + if (!mkdir($extractDir, 0755, true)) { + $account->addLog("Warning: Failed to create extraction directory"); + return; + } + + try { + $username = $account->source_username; + + if ($import->source_type === 'cpanel') { + // Extract home directory from cPanel backup + $cmd = "tar -xzf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) . + " --wildcards '*/{$username}/homedir/*' '*/homedir/*' 2>/dev/null"; + exec($cmd, $output, $code); + + // Find extracted files + $homeDirs = glob("$extractDir/**/homedir", GLOB_ONLYDIR) ?: + glob("$extractDir/*/homedir", GLOB_ONLYDIR) ?: + glob("$extractDir/homedir", GLOB_ONLYDIR) ?: []; + + foreach ($homeDirs as $homeDir) { + // Copy public_html to the domain + $publicHtml = "$homeDir/public_html"; + if (is_dir($publicHtml) && $account->main_domain) { + $destDir = "/home/{$user->username}/domains/{$account->main_domain}/public"; + if (is_dir($destDir)) { + exec("cp -r " . escapeshellarg($publicHtml) . "/* " . escapeshellarg($destDir) . "/ 2>&1"); + exec("chown -R " . escapeshellarg($user->username) . ":" . escapeshellarg($user->username) . " " . escapeshellarg($destDir) . " 2>&1"); + $account->addLog("Copied public_html to {$account->main_domain}"); + } + } + } + } else { + // Extract from DirectAdmin backup + $cmd = "tar -xzf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) . + " --wildcards 'domains/*' 'backup/domains/*' 2>/dev/null"; + exec($cmd, $output, $code); + + // Find domain directories + $domainDirs = glob("$extractDir/**/domains/*", GLOB_ONLYDIR) ?: + glob("$extractDir/domains/*", GLOB_ONLYDIR) ?: []; + + foreach ($domainDirs as $domainDir) { + $domain = basename($domainDir); + $publicHtml = "$domainDir/public_html"; + + if (is_dir($publicHtml)) { + $destDir = "/home/{$user->username}/domains/{$domain}/public"; + if (is_dir($destDir)) { + exec("cp -r " . escapeshellarg($publicHtml) . "/* " . escapeshellarg($destDir) . "/ 2>&1"); + exec("chown -R " . escapeshellarg($user->username) . ":" . escapeshellarg($user->username) . " " . escapeshellarg($destDir) . " 2>&1"); + $account->addLog("Copied files for domain: {$domain}"); + } + } + } + } + } finally { + // Cleanup + exec("rm -rf " . escapeshellarg($extractDir)); + } + } + + private function importDatabases(ServerImport $import, ServerImportAccount $account, User $user): void + { + if ($import->import_method !== 'backup_file' || !$import->backup_path) { + $account->addLog("Database import skipped - not a backup file import"); + return; + } + + $backupPath = Storage::disk('local')->path($import->backup_path); + if (!file_exists($backupPath)) { + return; + } + + $extractDir = "/tmp/import_db_{$import->id}_{$account->id}_" . time(); + if (!mkdir($extractDir, 0755, true)) { + return; + } + + try { + // Extract MySQL dumps + if ($import->source_type === 'cpanel') { + $cmd = "tar -xzf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) . + " --wildcards '*/mysql/*.sql' 'mysql/*.sql' 2>/dev/null"; + } else { + $cmd = "tar -xzf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) . + " --wildcards 'backup/databases/*.sql' 'databases/*.sql' 2>/dev/null"; + } + exec($cmd, $output, $code); + + // Find SQL files + $sqlFiles = []; + exec("find " . escapeshellarg($extractDir) . " -name '*.sql' -type f 2>/dev/null", $sqlFiles); + + foreach ($sqlFiles as $sqlFile) { + $dbName = basename($sqlFile, '.sql'); + + // Create database name with user prefix + $newDbName = substr($user->username . '_' . preg_replace('/^[^_]+_/', '', $dbName), 0, 64); + + // Create database via agent + $result = $this->getAgent()->mysqlCreateDatabase($user->username, $newDbName); + + if ($result['success'] ?? false) { + // Import data + $cmd = "mysql " . escapeshellarg($newDbName) . " < " . escapeshellarg($sqlFile) . " 2>&1"; + exec($cmd, $importOutput, $importCode); + + if ($importCode === 0) { + $account->addLog("Imported database: {$newDbName}"); + } else { + $account->addLog("Warning: Database created but import failed: {$newDbName}"); + } + } else { + $account->addLog("Warning: Failed to create database: {$newDbName}"); + } + } + } finally { + exec("rm -rf " . escapeshellarg($extractDir)); + } + } + + private function importEmails(ServerImport $import, ServerImportAccount $account, User $user): void + { + // Email import is complex and requires the email system to be configured + // For now, just log the email accounts that would be created + foreach ($account->email_accounts ?? [] as $emailAccount) { + $account->addLog("Email account found (not imported): {$emailAccount}@{$account->main_domain}"); + } + + $account->addLog("Note: Email accounts must be recreated manually"); + } +} diff --git a/app/Console/Commands/Jabali/MigrateRedisUsersCommand.php b/app/Console/Commands/Jabali/MigrateRedisUsersCommand.php new file mode 100644 index 0000000..7356a4d --- /dev/null +++ b/app/Console/Commands/Jabali/MigrateRedisUsersCommand.php @@ -0,0 +1,124 @@ +agent = new AgentClient(); + } + + public function handle(): int + { + $dryRun = $this->option('dry-run'); + $force = $this->option('force'); + + $this->info('Migrating existing users to Redis ACL...'); + + if ($dryRun) { + $this->warn('DRY RUN - no changes will be made'); + } + + $this->newLine(); + + $users = User::where('role', 'user')->get(); + + if ($users->isEmpty()) { + $this->info('No users found to migrate.'); + return 0; + } + + $this->info("Found {$users->count()} users to process..."); + $this->newLine(); + + foreach ($users as $user) { + $this->processUser($user, $dryRun, $force); + } + + $this->newLine(); + $this->info('Migration Complete'); + $this->table( + ['Metric', 'Count'], + [ + ['Created', $this->created], + ['Skipped', $this->skipped], + ['Failed', $this->failed], + ] + ); + + return $this->failed > 0 ? 1 : 0; + } + + private function processUser(User $user, bool $dryRun, bool $force): void + { + $homeDir = "/home/{$user->username}"; + $credFile = "{$homeDir}/.redis_credentials"; + + // Check if credentials file already exists + if (file_exists($credFile) && !$force) { + $this->line(" [skip] {$user->username} - credentials file already exists"); + $this->skipped++; + return; + } + + if ($dryRun) { + $this->line(" [would create] {$user->username}"); + $this->created++; + return; + } + + $this->line(" Processing: {$user->username}..."); + + // Generate password before sending to agent so we can save it + $redisPassword = bin2hex(random_bytes(16)); // 32 char password + $redisUser = 'jabali_' . $user->username; + + try { + $result = $this->agent->send('redis.create_user', [ + 'username' => $user->username, + 'password' => $redisPassword, + ]); + + if ($result['success'] ?? false) { + // Save credentials file + $credContent = "REDIS_USER={$redisUser}\n" . + "REDIS_PASS={$redisPassword}\n" . + "REDIS_PREFIX={$user->username}:\n"; + + file_put_contents($credFile, $credContent); + chmod($credFile, 0600); + chown($credFile, $user->username); + chgrp($credFile, $user->username); + + $this->info(" ✓ Created Redis user for {$user->username}"); + $this->created++; + } else { + $error = $result['error'] ?? 'Unknown error'; + $this->error(" ✗ Failed for {$user->username}: {$error}"); + $this->failed++; + } + } catch (\Exception $e) { + $this->error(" ✗ Exception for {$user->username}: {$e->getMessage()}"); + $this->failed++; + } + } +} diff --git a/app/Console/Commands/Jabali/SslCheckCommand.php b/app/Console/Commands/Jabali/SslCheckCommand.php new file mode 100644 index 0000000..a919891 --- /dev/null +++ b/app/Console/Commands/Jabali/SslCheckCommand.php @@ -0,0 +1,463 @@ +agent = new AgentClient(); + } + + public function handle(): int + { + $this->initializeLogging(); + $this->log('Starting SSL certificate check...'); + $this->info('Starting SSL certificate check...'); + $this->newLine(); + + $domain = $this->option('domain'); + $issueOnly = $this->option('issue-only'); + $renewOnly = $this->option('renew-only'); + + if ($domain) { + $this->processSingleDomain($domain); + } else { + if (!$renewOnly) { + $this->issueMissingCertificates(); + } + + if (!$issueOnly) { + $this->renewExpiringCertificates(); + } + + // Check for certificates expiring very soon (7 days) and notify + $this->notifyExpiringSoon(); + } + + $this->newLine(); + $this->info('SSL Check Complete'); + $this->table( + ['Metric', 'Count'], + [ + ['Issued', $this->issued], + ['Renewed', $this->renewed], + ['Failed', $this->failed], + ['Skipped', $this->skipped], + ] + ); + + // Log summary + $this->log(''); + $this->log('=== SSL Check Complete ==='); + $this->log("Issued: {$this->issued}"); + $this->log("Renewed: {$this->renewed}"); + $this->log("Failed: {$this->failed}"); + $this->log("Skipped: {$this->skipped}"); + + // Save log file + $this->saveLog(); + + // Clean old logs (older than 3 months) + $this->cleanOldLogs(); + + return $this->failed > 0 ? 1 : 0; + } + + private function initializeLogging(): void + { + $logDir = storage_path('logs/ssl'); + + try { + if (!is_dir($logDir)) { + mkdir($logDir, 0775, true); + } + + // Ensure directory is writable + if (!is_writable($logDir)) { + chmod($logDir, 0775); + } + + $this->logFile = $logDir . '/ssl-check-' . date('Y-m-d_H-i-s') . '.log'; + } catch (\Exception $e) { + // Fall back to temp directory if storage is not writable + $this->logFile = sys_get_temp_dir() . '/ssl-check-' . date('Y-m-d_H-i-s') . '.log'; + } + + $this->logEntries = []; + } + + private function log(string $message, string $level = 'INFO'): void + { + $timestamp = date('Y-m-d H:i:s'); + $this->logEntries[] = "[{$timestamp}] [{$level}] {$message}"; + } + + private function saveLog(): void + { + if (empty($this->logFile) || empty($this->logEntries)) { + return; + } + + try { + $content = implode("\n", $this->logEntries) . "\n"; + + // Ensure parent directory exists + $logDir = dirname($this->logFile); + if (!is_dir($logDir)) { + @mkdir($logDir, 0775, true); + } + + if (@file_put_contents($this->logFile, $content) !== false) { + // Also create/update a symlink to latest log + $latestLink = storage_path('logs/ssl/latest.log'); + @unlink($latestLink); + @symlink($this->logFile, $latestLink); + + $this->line("Log saved to: {$this->logFile}"); + } else { + $this->warn("Could not save log to: {$this->logFile}"); + } + } catch (\Exception $e) { + $this->warn("Log save failed: {$e->getMessage()}"); + } + } + + private function cleanOldLogs(): void + { + $logDir = storage_path('logs/ssl'); + $cutoffDate = now()->subMonths(3); + $deletedCount = 0; + + foreach (glob("{$logDir}/ssl-check-*.log") as $file) { + $fileTime = filemtime($file); + if ($fileTime < $cutoffDate->timestamp) { + unlink($file); + $deletedCount++; + } + } + + if ($deletedCount > 0) { + $this->log("Cleaned up {$deletedCount} old log files (older than 3 months)"); + } + } + + private function processSingleDomain(string $domainName): void + { + $domain = Domain::where('domain', $domainName)->with(['user', 'sslCertificate'])->first(); + + if (!$domain) { + $this->log("Domain not found: {$domainName}", 'ERROR'); + $this->error("Domain not found: {$domainName}"); + $this->failed++; + return; + } + + $this->log("Processing domain: {$domainName}"); + $this->line("Processing domain: {$domainName}"); + + $ssl = $domain->sslCertificate; + + if (!$ssl || $ssl->status === 'failed') { + $this->issueCertificate($domain); + } elseif ($ssl->needsRenewal()) { + $this->renewCertificate($domain); + } else { + $this->log("Certificate is valid for {$domainName}, expires: {$ssl->expires_at->format('Y-m-d')}"); + $this->line(" - Certificate is valid, expires: {$ssl->expires_at->format('Y-m-d')}"); + $this->skipped++; + } + } + + private function issueMissingCertificates(): void + { + $this->log('Checking domains without SSL certificates...'); + $this->info('Checking domains without SSL certificates...'); + + $domains = Domain::whereDoesntHave('sslCertificate') + ->orWhereHas('sslCertificate', function ($q) { + $q->where('status', 'failed') + ->where('renewal_attempts', '<', 3) + ->where(function ($q2) { + $q2->whereNull('last_check_at') + ->orWhere('last_check_at', '<', now()->subHours(6)); + }); + }) + ->with(['user', 'sslCertificate']) + ->get(); + + $this->log("Found {$domains->count()} domains without valid SSL"); + $this->line("Found {$domains->count()} domains without valid SSL"); + + foreach ($domains as $domain) { + $this->issueCertificate($domain); + } + } + + private function renewExpiringCertificates(): void + { + $this->log('Checking certificates that need renewal...'); + $this->info('Checking certificates that need renewal...'); + + $certificates = SslCertificate::where('auto_renew', true) + ->where('type', 'lets_encrypt') + ->where('status', 'active') + ->where('expires_at', '<=', now()->addDays(30)) + ->where('renewal_attempts', '<', 5) + ->with(['domain.user']) + ->get(); + + $this->log("Found {$certificates->count()} certificates needing renewal"); + $this->line("Found {$certificates->count()} certificates needing renewal"); + + foreach ($certificates as $ssl) { + if ($ssl->domain) { + $this->renewCertificate($ssl->domain); + } + } + } + + private function notifyExpiringSoon(): void + { + $certificates = SslCertificate::where('status', 'active') + ->where('expires_at', '<=', now()->addDays(7)) + ->where('expires_at', '>', now()) + ->with(['domain']) + ->get(); + + foreach ($certificates as $ssl) { + if ($ssl->domain) { + $daysLeft = (int) now()->diffInDays($ssl->expires_at); + $this->log("Certificate expiring soon: {$ssl->domain->domain} ({$daysLeft} days left)", 'WARN'); + AdminNotificationService::sslExpiring($ssl->domain->domain, $daysLeft); + } + } + } + + private function issueCertificate(Domain $domain): void + { + if (!$domain->user) { + $this->log("Skipping {$domain->domain}: No user associated", 'WARN'); + $this->warn(" Skipping {$domain->domain}: No user associated"); + $this->skipped++; + return; + } + + // Check if domain DNS points to this server + if (!$this->domainPointsToServer($domain->domain)) { + $this->log("Skipping {$domain->domain}: DNS does not point to this server", 'WARN'); + $this->warn(" Skipping {$domain->domain}: DNS does not point to this server"); + $this->skipped++; + return; + } + + $this->log("Issuing SSL for: {$domain->domain} (user: {$domain->user->username})"); + $this->line(" Issuing SSL for: {$domain->domain}"); + + try { + $result = $this->agent->sslIssue( + $domain->domain, + $domain->user->username, + $domain->user->email, + true + ); + + if ($result['success'] ?? false) { + $expiresAt = isset($result['valid_to']) ? Carbon::parse($result['valid_to']) : now()->addMonths(3); + + SslCertificate::updateOrCreate( + ['domain_id' => $domain->id], + [ + 'type' => 'lets_encrypt', + 'status' => 'active', + 'issuer' => "Let's Encrypt", + 'certificate' => $result['certificate'] ?? null, + 'issued_at' => now(), + 'expires_at' => $expiresAt, + 'last_check_at' => now(), + 'last_error' => null, + 'renewal_attempts' => 0, + 'auto_renew' => true, + ] + ); + + $domain->update(['ssl_enabled' => true]); + + $this->log("SUCCESS: Certificate issued for {$domain->domain}, expires: {$expiresAt->format('Y-m-d')}", 'SUCCESS'); + $this->info(" ✓ Certificate issued successfully"); + $this->issued++; + } else { + $error = $result['error'] ?? 'Unknown error'; + + $ssl = SslCertificate::firstOrNew(['domain_id' => $domain->id]); + $ssl->type = 'lets_encrypt'; + $ssl->status = 'failed'; + $ssl->last_check_at = now(); + $ssl->last_error = $error; + $ssl->increment('renewal_attempts'); + $ssl->save(); + + $this->log("FAILED: Certificate issue for {$domain->domain}: {$error}", 'ERROR'); + $this->error(" ✗ Failed: {$error}"); + $this->failed++; + + // Send admin notification + AdminNotificationService::sslError($domain->domain, $error); + } + } catch (Exception $e) { + $this->log("EXCEPTION: Certificate issue for {$domain->domain}: {$e->getMessage()}", 'ERROR'); + $this->error(" ✗ Exception: {$e->getMessage()}"); + $this->failed++; + + // Send admin notification + AdminNotificationService::sslError($domain->domain, $e->getMessage()); + } + } + + private function renewCertificate(Domain $domain): void + { + if (!$domain->user) { + $this->log("Skipping renewal for {$domain->domain}: No user associated", 'WARN'); + $this->warn(" Skipping {$domain->domain}: No user associated"); + $this->skipped++; + return; + } + + // Check if domain DNS still points to this server + if (!$this->domainPointsToServer($domain->domain)) { + $this->log("Skipping renewal for {$domain->domain}: DNS does not point to this server", 'WARN'); + $this->warn(" Skipping {$domain->domain}: DNS does not point to this server"); + $this->skipped++; + return; + } + + $this->log("Renewing SSL for: {$domain->domain} (user: {$domain->user->username})"); + $this->line(" Renewing SSL for: {$domain->domain}"); + + try { + $result = $this->agent->sslRenew($domain->domain, $domain->user->username); + + if ($result['success'] ?? false) { + $ssl = $domain->sslCertificate; + $expiresAt = isset($result['valid_to']) ? Carbon::parse($result['valid_to']) : now()->addMonths(3); + + if ($ssl) { + $ssl->update([ + 'status' => 'active', + 'issued_at' => now(), + 'expires_at' => $expiresAt, + 'last_check_at' => now(), + 'last_error' => null, + 'renewal_attempts' => 0, + ]); + } + + $this->log("SUCCESS: Certificate renewed for {$domain->domain}, expires: {$expiresAt->format('Y-m-d')}", 'SUCCESS'); + $this->info(" ✓ Certificate renewed successfully"); + $this->renewed++; + } else { + $error = $result['error'] ?? 'Unknown error'; + + $ssl = $domain->sslCertificate; + if ($ssl) { + $ssl->incrementRenewalAttempts(); + $ssl->update(['last_error' => $error]); + } + + $this->log("FAILED: Certificate renewal for {$domain->domain}: {$error}", 'ERROR'); + $this->error(" ✗ Failed: {$error}"); + $this->failed++; + + // Send admin notification + AdminNotificationService::sslError($domain->domain, "Renewal failed: {$error}"); + } + } catch (Exception $e) { + $this->log("EXCEPTION: Certificate renewal for {$domain->domain}: {$e->getMessage()}", 'ERROR'); + $this->error(" ✗ Exception: {$e->getMessage()}"); + $this->failed++; + + // Send admin notification + AdminNotificationService::sslError($domain->domain, "Renewal exception: {$e->getMessage()}"); + } + } + + private function domainPointsToServer(string $domain): bool + { + // Get server's public IP + $serverIp = $this->getServerPublicIp(); + if (!$serverIp) { + // If we can't determine server IP, assume it's okay to try + return true; + } + + // Get domain's DNS resolution + $domainIp = gethostbyname($domain); + + // gethostbyname returns the original string if resolution fails + if ($domainIp === $domain) { + return false; + } + + return $domainIp === $serverIp; + } + + private function getServerPublicIp(): ?string + { + static $cachedIp = null; + + if ($cachedIp !== null) { + return $cachedIp ?: null; + } + + // Try multiple services to get public IP + $services = [ + 'https://api.ipify.org', + 'https://ipv4.icanhazip.com', + 'https://checkip.amazonaws.com', + ]; + + foreach ($services as $service) { + $ip = @file_get_contents($service, false, stream_context_create([ + 'http' => ['timeout' => 5], + ])); + + if ($ip) { + $ip = trim($ip); + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + $cachedIp = $ip; + return $ip; + } + } + } + + $cachedIp = ''; + return null; + } +} diff --git a/app/Console/Commands/Jabali/UpgradeCommand.php b/app/Console/Commands/Jabali/UpgradeCommand.php new file mode 100644 index 0000000..0021e89 --- /dev/null +++ b/app/Console/Commands/Jabali/UpgradeCommand.php @@ -0,0 +1,509 @@ +basePath = base_path(); + $this->versionFile = $this->basePath.'/VERSION'; + } + + public function handle(): int + { + if ($this->option('check')) { + return $this->checkForUpdates(); + } + + return $this->performUpgrade(); + } + + private function checkForUpdates(): int + { + $this->info('Checking for updates...'); + + $currentVersion = $this->getCurrentVersion(); + $this->line("Current version: {$currentVersion}"); + + try { + $this->ensureGitRepository(); + // Fetch from remote without merging + $this->executeCommandOrFail('git fetch origin main'); + + // Check if there are updates + $behindCount = trim($this->executeCommandOrFail('git rev-list HEAD..origin/main --count')); + + if ($behindCount === '0') { + $this->info('Jabali Panel is up to date!'); + + return 0; + } + + $this->warn("Updates available: {$behindCount} commit(s) behind"); + + // Show recent commits + $this->line("\nRecent changes:"); + $commits = $this->executeCommandOrFail('git log HEAD..origin/main --oneline -10'); + if ($commits !== '') { + $this->line($commits); + } + + return 0; + } catch (Exception $e) { + $this->error('Failed to check for updates: '.$e->getMessage()); + + return 1; + } + } + + private function performUpgrade(): int + { + $this->info('Starting Jabali Panel upgrade...'); + $this->newLine(); + + $currentVersion = $this->getCurrentVersion(); + $this->line("Current version: {$currentVersion}"); + + // Step 1: Check git status + $this->info('[1/9] Checking repository status...'); + try { + $this->ensureGitRepository(); + $statusResult = $this->executeCommand('git status --porcelain'); + if ($statusResult['exitCode'] !== 0) { + throw new Exception($statusResult['output'] ?: 'Unable to read git status.'); + } + + $status = $statusResult['output']; + if (! empty(trim($status)) && ! $this->option('force')) { + $this->warn('Working directory has uncommitted changes:'); + $this->line($status); + if (! $this->confirm('Continue anyway? Local changes may be overwritten.')) { + $this->info('Upgrade cancelled.'); + + return 0; + } + } + } catch (Exception $e) { + $this->error('Git check failed: '.$e->getMessage()); + + return 1; + } + + // Step 2: Fetch updates + $this->info('[2/9] Fetching updates from repository...'); + try { + $this->executeCommandOrFail('git fetch origin main'); + } catch (Exception $e) { + $this->error('Failed to fetch updates: '.$e->getMessage()); + + return 1; + } + + // Step 3: Check if updates available + $behindCount = trim($this->executeCommandOrFail('git rev-list HEAD..origin/main --count')); + if ($behindCount === '0' && ! $this->option('force')) { + $this->info('Already up to date!'); + + return 0; + } + + // Step 4: Pull changes + $oldHead = trim($this->executeCommandOrFail('git rev-parse HEAD')); + $this->info('[3/9] Pulling latest changes...'); + try { + $pullResult = $this->executeCommand('git pull --ff-only origin main'); + if ($pullResult['exitCode'] !== 0) { + throw new Exception($pullResult['output'] ?: 'Git pull failed.'); + } + if ($pullResult['output'] !== '') { + $this->line($pullResult['output']); + } + } catch (Exception $e) { + $this->error('Failed to pull changes: '.$e->getMessage()); + $this->warn('You may need to resolve conflicts manually.'); + + return 1; + } + + $newHead = trim($this->executeCommandOrFail('git rev-parse HEAD')); + $changedFiles = $this->getChangedFiles($oldHead, $newHead); + + $hasVendor = File::exists($this->basePath.'/vendor/autoload.php'); + $hasManifest = File::exists($this->basePath.'/public/build/manifest.json'); + $hasPackageJson = File::exists($this->basePath.'/package.json'); + + $shouldRunComposer = $this->shouldRunComposerInstall($changedFiles, $this->option('force'), $hasVendor); + $shouldRunNpm = $this->shouldRunNpmBuild($changedFiles, $this->option('force'), $hasManifest, $hasPackageJson); + $shouldRunMigrations = $this->shouldRunMigrations($changedFiles, $this->option('force')); + + // Step 5: Install composer dependencies + $this->info('[4/9] Installing PHP dependencies...'); + if ($shouldRunComposer) { + try { + $this->ensureCommandAvailable('composer'); + $composerResult = $this->executeCommand('composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader', 1200); + if ($composerResult['exitCode'] !== 0) { + throw new Exception($composerResult['output'] ?: 'Composer install failed.'); + } + if ($composerResult['output'] !== '') { + $this->line($composerResult['output']); + } + } catch (Exception $e) { + $this->error('Failed to install dependencies: '.$e->getMessage()); + + return 1; + } + } else { + $this->line('No composer changes detected, skipping.'); + } + + // Step 5b: Install npm dependencies and build assets + $this->info('[5/9] Building frontend assets...'); + if ($shouldRunNpm) { + try { + $this->ensureCommandAvailable('npm'); + $npmInstall = File::exists($this->basePath.'/package-lock.json') ? 'npm ci' : 'npm install'; + $installResult = $this->executeCommand($npmInstall, 1200); + if ($installResult['exitCode'] !== 0) { + throw new Exception($installResult['output'] ?: 'npm install failed.'); + } + if ($installResult['output'] !== '') { + $this->line($installResult['output']); + } + + $buildResult = $this->executeCommand('npm run build', 1200); + if ($buildResult['exitCode'] !== 0) { + throw new Exception($buildResult['output'] ?: 'npm build failed.'); + } + if ($buildResult['output'] !== '') { + $this->line($buildResult['output']); + } + } catch (Exception $e) { + $this->error('Asset build failed: '.$e->getMessage()); + + return 1; + } + } else { + $this->line('No frontend changes detected, skipping.'); + } + + // Step 6: Run migrations + $this->info('[6/9] Running database migrations...'); + if ($shouldRunMigrations) { + try { + Artisan::call('migrate', ['--force' => true]); + $this->line(Artisan::output()); + } catch (Exception $e) { + $this->error('Migration failed: '.$e->getMessage()); + + return 1; + } + } else { + $this->line('No migration changes detected, skipping.'); + } + + // Step 7: Clear caches + $this->info('[7/9] Clearing caches...'); + try { + Artisan::call('optimize:clear'); + $this->line(Artisan::output()); + } catch (Exception $e) { + $this->warn('Cache clear warning: '.$e->getMessage()); + } + + // Step 8: Setup Redis ACL if not configured + $this->info('[8/9] Checking Redis ACL configuration...'); + $this->setupRedisAcl(); + + // Step 9: Restart services + $this->info('[9/9] Restarting services...'); + $this->restartServices(); + + $newVersion = $this->getCurrentVersion(); + $this->newLine(); + $this->info("Upgrade complete! Version: {$newVersion}"); + + return 0; + } + + private function getCurrentVersion(): string + { + if (! File::exists($this->versionFile)) { + return 'unknown'; + } + + $content = File::get($this->versionFile); + if (preg_match('/VERSION=(.+)/', $content, $matches)) { + return trim($matches[1]); + } + + return 'unknown'; + } + + private function executeCommand(string $command, int $timeout = 600): array + { + $process = Process::fromShellCommandline($command, $this->basePath, $this->getCommandEnvironment()); + $process->setTimeout($timeout); + $process->run(); + $output = trim($process->getOutput().$process->getErrorOutput()); + + return [ + 'exitCode' => $process->getExitCode() ?? 1, + 'output' => $output, + ]; + } + + private function executeCommandOrFail(string $command, int $timeout = 600): string + { + $result = $this->executeCommand($command, $timeout); + if ($result['exitCode'] !== 0) { + throw new Exception($result['output'] ?: "Command failed: {$command}"); + } + + return $result['output']; + } + + private function getCommandEnvironment(): array + { + $path = getenv('PATH') ?: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'; + + return [ + 'PATH' => $path, + 'COMPOSER_ALLOW_SUPERUSER' => '1', + ]; + } + + private function ensureCommandAvailable(string $command): void + { + $result = $this->executeCommand("command -v {$command}"); + if ($result['exitCode'] !== 0 || $result['output'] === '') { + throw new Exception("Required command not found: {$command}"); + } + } + + private function ensureGitRepository(): void + { + if (! File::isDirectory($this->basePath.'/.git')) { + throw new Exception('Not a git repository.'); + } + } + + private function getChangedFiles(string $from, string $to): array + { + if ($from === $to) { + return []; + } + + $output = $this->executeCommandOrFail("git diff --name-only {$from}..{$to}"); + if ($output === '') { + return []; + } + + return array_values(array_filter(array_map('trim', explode("\n", $output)))); + } + + protected function shouldRunComposerInstall(array $changedFiles, bool $force, bool $hasVendor): bool + { + if ($force || ! $hasVendor) { + return true; + } + + return $this->hasChangedFile($changedFiles, ['composer.json', 'composer.lock']); + } + + protected function shouldRunNpmBuild(array $changedFiles, bool $force, bool $hasManifest, bool $hasPackageJson): bool + { + if (! $hasPackageJson) { + return false; + } + + if ($force || ! $hasManifest) { + return true; + } + + if ($this->hasChangedFile($changedFiles, [ + 'package.json', + 'package-lock.json', + 'vite.config.js', + 'postcss.config.js', + 'tailwind.config.js', + ])) { + return true; + } + + return $this->hasChangedPathPrefix($changedFiles, 'resources/'); + } + + protected function shouldRunMigrations(array $changedFiles, bool $force): bool + { + if ($force) { + return true; + } + + return $this->hasChangedPathPrefix($changedFiles, 'database/migrations/'); + } + + protected function hasChangedFile(array $changedFiles, array $targets): bool + { + foreach ($targets as $target) { + if (in_array($target, $changedFiles, true)) { + return true; + } + } + + return false; + } + + protected function hasChangedPathPrefix(array $changedFiles, string $prefix): bool + { + foreach ($changedFiles as $file) { + if (str_starts_with($file, $prefix)) { + return true; + } + } + + return false; + } + + private function restartServices(): void + { + try { + Artisan::call('queue:restart'); + $this->line(' - queue restarted'); + } catch (Exception $e) { + $this->warn('Queue restart warning: '.$e->getMessage()); + } + + if (! $this->isRunningAsRoot()) { + $this->warn('Skipping system service reloads (requires root).'); + + return; + } + + $agentResult = $this->executeCommand('systemctl restart jabali-agent'); + if ($agentResult['exitCode'] === 0) { + $this->line(' - jabali-agent restarted'); + } else { + $this->warn(' - jabali-agent restart failed'); + } + + $fpmResult = $this->executeCommand('systemctl reload php*-fpm'); + if ($fpmResult['exitCode'] === 0) { + $this->line(' - PHP-FPM reloaded (all versions)'); + } else { + $this->warn(' - PHP-FPM reload failed'); + } + } + + private function isRunningAsRoot(): bool + { + if (function_exists('posix_geteuid')) { + return posix_geteuid() === 0; + } + + return getmyuid() === 0; + } + + private function setupRedisAcl(): void + { + $credFile = '/root/.jabali_redis_credentials'; + $aclFile = '/etc/redis/users.acl'; + + // Check if Redis ACL is already configured + if (File::exists($credFile) && File::exists($aclFile)) { + $this->line(' - Redis ACL already configured'); + + return; + } + + // Check if we have permission to write to /root/ + if (! is_writable('/root') && ! File::exists($credFile)) { + $this->line(' - Skipping Redis ACL setup (requires root privileges)'); + + return; + } + + $this->line(' - Setting up Redis ACL...'); + + // Generate admin password + $password = bin2hex(random_bytes(16)); + + // Save credentials + File::put($credFile, "REDIS_ADMIN_PASSWORD={$password}\n"); + chmod($credFile, 0600); + + // Create ACL file + $aclContent = "user default off\nuser jabali_admin on >{$password} ~* &* +@all\n"; + File::put($aclFile, $aclContent); + chmod($aclFile, 0640); + chown($aclFile, 'redis'); + chgrp($aclFile, 'redis'); + + // Check if redis.conf has aclfile directive + $redisConf = '/etc/redis/redis.conf'; + if (File::exists($redisConf)) { + $conf = File::get($redisConf); + if (strpos($conf, 'aclfile') === false) { + // Add aclfile directive + $conf .= "\n# ACL configuration\naclfile /etc/redis/users.acl\n"; + File::put($redisConf, $conf); + } + } + + // Update Laravel .env with Redis credentials + $envFile = base_path('.env'); + if (File::exists($envFile)) { + $env = File::get($envFile); + + // Update or add Redis credentials + if (strpos($env, 'REDIS_USERNAME=') === false) { + $env = preg_replace( + '/REDIS_HOST=.*/m', + "REDIS_HOST=127.0.0.1\nREDIS_USERNAME=jabali_admin\nREDIS_PASSWORD={$password}", + $env + ); + } else { + $env = preg_replace('/REDIS_PASSWORD=.*/m', "REDIS_PASSWORD={$password}", $env); + } + + File::put($envFile, $env); + } + + // Restart Redis + exec('systemctl restart redis-server 2>&1', $output, $code); + if ($code === 0) { + $this->line(' - Redis ACL configured and restarted'); + + // Migrate existing users + $this->line(' - Migrating existing users to Redis ACL...'); + try { + Artisan::call('jabali:migrate-redis-users'); + $this->line(Artisan::output()); + } catch (Exception $e) { + $this->warn(' - Redis user migration warning: '.$e->getMessage()); + } + } else { + $this->warn(' - Redis restart failed, ACL may not be active'); + } + } +} diff --git a/app/Console/Commands/Jabali/UserCommand.php b/app/Console/Commands/Jabali/UserCommand.php new file mode 100644 index 0000000..b39f0fd --- /dev/null +++ b/app/Console/Commands/Jabali/UserCommand.php @@ -0,0 +1,106 @@ +argument('action')) { + 'list' => $this->listUsers(), + 'create' => $this->createUser(), + 'show' => $this->showUser(), + 'update' => $this->updateUser(), + 'delete' => $this->deleteUser(), + 'password' => $this->changePassword(), + default => $this->error("Unknown action. Use: list, create, show, update, delete, password") ?? 1, + }; + } + + private function generateSecurePassword(int $length = 16): string { + $lower = 'abcdefghijklmnopqrstuvwxyz'; + $upper = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + $numbers = '0123456789'; + $special = '!@#$%^&*'; + $password = $lower[random_int(0, 25)] . $upper[random_int(0, 25)] . $numbers[random_int(0, 9)] . $special[random_int(0, 7)]; + $all = $lower . $upper . $numbers . $special; + for ($i = 4; $i < $length; $i++) $password .= $all[random_int(0, strlen($all) - 1)]; + return str_shuffle($password); + } + + private function validatePassword(string $password): ?string { + if (strlen($password) < 8) return 'Password must be at least 8 characters'; + if (!preg_match('/[a-z]/', $password)) return 'Password must contain a lowercase letter'; + if (!preg_match('/[A-Z]/', $password)) return 'Password must contain an uppercase letter'; + if (!preg_match('/[0-9]/', $password)) return 'Password must contain a number'; + return null; + } + + private function listUsers(): int { + $users = User::all(); + $this->table(['ID', 'Name', 'Email', 'Role', 'Created'], $users->map(fn($u) => [$u->id, $u->name, $u->email, $u->role ?? 'user', $u->created_at->format('Y-m-d')])->toArray()); + return 0; + } + + private function createUser(): int { + $name = $this->option('name') ?? $this->ask('Name'); + $email = $this->option('email') ?? $this->ask('Email'); + $gen = !$this->option('password'); + $password = $this->option('password') ?? $this->generateSecurePassword(); + if ($error = $this->validatePassword($password)) { $this->error($error); return 1; } + if (User::where('email', $email)->exists()) { $this->error("Email exists!"); return 1; } + $user = User::create(['name' => $name, 'email' => $email, 'password' => Hash::make($password), 'role' => $this->option('role') ?? 'user', 'email_verified_at' => now()]); + $this->info("✓ Created user #{$user->id}: {$email}"); + if ($gen) $this->warn("🔑 Password: $password"); + return 0; + } + + private function showUser(): int { + $user = $this->findUser(); + if (!$user) return 1; + $this->table(['Field', 'Value'], [['ID', $user->id], ['Name', $user->name], ['Email', $user->email], ['Role', $user->role ?? 'user'], ['Created', $user->created_at]]); + return 0; + } + + private function updateUser(): int { + $user = $this->findUser(); + if (!$user) return 1; + $updates = array_filter(['name' => $this->option('name'), 'email' => $this->option('email'), 'role' => $this->option('role')]); + if (empty($updates)) { $this->warn('Specify --name, --email, or --role'); return 0; } + $user->update($updates); + $this->info("✓ Updated user #{$user->id}"); + return 0; + } + + private function deleteUser(): int { + $user = $this->findUser(); + if (!$user) return 1; + if (!$this->option('force') && !$this->confirm("Delete {$user->email}?")) return 0; + $user->delete(); + $this->info("✓ Deleted user #{$user->id}"); + return 0; + } + + private function changePassword(): int { + $user = $this->findUser(); + if (!$user) return 1; + $gen = !$this->option('password'); + $password = $this->option('password') ?? $this->generateSecurePassword(); + if ($error = $this->validatePassword($password)) { $this->error($error); return 1; } + $user->update(['password' => Hash::make($password)]); + $this->info("✓ Password updated for {$user->email}"); + if ($gen) $this->warn("🔑 Password: $password"); + return 0; + } + + private function findUser(): ?User { + $id = $this->option('id') ?? $this->ask('User ID or email'); + $user = is_numeric($id) ? User::find($id) : User::where('email', $id)->first(); + if (!$user) $this->error("User not found: $id"); + return $user; + } +} diff --git a/app/Console/Commands/ManageGitProtection.php b/app/Console/Commands/ManageGitProtection.php new file mode 100644 index 0000000..5cc6ac5 --- /dev/null +++ b/app/Console/Commands/ManageGitProtection.php @@ -0,0 +1,233 @@ +authFile = base_path('.git-authorized-committers'); + $this->hookFile = base_path('.git/hooks/pre-commit'); + $this->deployKeyFile = base_path('.deploy-key'); + } + + public function handle(): int + { + $action = $this->argument('action'); + + return match ($action) { + 'enable' => $this->enableProtection(), + 'disable' => $this->disableProtection(), + 'status' => $this->showStatus(), + 'add-committer' => $this->addCommitter(), + 'remove-committer' => $this->removeCommitter(), + 'list-committers' => $this->listCommitters(), + default => $this->showHelp(), + }; + } + + protected function enableProtection(): int + { + // Ensure hook exists and is executable + if (!file_exists($this->hookFile)) { + $this->error('Pre-commit hook not found. Please reinstall Jabali.'); + return 1; + } + + chmod($this->hookFile, 0755); + + // Create authorized committers file if not exists + if (!file_exists($this->authFile)) { + // Add current git user as first authorized committer + $email = trim(shell_exec('git config user.email') ?? ''); + if ($email) { + file_put_contents($this->authFile, $email . "\n"); + } else { + touch($this->authFile); + } + chmod($this->authFile, 0600); + } + + // Generate deploy key for automated deployments + if (!file_exists($this->deployKeyFile)) { + $key = Str::random(64); + file_put_contents($this->deployKeyFile, $key); + chmod($this->deployKeyFile, 0600); + $this->info("Deploy key generated. Use JABALI_DEPLOY_KEY=$key for automated deployments."); + } + + $this->info('Git commit protection ENABLED.'); + $this->line('Only authorized committers can now make commits.'); + $this->line(''); + $this->line('Authorized committers file: ' . $this->authFile); + + return 0; + } + + protected function disableProtection(): int + { + if (file_exists($this->authFile)) { + unlink($this->authFile); + } + + $this->info('Git commit protection DISABLED.'); + $this->line('Anyone can now make commits to this repository.'); + + return 0; + } + + protected function showStatus(): int + { + $isEnabled = file_exists($this->authFile); + + $this->line('Git Protection Status'); + $this->line('====================='); + $this->line(''); + + if ($isEnabled) { + $this->info('Status: ENABLED'); + $this->line(''); + + $committers = $this->getCommitters(); + if (count($committers) > 0) { + $this->line('Authorized committers:'); + foreach ($committers as $email) { + $this->line(" - $email"); + } + } else { + $this->warn('No authorized committers configured!'); + $this->line('No one will be able to commit.'); + } + + if (file_exists($this->deployKeyFile)) { + $this->line(''); + $this->line('Deploy key: Configured'); + } + } else { + $this->warn('Status: DISABLED'); + $this->line('Anyone can make commits.'); + } + + // Show last integrity check status + $lastCheck = \App\Models\DnsSetting::get('last_integrity_check'); + $lastStatus = \App\Models\DnsSetting::get('last_integrity_status'); + + if ($lastCheck) { + $this->line(''); + $this->line('Last integrity check: ' . $lastCheck); + $this->line('Status: ' . ($lastStatus === 'clean' ? 'Clean' : 'Modified files detected')); + } + + return 0; + } + + protected function addCommitter(): int + { + $email = $this->option('email'); + + if (!$email) { + $email = $this->ask('Enter committer email address'); + } + + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + $this->error('Invalid email address.'); + return 1; + } + + $committers = $this->getCommitters(); + + if (in_array($email, $committers)) { + $this->warn("$email is already an authorized committer."); + return 0; + } + + $committers[] = $email; + file_put_contents($this->authFile, implode("\n", $committers) . "\n"); + chmod($this->authFile, 0600); + + $this->info("Added $email to authorized committers."); + + return 0; + } + + protected function removeCommitter(): int + { + $email = $this->option('email'); + + if (!$email) { + $email = $this->ask('Enter committer email to remove'); + } + + $committers = $this->getCommitters(); + $committers = array_filter($committers, fn($e) => $e !== $email); + + file_put_contents($this->authFile, implode("\n", $committers) . "\n"); + + $this->info("Removed $email from authorized committers."); + + return 0; + } + + protected function listCommitters(): int + { + $committers = $this->getCommitters(); + + if (empty($committers)) { + $this->warn('No authorized committers configured.'); + return 0; + } + + $this->info('Authorized committers:'); + foreach ($committers as $email) { + $this->line(" - $email"); + } + + return 0; + } + + protected function getCommitters(): array + { + if (!file_exists($this->authFile)) { + return []; + } + + $content = file_get_contents($this->authFile); + $lines = array_filter(array_map('trim', explode("\n", $content))); + + return $lines; + } + + protected function showHelp(): int + { + $this->line('Usage: php artisan jabali:git-protection '); + $this->line(''); + $this->line('Actions:'); + $this->line(' enable Enable commit protection'); + $this->line(' disable Disable commit protection'); + $this->line(' status Show protection status'); + $this->line(' add-committer Add an authorized committer'); + $this->line(' remove-committer Remove an authorized committer'); + $this->line(' list-committers List all authorized committers'); + $this->line(''); + $this->line('Options:'); + $this->line(' --email= Email address for add/remove actions'); + + return 0; + } +} diff --git a/app/Console/Commands/NotifyHighLoad.php b/app/Console/Commands/NotifyHighLoad.php new file mode 100644 index 0000000..10b50a1 --- /dev/null +++ b/app/Console/Commands/NotifyHighLoad.php @@ -0,0 +1,66 @@ +argument('event'); + $load = (float) $this->argument('load'); + $minutes = (int) $this->argument('minutes'); + + $result = match ($event) { + 'high' => $this->notifyHighLoad($load, $minutes), + 'recovered' => $this->notifyRecovered($load), + default => false, + }; + + return $result ? Command::SUCCESS : Command::FAILURE; + } + + protected function notifyHighLoad(float $load, int $minutes): bool + { + $hostname = gethostname() ?: 'server'; + $cpuCount = (int) shell_exec('nproc 2>/dev/null') ?: 1; + + return AdminNotificationService::send( + 'high_load', + "High Server Load: {$hostname}", + "The server load has been critically high for {$minutes} minutes. Current load: {$load} (CPU cores: {$cpuCount}). Please investigate immediately.", + [ + 'Load Average' => number_format($load, 2), + 'Duration' => "{$minutes} minutes", + 'CPU Cores' => $cpuCount, + 'Load per Core' => number_format($load / $cpuCount, 2), + ] + ); + } + + protected function notifyRecovered(float $load): bool + { + $hostname = gethostname() ?: 'server'; + + return AdminNotificationService::send( + 'high_load', + "Server Load Recovered: {$hostname}", + "The server load has returned to normal levels. Current load: {$load}.", + [ + 'Load Average' => number_format($load, 2), + 'Status' => 'Recovered', + ] + ); + } +} diff --git a/app/Console/Commands/NotifyServiceHealth.php b/app/Console/Commands/NotifyServiceHealth.php new file mode 100644 index 0000000..1877732 --- /dev/null +++ b/app/Console/Commands/NotifyServiceHealth.php @@ -0,0 +1,75 @@ +argument('event'); + $service = $this->argument('service'); + $description = $this->option('description') ?? $service; + + $result = match ($event) { + 'down' => $this->notifyDown($service, $description), + 'restarted' => $this->notifyRestarted($service, $description), + 'recovered' => $this->notifyRecovered($service, $description), + 'failed' => $this->notifyFailed($service, $description), + default => false, + }; + + return $result ? Command::SUCCESS : Command::FAILURE; + } + + protected function notifyDown(string $service, string $description): bool + { + return AdminNotificationService::send( + 'service_health', + "Service Down: {$description}", + "The {$description} service ({$service}) has stopped and will be automatically restarted.", + ['Service' => $service, 'Status' => 'Down', 'Action' => 'Auto-restart pending'] + ); + } + + protected function notifyRestarted(string $service, string $description): bool + { + return AdminNotificationService::send( + 'service_health', + "Service Auto-Restarted: {$description}", + "The {$description} service ({$service}) was down and has been automatically restarted by the health monitor.", + ['Service' => $service, 'Status' => 'Restarted', 'Action' => 'Automatic recovery'] + ); + } + + protected function notifyRecovered(string $service, string $description): bool + { + return AdminNotificationService::send( + 'service_health', + "Service Recovered: {$description}", + "The {$description} service ({$service}) has recovered and is now running normally.", + ['Service' => $service, 'Status' => 'Running', 'Action' => 'None required'] + ); + } + + protected function notifyFailed(string $service, string $description): bool + { + return AdminNotificationService::send( + 'service_health', + "CRITICAL: Service Failed: {$description}", + "The {$description} service ({$service}) could not be automatically restarted after multiple attempts. Manual intervention is required immediately.", + ['Service' => $service, 'Status' => 'Failed', 'Action' => 'Manual intervention required'] + ); + } +} diff --git a/app/Console/Commands/NotifySshLogin.php b/app/Console/Commands/NotifySshLogin.php new file mode 100644 index 0000000..7d18091 --- /dev/null +++ b/app/Console/Commands/NotifySshLogin.php @@ -0,0 +1,25 @@ +argument('username'); + $ip = $this->argument('ip'); + $method = $this->option('method'); + + AdminNotificationService::sshLogin($username, $ip, $method); + + return Command::SUCCESS; + } +} diff --git a/app/Console/Commands/RunBackupSchedules.php b/app/Console/Commands/RunBackupSchedules.php new file mode 100644 index 0000000..ec4e36f --- /dev/null +++ b/app/Console/Commands/RunBackupSchedules.php @@ -0,0 +1,282 @@ +agent = new AgentClient(); + } + + public function handle(): int + { + $this->info('Checking for due backup schedules...'); + + $dueSchedules = BackupSchedule::due()->with('destination')->get(); + + if ($dueSchedules->isEmpty()) { + $this->info('No backup schedules due.'); + return Command::SUCCESS; + } + + $this->info("Found {$dueSchedules->count()} schedule(s) to run."); + + foreach ($dueSchedules as $schedule) { + $this->runSchedule($schedule); + } + + return Command::SUCCESS; + } + + protected function runSchedule(BackupSchedule $schedule): void + { + $this->info("Running schedule: {$schedule->name}"); + + $backupType = $schedule->metadata['backup_type'] ?? 'full'; + $timestamp = now()->format('Y-m-d_His'); + + if ($schedule->is_server_backup) { + // Server backup: folder with individual user tar.gz files + $filename = $timestamp; + $outputPath = "/var/backups/jabali/{$timestamp}"; + } else { + $user = $schedule->user; + if (!$user) { + $this->error("Schedule {$schedule->id} has no user."); + return; + } + $filename = "backup_scheduled_{$timestamp}.tar.gz"; + $outputPath = "/home/{$user->username}/backups/{$filename}"; + } + + $backup = Backup::create([ + 'user_id' => $schedule->user_id, + 'destination_id' => $schedule->destination_id, + 'schedule_id' => $schedule->id, + 'name' => "{$schedule->name} - " . now()->format('M j, Y H:i'), + 'filename' => $filename, + 'type' => $schedule->is_server_backup ? 'server' : 'partial', + 'include_files' => $schedule->include_files, + 'include_databases' => $schedule->include_databases, + 'include_mailboxes' => $schedule->include_mailboxes, + 'include_dns' => $schedule->include_dns, + 'users' => $schedule->users, + 'status' => 'pending', + 'local_path' => $outputPath, + 'metadata' => ['backup_type' => $backupType], + ]); + + try { + $backup->update(['status' => 'running', 'started_at' => now()]); + + // Check if this is an incremental backup with remote destination (dirvish-style) + $isIncrementalRemote = $backupType === 'incremental' && $schedule->destination; + + if ($schedule->is_server_backup) { + if ($isIncrementalRemote) { + // Dirvish-style: rsync directly to remote with --link-dest + $config = array_merge( + $schedule->destination->config ?? [], + ['type' => $schedule->destination->type] + ); + $result = $this->agent->backupIncrementalDirect($config, [ + 'users' => $schedule->users, + 'include_files' => $schedule->include_files, + 'include_databases' => $schedule->include_databases, + 'include_mailboxes' => $schedule->include_mailboxes, + 'include_dns' => $schedule->include_dns, + ]); + + // Update backup record with remote path + if ($result['success']) { + $backup->update([ + 'local_path' => null, // No local file for incremental remote + 'remote_path' => $result['remote_path'] ?? null, + ]); + } + } else { + $result = $this->agent->backupCreateServer($outputPath, [ + 'backup_type' => $backupType, + 'users' => $schedule->users, + 'include_files' => $schedule->include_files, + 'include_databases' => $schedule->include_databases, + 'include_mailboxes' => $schedule->include_mailboxes, + 'include_dns' => $schedule->include_dns, + ]); + } + } else { + $result = $this->agent->backupCreate($schedule->user->username, $outputPath, [ + 'include_files' => $schedule->include_files, + 'include_databases' => $schedule->include_databases, + 'include_mailboxes' => $schedule->include_mailboxes, + 'include_dns' => $schedule->include_dns, + 'include_ssl' => $schedule->include_ssl ?? true, + ]); + } + + if ($result['success']) { + $backup->update([ + 'status' => 'completed', + 'completed_at' => now(), + 'size_bytes' => $result['size'] ?? 0, + 'checksum' => $result['checksum'] ?? null, + 'domains' => $result['domains'] ?? null, + 'databases' => $result['databases'] ?? null, + 'mailboxes' => $result['mailboxes'] ?? null, + 'users' => $result['users'] ?? null, + ]); + + // Upload to remote if destination configured + if ($schedule->destination) { + $backup->load('destination'); // Load the relationship + $uploadSuccess = $this->uploadToRemote($backup); + + // Delete local file after successful remote upload (unless keep_local is set) + $keepLocal = $schedule->metadata['keep_local_copy'] ?? false; + if (!$keepLocal && $uploadSuccess && $backup->local_path) { + $this->agent->backupDeleteServer($backup->local_path); + $backup->update(['local_path' => null]); + $this->info("Local backup deleted after remote upload"); + } + } + + $schedule->update([ + 'last_run_at' => now(), + 'last_status' => 'success', + 'last_error' => null, + ]); + + $this->info("Backup completed: {$backup->name}"); + + // Apply retention policy + $this->applyRetention($schedule); + } else { + throw new Exception($result['error'] ?? 'Backup failed'); + } + } catch (Exception $e) { + $backup->update([ + 'status' => 'failed', + 'completed_at' => now(), + 'error_message' => $e->getMessage(), + ]); + + $schedule->update([ + 'last_run_at' => now(), + 'last_status' => 'failed', + 'last_error' => $e->getMessage(), + ]); + + $this->error("Backup failed: {$e->getMessage()}"); + + // Send admin notification + AdminNotificationService::backupFailure($schedule->name, $e->getMessage()); + } + + // Calculate next run + $schedule->calculateNextRun(); + $schedule->save(); + } + + protected function uploadToRemote(Backup $backup): bool + { + if (!$backup->destination || !$backup->local_path) { + return false; + } + + try { + $backup->update(['status' => 'uploading']); + + $config = array_merge( + $backup->destination->config ?? [], + ['type' => $backup->destination->type] + ); + $backupType = $backup->metadata['backup_type'] ?? 'full'; + + $result = $this->agent->backupUploadRemote($backup->local_path, $config, $backupType); + + if ($result['success']) { + $backup->update([ + 'status' => 'completed', + 'remote_path' => $result['remote_path'] ?? null, + ]); + $this->info("Uploaded to remote: {$backup->destination->name}"); + return true; + } else { + throw new Exception($result['error'] ?? 'Upload failed'); + } + } catch (Exception $e) { + $backup->update([ + 'status' => 'completed', // Keep as completed since local exists + 'error_message' => 'Remote upload failed: ' . $e->getMessage(), + ]); + $this->warn("Remote upload failed: {$e->getMessage()}"); + return false; + } + } + + protected function applyRetention(BackupSchedule $schedule): void + { + $retentionCount = $schedule->retention_count ?? 7; + + // Get backups from this schedule, ordered by date + // schedule_id is a top-level field on the backups table + $backups = Backup::where('schedule_id', $schedule->id) + ->where('status', 'completed') + ->orderByDesc('created_at') + ->get(); + + if ($backups->count() <= $retentionCount) { + return; + } + + // Get backups to delete + $toDelete = $backups->slice($retentionCount); + + foreach ($toDelete as $backup) { + $this->info("Deleting old backup: {$backup->name}"); + + // Delete local file + if ($backup->local_path && file_exists($backup->local_path)) { + if (is_file($backup->local_path)) { + unlink($backup->local_path); + } else { + exec("rm -rf " . escapeshellarg($backup->local_path)); + } + } + + // Delete from remote if exists + if ($backup->remote_path && $backup->destination) { + try { + $config = array_merge( + $backup->destination->config ?? [], + ['type' => $backup->destination->type] + ); + $this->agent->backupDeleteRemote($backup->remote_path, $config); + } catch (Exception $e) { + // Silent fail for remote deletion + } + } + + $backup->delete(); + } + + $deletedCount = $toDelete->count(); + $this->info("Deleted {$deletedCount} old backup(s) per retention policy."); + } +} diff --git a/app/Console/Commands/RunUserCronJobs.php b/app/Console/Commands/RunUserCronJobs.php new file mode 100644 index 0000000..78be887 --- /dev/null +++ b/app/Console/Commands/RunUserCronJobs.php @@ -0,0 +1,121 @@ +get(); + + foreach ($jobs as $job) { + if ($this->isDue($job->schedule)) { + $this->runJob($job); + } + } + + return Command::SUCCESS; + } + + protected function isDue(string $schedule): bool + { + $parts = explode(' ', $schedule); + if (count($parts) !== 5) { + return false; + } + + [$minute, $hour, $dayOfMonth, $month, $dayOfWeek] = $parts; + + $now = now(); + + return $this->matchesPart($minute, $now->minute) + && $this->matchesPart($hour, $now->hour) + && $this->matchesPart($dayOfMonth, $now->day) + && $this->matchesPart($month, $now->month) + && $this->matchesPart($dayOfWeek, $now->dayOfWeek); + } + + protected function matchesPart(string $pattern, int $value): bool + { + // Handle * + if ($pattern === '*') { + return true; + } + + // Handle */n (step values) + if (str_starts_with($pattern, '*/')) { + $step = (int) substr($pattern, 2); + return $step > 0 && $value % $step === 0; + } + + // Handle ranges (e.g., 1-5) + if (str_contains($pattern, '-')) { + [$start, $end] = explode('-', $pattern); + return $value >= (int) $start && $value <= (int) $end; + } + + // Handle lists (e.g., 1,3,5) + if (str_contains($pattern, ',')) { + $values = array_map('intval', explode(',', $pattern)); + return in_array($value, $values); + } + + // Handle exact value + return (int) $pattern === $value; + } + + protected function runJob(CronJob $job): void + { + $username = $job->user->username ?? null; + + if (!$username) { + Log::warning("Cron job {$job->id} has no valid user"); + return; + } + + $this->info("Running cron job: {$job->name} (ID: {$job->id})"); + + $startTime = microtime(true); + + // Strip output redirection from command so we can capture it + $command = $job->command; + $command = preg_replace('/\s*>\s*\/dev\/null.*$/', '', $command); + $command = preg_replace('/\s*2>&1\s*$/', '', $command); + + // Run the command as the user + $cmd = sprintf( + 'sudo -u %s bash -c %s 2>&1', + escapeshellarg($username), + escapeshellarg($command) + ); + + exec($cmd, $output, $exitCode); + + $duration = round(microtime(true) - $startTime, 2); + $outputStr = implode("\n", $output); + + // Update the job record + $job->update([ + 'last_run_at' => now(), + 'last_run_status' => $exitCode === 0 ? 'success' : 'failed', + 'last_run_output' => substr($outputStr, 0, 10000), // Limit output size + ]); + + if ($exitCode === 0) { + $this->info(" Completed successfully in {$duration}s"); + } else { + $this->error(" Failed with exit code {$exitCode} in {$duration}s"); + Log::warning("Cron job {$job->id} ({$job->name}) failed", [ + 'exit_code' => $exitCode, + 'output' => $outputStr, + ]); + } + } +} diff --git a/app/Console/Commands/SyncMailboxQuotas.php b/app/Console/Commands/SyncMailboxQuotas.php new file mode 100644 index 0000000..23733f3 --- /dev/null +++ b/app/Console/Commands/SyncMailboxQuotas.php @@ -0,0 +1,58 @@ +get(); + + if ($mailboxes->isEmpty()) { + $this->info('No mailboxes to sync.'); + return 0; + } + + $this->info("Syncing quota usage for {$mailboxes->count()} mailboxes..."); + + $agent = new AgentClient(); + $synced = 0; + $errors = 0; + + foreach ($mailboxes as $mailbox) { + try { + $response = $agent->send('email.mailbox_quota_usage', [ + 'email' => $mailbox->email, + 'maildir_path' => $mailbox->getRawOriginal('maildir_path'), + ]); + + $mailbox->quota_used_bytes = $response['quota_used_bytes'] ?? 0; + $mailbox->save(); + $synced++; + } catch (\Exception $e) { + if (str_contains($e->getMessage(), 'not found')) { + // Mailbox directory doesn't exist yet (no mail received) + $mailbox->quota_used_bytes = 0; + $mailbox->save(); + $synced++; + } else { + $this->error("Failed to sync {$mailbox->email}: {$e->getMessage()}"); + $errors++; + } + } + } + + $this->info("Synced {$synced} mailboxes" . ($errors ? ", {$errors} errors" : '')); + + return $errors > 0 ? 1 : 0; + } +} diff --git a/app/Filament/Admin/Pages/Auth/Login.php b/app/Filament/Admin/Pages/Auth/Login.php new file mode 100644 index 0000000..4b533fc --- /dev/null +++ b/app/Filament/Admin/Pages/Auth/Login.php @@ -0,0 +1,58 @@ +form->getState(); + + // Check credentials without logging in + $user = User::where('email', $data['email'])->first(); + + if ($user && Hash::check($data['password'], $user->password)) { + if (! $user->is_admin) { + $this->redirect(route('filament.jabali.pages.dashboard')); + + return null; + } + + // Check if 2FA is enabled + if ($user->two_factor_secret && $user->two_factor_confirmed_at) { + // Store user ID in session for 2FA challenge + session(['login.id' => $user->id]); + session(['login.remember' => $data['remember'] ?? false]); + + // Redirect to 2FA challenge + $this->redirect(route('filament.admin.auth.two-factor-challenge')); + + return null; + } + } + + $response = parent::authenticate(); + + // If authentication successful, check if user is NOT admin + $user = Filament::auth()->user(); + if ($user && ! $user->is_admin) { + // Log out from admin guard - regular users can't access admin panel + Filament::auth()->logout(); + + // Redirect to user panel using Livewire's redirect + $this->redirect(route('filament.jabali.pages.dashboard')); + + return null; + } + + return $response; + } +} diff --git a/app/Filament/Admin/Pages/Auth/TwoFactorChallenge.php b/app/Filament/Admin/Pages/Auth/TwoFactorChallenge.php new file mode 100644 index 0000000..080e07b --- /dev/null +++ b/app/Filament/Admin/Pages/Auth/TwoFactorChallenge.php @@ -0,0 +1,188 @@ +getChallengedUser(); + + // If not in 2FA challenge state or not an admin, redirect to login + if (! $user) { + $this->clearChallengeSession(); + $this->redirect(Filament::getPanel('admin')->getLoginUrl()); + + return; + } + + $this->form->fill(); + } + + public function getTitle(): string|Htmlable + { + return __('Two-Factor Authentication'); + } + + public function getHeading(): string|Htmlable + { + return __('Two-Factor Authentication'); + } + + public function getSubheading(): string|Htmlable|null + { + return $this->useRecoveryCode + ? __('Please enter one of your emergency recovery codes.') + : __('Please enter the authentication code from your app.'); + } + + public function form(Schema $schema): Schema + { + return $schema + ->schema([ + TextInput::make('code') + ->label($this->useRecoveryCode ? __('Recovery Code') : __('Authentication Code')) + ->placeholder($this->useRecoveryCode ? __('Enter recovery code') : '000000') + ->required() + ->autocomplete('one-time-code') + ->autofocus() + ->extraInputAttributes([ + 'inputmode' => $this->useRecoveryCode ? 'text' : 'numeric', + 'pattern' => $this->useRecoveryCode ? null : '[0-9]*', + 'maxlength' => $this->useRecoveryCode ? 21 : 6, + ]), + ]) + ->statePath('data'); + } + + public function authenticate(): void + { + $data = $this->form->getState(); + $code = $data['code']; + + $user = $this->getChallengedUser(); + + if (! $user) { + $this->clearChallengeSession(); + $this->redirect(Filament::getPanel('admin')->getLoginUrl()); + + return; + } + + $valid = $this->useRecoveryCode + ? $this->validateRecoveryCode($user, $code) + : $this->validateAuthenticationCode($user, $code); + + if (! $valid) { + Notification::make() + ->title(__('Invalid Code')) + ->body($this->useRecoveryCode + ? __('The recovery code is invalid.') + : __('The authentication code is invalid.')) + ->danger() + ->send(); + + $this->form->fill(); + + return; + } + + $remember = (bool) session('login.remember', false); + $this->clearChallengeSession(); + + // Login the user with admin guard + Auth::guard('admin')->login($user, $remember); + + session()->regenerate(); + + $this->redirect(Filament::getPanel('admin')->getUrl()); + } + + protected function getChallengedUser() + { + $userId = session('login.id'); + + if (! $userId) { + return null; + } + + $user = \App\Models\User::find($userId); + + if (! $user || ! $user->is_admin) { + return null; + } + + return $user; + } + + protected function clearChallengeSession(): void + { + session()->forget('login.id'); + session()->forget('login.remember'); + } + + protected function validateAuthenticationCode($user, string $code): bool + { + return app(TwoFactorAuthenticationProvider::class)->verify( + decrypt($user->two_factor_secret), + $code + ); + } + + protected function validateRecoveryCode($user, string $code): bool + { + $codes = json_decode(decrypt($user->two_factor_recovery_codes), true); + + $code = str_replace('-', '', trim($code)); + + foreach ($codes as $index => $storedCode) { + $storedCode = str_replace('-', '', $storedCode); + if (hash_equals($storedCode, $code)) { + // Remove the used code + unset($codes[$index]); + $user->forceFill([ + 'two_factor_recovery_codes' => encrypt(json_encode(array_values($codes))), + ])->save(); + + event(new RecoveryCodeReplaced($user, $code)); + + return true; + } + } + + return false; + } + + public function toggleRecoveryCode(): void + { + $this->useRecoveryCode = ! $this->useRecoveryCode; + $this->form->fill(); + } + + protected function hasFullWidthFormActions(): bool + { + return true; + } +} diff --git a/app/Filament/Admin/Pages/Backups.php b/app/Filament/Admin/Pages/Backups.php new file mode 100644 index 0000000..6e42b12 --- /dev/null +++ b/app/Filament/Admin/Pages/Backups.php @@ -0,0 +1,1559 @@ +activeTab = $this->normalizeTabName($this->activeTab); + } + + protected function normalizeTabName(?string $tab): string + { + return match ($tab) { + 'destinations', 'schedules', 'backups' => $tab, + default => 'destinations', + }; + } + + public function setTab(string $tab): void + { + $this->activeTab = $this->normalizeTabName($tab); + $this->resetTable(); + } + + protected function getForms(): array + { + return [ + 'backupsForm', + ]; + } + + public function backupsForm(Schema $schema): Schema + { + return $schema + ->schema([ + Section::make(__('Recommendation')) + ->description(__('Use Incremental Backups for scheduled server backups. They only store changes since the last backup, significantly reducing storage space and backup time while maintaining full restore capability.')) + ->icon('heroicon-o-light-bulb') + ->iconColor('info') + ->collapsed(false) + ->collapsible(false), + View::make('filament.admin.components.backup-tabs-nav'), + ]); + } + + public function getAgent(): AgentClient + { + return $this->agent ??= new AgentClient; + } + + protected function supportsIncremental($destinationId): bool + { + if (empty($destinationId)) { + return false; + } + + $destination = BackupDestination::find($destinationId); + if (! $destination) { + return false; + } + + return in_array($destination->type, ['sftp', 'nfs']); + } + + public function table(Table $table): Table + { + return match ($this->activeTab) { + 'destinations' => $this->destinationsTable($table), + 'schedules' => $this->schedulesTable($table), + 'backups' => $this->backupsTable($table), + default => $this->destinationsTable($table), + }; + } + + protected function destinationsTable(Table $table): Table + { + return $table + ->query(BackupDestination::query()->where('is_server_backup', true)->orderBy('name')) + ->columns([ + TextColumn::make('name') + ->label(__('Name')) + ->weight('medium') + ->description(fn (BackupDestination $record): ?string => $record->is_default ? __('Default') : null) + ->searchable(), + TextColumn::make('type') + ->label(__('Type')) + ->badge() + ->formatStateUsing(fn (string $state): string => strtoupper($state)) + ->color(fn (string $state): string => match ($state) { + 'sftp' => 'info', + 'nfs' => 'warning', + 's3' => 'success', + default => 'gray', + }), + TextColumn::make('test_status') + ->label(__('Status')) + ->badge() + ->formatStateUsing(fn (?string $state): string => match ($state) { + 'success' => __('Connected'), + 'failed' => __('Failed'), + default => __('Not Tested'), + }) + ->color(fn (?string $state): string => match ($state) { + 'success' => 'success', + 'failed' => 'danger', + default => 'gray', + }), + TextColumn::make('last_tested_at') + ->label(__('Last Tested')) + ->since() + ->placeholder(__('Never')) + ->color('gray'), + ]) + ->recordActions([ + Action::make('test') + ->label(__('Test')) + ->icon('heroicon-o-check-circle') + ->color('success') + ->size('sm') + ->action(fn (BackupDestination $record) => $this->testDestination($record->id)), + Action::make('delete') + ->label(__('Delete')) + ->icon('heroicon-o-trash') + ->color('danger') + ->size('sm') + ->requiresConfirmation() + ->action(fn (BackupDestination $record) => $this->deleteDestination($record->id)), + ]) + ->emptyStateHeading(__('No remote destinations configured')) + ->emptyStateDescription(__('Click "Add Destination" to configure SFTP, NFS, or S3 storage')) + ->emptyStateIcon('heroicon-o-server-stack') + ->striped(); + } + + protected function schedulesTable(Table $table): Table + { + return $table + ->query(BackupSchedule::query()->where('is_server_backup', true)->with('destination')->orderBy('name')) + ->columns([ + TextColumn::make('name') + ->label(__('Name')) + ->weight('medium') + ->searchable(), + TextColumn::make('frequency_label') + ->label(__('Frequency')), + TextColumn::make('destination.name') + ->label(__('Destination')) + ->placeholder(__('Local')), + TextColumn::make('retention_count') + ->label(__('Retention')) + ->formatStateUsing(fn (int $state): string => $state.' '.__('backups')), + TextColumn::make('last_run_at') + ->label(__('Last Run')) + ->since() + ->dateTimeTooltip('M j, Y H:i T', timezone: $this->getSystemTimezone()) + ->placeholder(__('Never')) + ->color('gray'), + TextColumn::make('next_run_at') + ->label(__('Next Run')) + ->since() + ->dateTimeTooltip('M j, Y H:i T', timezone: $this->getSystemTimezone()) + ->placeholder(__('Not scheduled')) + ->color('gray'), + ViewColumn::make('status') + ->label(__('Status')) + ->view('filament.admin.columns.schedule-status'), + ]) + ->recordActions([ + Action::make('run') + ->label(__('Run')) + ->icon('heroicon-o-play') + ->color('gray') + ->size('sm') + ->visible(fn (BackupSchedule $record): bool => ! Backup::where('schedule_id', $record->id)->running()->exists()) + ->action(fn (BackupSchedule $record) => $this->runScheduleNow($record->id)), + Action::make('stop') + ->label(__('Stop')) + ->icon('heroicon-o-stop') + ->color('danger') + ->size('sm') + ->visible(fn (BackupSchedule $record): bool => Backup::where('schedule_id', $record->id)->running()->exists()) + ->requiresConfirmation() + ->action(fn (BackupSchedule $record) => $this->stopScheduleBackup($record->id)), + Action::make('edit') + ->label(__('Edit')) + ->icon('heroicon-o-pencil') + ->color('gray') + ->size('sm') + ->action(fn (BackupSchedule $record) => $this->mountAction('editSchedule', ['id' => $record->id])), + Action::make('toggle') + ->label(fn (BackupSchedule $record): string => $record->is_active ? __('Disable') : __('Enable')) + ->icon(fn (BackupSchedule $record): string => $record->is_active ? 'heroicon-o-pause' : 'heroicon-o-play') + ->color('gray') + ->size('sm') + ->action(fn (BackupSchedule $record) => $this->toggleSchedule($record->id)), + Action::make('delete') + ->label(__('Delete')) + ->icon('heroicon-o-trash') + ->color('danger') + ->size('sm') + ->requiresConfirmation() + ->action(fn (BackupSchedule $record) => $this->deleteSchedule($record->id)), + ]) + ->headerActions([ + $this->addScheduleAction(), + ]) + ->emptyStateHeading(__('No backup schedules configured')) + ->emptyStateDescription(__('Click "Add Schedule" to set up automatic backups')) + ->emptyStateIcon('heroicon-o-clock') + ->striped() + ->poll(fn () => Backup::running()->exists() ? '3s' : null); + } + + protected function backupsTable(Table $table): Table + { + return $table + ->query(Backup::query()->where('type', 'server')->with(['destination', 'user'])->orderByDesc('created_at')->limit(50)) + ->columns([ + TextColumn::make('name') + ->label(__('Name')) + ->weight('medium') + ->searchable() + ->limit(40), + ViewColumn::make('status') + ->label(__('Status')) + ->view('filament.admin.columns.backup-status'), + TextColumn::make('size_bytes') + ->label(__('Size')) + ->formatStateUsing(fn (Backup $record): string => $record->size_human), + TextColumn::make('destination.name') + ->label(__('Destination')) + ->placeholder(__('Local')), + TextColumn::make('created_at') + ->label(__('Created')) + ->dateTime('M j, Y H:i') + ->color('gray'), + TextColumn::make('duration') + ->label(__('Duration')) + ->placeholder('-') + ->color('gray'), + ]) + ->recordActions([ + Action::make('restore') + ->label(__('Restore')) + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->size('sm') + ->visible(fn (Backup $record): bool => $record->status === 'completed' && ($record->local_path || $record->remote_path)) + ->modalHeading(__('Restore Backup')) + ->modalDescription(__('Select what you want to restore. Warning: Existing data may be overwritten.')) + ->modalWidth('xl') + ->form(function (Backup $record): array { + // Check if this is a remote backup (no local files) + $isRemoteBackup = ! $record->local_path || ! file_exists($record->local_path); + + $manifest = $this->getBackupManifest($record); + $users = $manifest['users'] ?? $record->users ?? []; + if (empty($users)) { + $users = [$manifest['username'] ?? '']; + } + $users = array_filter($users); + + $isServerBackup = ($manifest['type'] ?? $record->type) === 'server' && count($users) > 1; + + // For server backups, get data for first user by default + $selectedUser = $users[0] ?? ''; + if ($isServerBackup && ! empty($selectedUser) && ! $isRemoteBackup) { + $manifest = $this->getBackupManifest($record, $selectedUser); + } + + // For remote backups, use include_* flags from record + if ($isRemoteBackup) { + $hasFiles = $record->include_files ?? true; + $hasDatabases = $record->include_databases ?? true; + $hasMailboxes = $record->include_mailboxes ?? true; + $hasDns = $record->include_dns ?? true; + $hasSsl = true; // Assume SSL is included + $domains = []; + $databases = []; + $mailboxes = []; + } else { + $domains = $manifest['domains'] ?? []; + $databases = $manifest['databases'] ?? []; + $mailboxes = $manifest['mailboxes'] ?? []; + $hasFiles = ! empty($domains); + $hasDatabases = ! empty($databases); + $hasMailboxes = ! empty($mailboxes); + $hasDns = ! empty($manifest['dns_zones'] ?? []); + $hasSsl = ! empty($manifest['ssl_certificates'] ?? []); + } + + $schema = []; + + // Backup info section + $infoSchema = [ + TextInput::make('backup_name') + ->label(__('Backup')) + ->default($record->name) + ->disabled(), + ]; + + // Add user selector for server backups with multiple users + if ($isServerBackup || count($users) > 1) { + $infoSchema[] = Select::make('restore_username') + ->label(__('User to Restore')) + ->options(array_combine($users, $users)) + ->default($selectedUser) + ->required() + ->helperText(__('Backup contains :count user(s)', ['count' => count($users)])); + } else { + $infoSchema[] = TextInput::make('restore_username') + ->label(__('User')) + ->default($selectedUser) + ->disabled(); + } + + $schema[] = Section::make(__('Backup Information')) + ->schema([Grid::make(2)->schema($infoSchema)]); + + // Remote backup notice + if ($isRemoteBackup) { + $schema[] = Section::make(__('Remote Backup')) + ->description(__('This backup will be downloaded from the remote destination before restoring.')) + ->icon('heroicon-o-cloud-arrow-down') + ->iconColor('info'); + } + + // Restore options section + $restoreOptions = []; + + // Website Files + $filesLabel = __('Website Files'); + if (! $isRemoteBackup && ! empty($domains)) { + $filesLabel .= ' ('.count($domains).')'; + } + $restoreOptions[] = Toggle::make('restore_files') + ->label($filesLabel) + ->helperText($isRemoteBackup + ? __('Restore all domain files') + : (! empty($domains) ? implode(', ', array_slice($domains, 0, 3)).(count($domains) > 3 ? '...' : '') : __('No files'))) + ->default($hasFiles) + ->disabled(! $hasFiles && ! $isRemoteBackup); + + if (! $isRemoteBackup && count($domains) > 1) { + $restoreOptions[] = Select::make('selected_domains') + ->label(__('Specific Domains')) + ->multiple() + ->options(fn () => array_combine($domains, $domains)) + ->placeholder(__('All domains')) + ->visible(fn ($get) => $get('restore_files')); + } + + // Databases + $dbLabel = __('Databases'); + if (! $isRemoteBackup && ! empty($databases)) { + $dbLabel .= ' ('.count($databases).')'; + } + $restoreOptions[] = Toggle::make('restore_databases') + ->label($dbLabel) + ->helperText($isRemoteBackup + ? __('Restore all databases') + : (! empty($databases) ? implode(', ', array_slice($databases, 0, 3)).(count($databases) > 3 ? '...' : '') : __('No databases'))) + ->default($hasDatabases) + ->disabled(! $hasDatabases && ! $isRemoteBackup); + + if (! $isRemoteBackup && count($databases) > 1) { + $restoreOptions[] = Select::make('selected_databases') + ->label(__('Specific Databases')) + ->multiple() + ->options(fn () => array_combine($databases, $databases)) + ->placeholder(__('All databases')) + ->visible(fn ($get) => $get('restore_databases')); + } + + // MySQL Users + $restoreOptions[] = Toggle::make('restore_mysql_users') + ->label(__('MySQL Users')) + ->default($hasDatabases) + ->helperText(__('Restore MySQL users and their permissions')); + + // Mailboxes + $mailLabel = __('Mailboxes'); + if (! $isRemoteBackup && ! empty($mailboxes)) { + $mailLabel .= ' ('.count($mailboxes).')'; + } + $restoreOptions[] = Toggle::make('restore_mailboxes') + ->label($mailLabel) + ->helperText($isRemoteBackup + ? __('Restore all mailboxes') + : (! empty($mailboxes) ? implode(', ', array_slice($mailboxes, 0, 3)).(count($mailboxes) > 3 ? '...' : '') : __('No mailboxes'))) + ->default($hasMailboxes) + ->disabled(! $hasMailboxes && ! $isRemoteBackup); + + // SSL Certificates + $restoreOptions[] = Toggle::make('restore_ssl') + ->label(__('SSL Certificates')) + ->default(false) + ->helperText(__('Restore SSL certificates for domains')); + + // DNS Zones + $restoreOptions[] = Toggle::make('restore_dns') + ->label(__('DNS Zones')) + ->default($hasDns) + ->helperText(__('Restore DNS zone files')); + + $schema[] = Section::make(__('Restore Options')) + ->description(__('Toggle items you want to restore.')) + ->schema($restoreOptions); + + return $schema; + }) + ->action(function (array $data, Backup $record): void { + $this->executeRestore($record, $data); + }) + ->modalSubmitActionLabel(__('Restore')) + ->requiresConfirmation(), + Action::make('download') + ->label(__('Download')) + ->icon('heroicon-o-arrow-down-tray') + ->color('gray') + ->size('sm') + ->visible(fn (Backup $record): bool => $record->canDownload()) + ->url(fn (Backup $record): string => route('filament.admin.pages.backup-download', ['id' => $record->id])) + ->openUrlInNewTab(), + Action::make('delete') + ->label(__('Delete')) + ->icon('heroicon-o-trash') + ->color('danger') + ->size('sm') + ->requiresConfirmation() + ->action(fn (Backup $record) => $this->deleteBackup($record->id)), + ]) + ->emptyStateHeading(__('No server backups yet')) + ->emptyStateDescription(__('Click "Create Server Backup" to create your first backup')) + ->emptyStateIcon('heroicon-o-archive-box') + ->striped() + ->poll(fn () => Backup::whereIn('status', ['pending', 'running', 'uploading'])->exists() ? '3s' : null); + } + + public function getTableRecordKey(Model|array $record): string + { + return is_array($record) ? (string) $record['id'] : (string) $record->getKey(); + } + + protected function getHeaderActions(): array + { + return [ + $this->getTourAction(), + Action::make('createServerBackup') + ->label(__('Create Server Backup')) + ->icon('heroicon-o-archive-box-arrow-down') + ->color('primary') + ->form([ + TextInput::make('name') + ->label(__('Backup Name')) + ->default(fn () => __('Server Backup').' '.now()->format('Y-m-d H:i')) + ->required(), + Select::make('destination_id') + ->label(__('Destination')) + ->options(fn () => BackupDestination::where('is_server_backup', true) + ->where('is_active', true) + ->pluck('name', 'id') + ->prepend(__('Local Storage'), '')) + ->default('') + ->live() + ->afterStateUpdated(fn ($set, $state) => $set('backup_type', $this->supportsIncremental($state) ? 'incremental' : 'full')), + Radio::make('backup_type') + ->label(__('Backup Type')) + ->options(fn ($get) => $this->supportsIncremental($get('destination_id')) + ? [ + 'incremental' => __('Incremental (rsync) - Space-efficient'), + 'full' => __('Full (tar.gz) - Complete archive'), + ] + : [ + 'full' => __('Full (tar.gz) - Complete archive'), + ]) + ->default('full') + ->required(), + TextInput::make('local_path') + ->label(__('Local Backup Folder')) + ->default('/var/backups/jabali') + ->visible(fn ($get) => empty($get('destination_id'))), + Section::make(__('Include')) + ->schema([ + Grid::make(2)->schema([ + Toggle::make('include_files')->label(__('Website Files'))->default(true), + Toggle::make('include_databases')->label(__('Databases'))->default(true), + Toggle::make('include_mailboxes')->label(__('Mailboxes'))->default(true), + Toggle::make('include_dns')->label(__('DNS Records'))->default(true), + ]), + ]), + Select::make('users') + ->label(__('Users to Backup')) + ->multiple() + ->options(fn () => User::where('is_admin', false) + ->where('is_active', true) + ->pluck('username', 'username')) + ->placeholder(__('All Users')), + ]) + ->action(function (array $data) { + $this->createServerBackup($data); + }), + + $this->createUserBackupAction(), + + Action::make('addDestination') + ->label(__('Add Destination')) + ->icon('heroicon-o-plus') + ->color('gray') + ->form($this->getDestinationForm()) + ->action(function (array $data) { + $this->saveDestination($data); + }), + ]; + } + + protected function getDestinationForm(): array + { + return [ + TextInput::make('name') + ->label(__('Destination Name')) + ->required(), + Select::make('type') + ->label(__('Type')) + ->options([ + 'sftp' => __('SFTP Server'), + 'nfs' => __('NFS Mount'), + 's3' => __('S3-Compatible Storage'), + ]) + ->required() + ->live(), + + Section::make(__('SFTP Settings')) + ->visible(fn ($get) => $get('type') === 'sftp') + ->schema([ + Grid::make(2)->schema([ + TextInput::make('host')->label(__('Host'))->required(), + TextInput::make('port')->label(__('Port'))->numeric()->default(22), + ]), + TextInput::make('username')->label(__('Username'))->required(), + TextInput::make('password')->label(__('Password'))->password(), + Textarea::make('private_key')->label(__('Private Key (SSH)'))->rows(4), + TextInput::make('path')->label(__('Remote Path'))->default('/backups'), + ]), + + Section::make(__('NFS Settings')) + ->visible(fn ($get) => $get('type') === 'nfs') + ->schema([ + TextInput::make('server')->label(__('NFS Server'))->required(), + TextInput::make('share')->label(__('Share Path'))->required(), + TextInput::make('path')->label(__('Sub-directory'))->default(''), + ]), + + Section::make(__('S3-Compatible Settings')) + ->visible(fn ($get) => $get('type') === 's3') + ->schema([ + TextInput::make('endpoint')->label(__('Endpoint URL')), + TextInput::make('bucket')->label(__('Bucket Name'))->required(), + Grid::make(2)->schema([ + TextInput::make('access_key')->label(__('Access Key ID'))->required(), + TextInput::make('secret_key')->label(__('Secret Access Key'))->password()->required(), + ]), + TextInput::make('region')->label(__('Region'))->default('us-east-1'), + TextInput::make('path')->label(__('Path Prefix'))->default('backups'), + ]), + + Toggle::make('is_default')->label(__('Set as Default Destination')), + + FormActions::make([ + Action::make('testConnection') + ->label(__('Test Connection')) + ->icon('heroicon-o-signal') + ->color('gray') + ->action(function ($get, $livewire) { + $type = $get('type'); + if (empty($type)) { + Notification::make() + ->title(__('Select a destination type first')) + ->warning() + ->send(); + + return; + } + + $config = match ($type) { + 'sftp' => [ + 'type' => 'sftp', + 'host' => $get('host') ?? '', + 'port' => (int) ($get('port') ?? 22), + 'username' => $get('username') ?? '', + 'password' => $get('password') ?? '', + 'private_key' => $get('private_key') ?? '', + 'path' => $get('path') ?? '/backups', + ], + 'nfs' => [ + 'type' => 'nfs', + 'server' => $get('server') ?? '', + 'share' => $get('share') ?? '', + 'path' => $get('path') ?? '', + ], + 's3' => [ + 'type' => 's3', + 'endpoint' => $get('endpoint') ?? '', + 'bucket' => $get('bucket') ?? '', + 'access_key' => $get('access_key') ?? '', + 'secret_key' => $get('secret_key') ?? '', + 'region' => $get('region') ?? 'us-east-1', + 'path' => $get('path') ?? 'backups', + ], + default => [], + }; + + if (empty($config)) { + Notification::make() + ->title(__('Invalid destination type')) + ->danger() + ->send(); + + return; + } + + try { + $result = $livewire->getAgent()->backupTestDestination($config); + if ($result['success']) { + Notification::make() + ->title(__('Connection successful')) + ->body(__('The destination is reachable and ready to use.')) + ->success() + ->send(); + } else { + Notification::make() + ->title(__('Connection failed')) + ->body($result['error'] ?? __('Could not connect to destination')) + ->danger() + ->send(); + } + } catch (Exception $e) { + Notification::make() + ->title(__('Connection test failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + }), + ])->visible(fn ($get) => ! empty($get('type'))), + ]; + } + + public function saveDestination(array $data): void + { + $config = []; + $type = $data['type']; + + switch ($type) { + case 'sftp': + $config = [ + 'host' => $data['host'] ?? '', + 'port' => (int) ($data['port'] ?? 22), + 'username' => $data['username'] ?? '', + 'password' => $data['password'] ?? '', + 'private_key' => $data['private_key'] ?? '', + 'path' => $data['path'] ?? '/backups', + ]; + break; + + case 'nfs': + $config = [ + 'server' => $data['server'] ?? '', + 'share' => $data['share'] ?? '', + 'path' => $data['path'] ?? '', + ]; + break; + + case 's3': + $config = [ + 'endpoint' => $data['endpoint'] ?? '', + 'bucket' => $data['bucket'] ?? '', + 'access_key' => $data['access_key'] ?? '', + 'secret_key' => $data['secret_key'] ?? '', + 'region' => $data['region'] ?? 'us-east-1', + 'path' => $data['path'] ?? 'backups', + ]; + break; + } + + $testConfig = array_merge($config, ['type' => $type]); + try { + $result = $this->getAgent()->backupTestDestination($testConfig); + if (! $result['success']) { + Notification::make() + ->title(__('Connection failed')) + ->body($result['error'] ?? __('Could not connect to destination')) + ->danger() + ->send(); + + return; + } + } catch (Exception $e) { + Notification::make() + ->title(__('Connection test failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + + return; + } + + BackupDestination::create([ + 'name' => $data['name'], + 'type' => $type, + 'config' => $config, + 'is_server_backup' => true, + 'is_default' => $data['is_default'] ?? false, + 'is_active' => true, + 'last_tested_at' => now(), + 'test_status' => 'success', + ]); + + Notification::make()->title(__('Destination verified and added'))->success()->send(); + $this->resetTable(); + } + + public function testDestination(int $id): void + { + $destination = BackupDestination::find($id); + if (! $destination) { + return; + } + + try { + $config = array_merge($destination->config ?? [], ['type' => $destination->type]); + $result = $this->getAgent()->backupTestDestination($config); + + $destination->update([ + 'last_tested_at' => now(), + 'test_status' => $result['success'] ? 'success' : 'failed', + 'test_message' => $result['message'] ?? $result['error'] ?? null, + ]); + + if ($result['success']) { + Notification::make()->title(__('Connection successful'))->success()->send(); + } else { + Notification::make()->title(__('Connection failed'))->body($result['error'] ?? __('Unknown error'))->danger()->send(); + } + } catch (Exception $e) { + $destination->update([ + 'last_tested_at' => now(), + 'test_status' => 'failed', + 'test_message' => $e->getMessage(), + ]); + Notification::make()->title(__('Test failed'))->body($e->getMessage())->danger()->send(); + } + + $this->resetTable(); + } + + public function deleteDestination(int $id): void + { + BackupDestination::where('id', $id)->delete(); + Notification::make()->title(__('Destination deleted'))->success()->send(); + $this->resetTable(); + } + + public function createServerBackup(array $data): void + { + $backupType = $data['backup_type'] ?? 'full'; + $timestamp = now()->format('Y-m-d_His'); + $folderName = $timestamp; + $baseFolder = rtrim($data['local_path'] ?? '/var/backups/jabali', '/'); + $outputPath = "{$baseFolder}/{$folderName}"; + + $isIncrementalRemote = $backupType === 'incremental' && ! empty($data['destination_id']); + + if ($isIncrementalRemote) { + $destination = BackupDestination::find($data['destination_id']); + if (! $destination || ! in_array($destination->type, ['sftp', 'nfs'])) { + Notification::make() + ->title(__('Invalid destination')) + ->body(__('Incremental backups require an SFTP or NFS destination')) + ->danger() + ->send(); + + return; + } + } + + // Create backup record with pending status + $backup = Backup::create([ + 'name' => $data['name'], + 'filename' => $folderName, + 'type' => 'server', + 'include_files' => $data['include_files'] ?? true, + 'include_databases' => $data['include_databases'] ?? true, + 'include_mailboxes' => $data['include_mailboxes'] ?? true, + 'include_dns' => $data['include_dns'] ?? true, + 'users' => ! empty($data['users']) ? $data['users'] : null, + 'destination_id' => ! empty($data['destination_id']) ? $data['destination_id'] : null, + 'schedule_id' => $data['schedule_id'] ?? null, + 'status' => 'pending', + 'local_path' => $isIncrementalRemote ? null : $outputPath, + 'metadata' => ['backup_type' => $backupType], + ]); + + // Dispatch job to run backup in background + \App\Jobs\RunServerBackup::dispatch($backup->id); + + // Show notification and refresh table + Notification::make() + ->title(__('Backup started')) + ->body(__('The backup is running in the background. The status will update automatically.')) + ->info() + ->send(); + + $this->resetTable(); + } + + protected function uploadToRemote(Backup $backup, bool $keepLocal = false): bool + { + if (! $backup->destination || ! $backup->local_path) { + return false; + } + + try { + $backup->update(['status' => 'uploading']); + + $config = array_merge($backup->destination->config ?? [], ['type' => $backup->destination->type]); + $backupType = $backup->metadata['backup_type'] ?? 'full'; + $result = $this->getAgent()->backupUploadRemote($backup->local_path, $config, $backupType); + + if ($result['success']) { + $backup->update([ + 'status' => 'completed', + 'remote_path' => $result['remote_path'] ?? null, + ]); + + if (! $keepLocal && $backup->local_path) { + $this->getAgent()->backupDeleteServer($backup->local_path); + $backup->update(['local_path' => null]); + } + + return true; + } else { + throw new Exception($result['error'] ?? __('Upload failed')); + } + } catch (Exception $e) { + $backup->update([ + 'status' => 'completed', + 'error_message' => __('Remote upload failed').': '.$e->getMessage(), + ]); + + return false; + } + } + + public function deleteBackup(int $id): void + { + $backup = Backup::find($id); + if (! $backup) { + return; + } + + // Delete local file/folder + if ($backup->local_path && file_exists($backup->local_path)) { + if (is_file($backup->local_path)) { + unlink($backup->local_path); + } else { + exec('rm -rf '.escapeshellarg($backup->local_path)); + } + } + + // Delete from remote destination if exists + if ($backup->remote_path && $backup->destination) { + try { + $config = array_merge( + $backup->destination->config ?? [], + ['type' => $backup->destination->type] + ); + $this->getAgent()->send('backup.delete_remote', [ + 'remote_path' => $backup->remote_path, + 'destination' => $config, + ]); + } catch (Exception $e) { + // Log but continue - we still want to delete the DB record + logger()->warning('Failed to delete remote backup: '.$e->getMessage()); + } + } + + $backup->delete(); + Notification::make()->title(__('Backup deleted'))->success()->send(); + $this->resetTable(); + } + + public function addScheduleAction(): Action + { + return Action::make('addSchedule') + ->label(__('Add Schedule')) + ->icon('heroicon-o-clock') + ->color('primary') + ->form([ + TextInput::make('name') + ->label(__('Schedule Name')) + ->required(), + Select::make('destination_id') + ->label(__('Destination')) + ->options(fn () => BackupDestination::where('is_server_backup', true) + ->where('is_active', true) + ->pluck('name', 'id') + ->prepend(__('Local Storage'), '')) + ->default('') + ->live() + ->afterStateUpdated(fn ($set, $state) => $set('backup_type', $this->supportsIncremental($state) ? 'incremental' : 'full')), + Radio::make('backup_type') + ->label(__('Backup Type')) + ->options(fn ($get) => $this->supportsIncremental($get('destination_id')) + ? [ + 'incremental' => __('Incremental (rsync)'), + 'full' => __('Full (tar.gz)'), + ] + : [ + 'full' => __('Full (tar.gz)'), + ]) + ->default('full') + ->required(), + Select::make('frequency') + ->label(__('Frequency')) + ->options([ + 'hourly' => __('Hourly'), + 'daily' => __('Daily'), + 'weekly' => __('Weekly'), + 'monthly' => __('Monthly'), + ]) + ->required() + ->live(), + TextInput::make('time') + ->label(__('Time (HH:MM)')) + ->default('02:00') + ->visible(fn ($get) => in_array($get('frequency'), ['daily', 'weekly', 'monthly'])), + Select::make('day_of_week') + ->label(__('Day of Week')) + ->options([ + 0 => __('Sunday'), 1 => __('Monday'), 2 => __('Tuesday'), + 3 => __('Wednesday'), 4 => __('Thursday'), 5 => __('Friday'), 6 => __('Saturday'), + ]) + ->visible(fn ($get) => $get('frequency') === 'weekly'), + Select::make('day_of_month') + ->label(__('Day of Month')) + ->options(array_combine(range(1, 28), range(1, 28))) + ->visible(fn ($get) => $get('frequency') === 'monthly'), + TextInput::make('retention_count') + ->label(__('Keep Last N Backups')) + ->numeric() + ->default(7), + Section::make(__('Include')) + ->schema([ + Grid::make(2)->schema([ + Toggle::make('include_files')->label(__('Website Files'))->default(true), + Toggle::make('include_databases')->label(__('Databases'))->default(true), + Toggle::make('include_mailboxes')->label(__('Mailboxes'))->default(true), + Toggle::make('include_dns')->label(__('DNS Records'))->default(true), + ]), + ]), + ]) + ->action(function (array $data) { + $schedule = BackupSchedule::create([ + 'name' => $data['name'], + 'is_server_backup' => true, + 'is_active' => true, + 'frequency' => $data['frequency'], + 'time' => $data['time'] ?? '02:00', + 'day_of_week' => $data['day_of_week'] ?? null, + 'day_of_month' => $data['day_of_month'] ?? null, + 'destination_id' => ! empty($data['destination_id']) ? $data['destination_id'] : null, + 'retention_count' => $data['retention_count'] ?? 7, + 'include_files' => $data['include_files'] ?? true, + 'include_databases' => $data['include_databases'] ?? true, + 'include_mailboxes' => $data['include_mailboxes'] ?? true, + 'include_dns' => $data['include_dns'] ?? true, + 'metadata' => ['backup_type' => $data['backup_type'] ?? 'full'], + ]); + + $schedule->calculateNextRun(); + $schedule->save(); + + Notification::make()->title(__('Schedule created'))->success()->send(); + $this->resetTable(); + }); + } + + public function toggleSchedule(int $id): void + { + $schedule = BackupSchedule::find($id); + if (! $schedule) { + return; + } + + $schedule->update(['is_active' => ! $schedule->is_active]); + + if ($schedule->is_active) { + $schedule->calculateNextRun(); + $schedule->save(); + } + + Notification::make()->title($schedule->is_active ? __('Schedule enabled') : __('Schedule disabled'))->success()->send(); + $this->resetTable(); + } + + public function deleteSchedule(int $id): void + { + BackupSchedule::where('id', $id)->delete(); + Notification::make()->title(__('Schedule deleted'))->success()->send(); + $this->resetTable(); + } + + public function editScheduleAction(): Action + { + return Action::make('editSchedule') + ->label(__('Edit Schedule')) + ->icon('heroicon-o-pencil') + ->color('gray') + ->fillForm(function (array $arguments): array { + $schedule = BackupSchedule::find($arguments['id']); + if (! $schedule) { + return []; + } + + return [ + 'name' => $schedule->name, + 'backup_type' => $schedule->metadata['backup_type'] ?? 'full', + 'frequency' => $schedule->frequency, + 'time' => $schedule->time, + 'day_of_week' => $schedule->day_of_week, + 'day_of_month' => $schedule->day_of_month, + 'destination_id' => $schedule->destination_id ?? '', + 'retention_count' => $schedule->retention_count, + 'include_files' => $schedule->include_files, + 'include_databases' => $schedule->include_databases, + 'include_mailboxes' => $schedule->include_mailboxes, + 'include_dns' => $schedule->include_dns, + ]; + }) + ->form([ + TextInput::make('name')->label(__('Schedule Name'))->required(), + Select::make('destination_id') + ->label(__('Destination')) + ->options(fn () => BackupDestination::where('is_server_backup', true) + ->where('is_active', true) + ->pluck('name', 'id') + ->prepend(__('Local Storage'), '')) + ->default('') + ->live(), + Radio::make('backup_type') + ->label(__('Backup Type')) + ->options(fn ($get) => $this->supportsIncremental($get('destination_id')) + ? ['incremental' => __('Incremental'), 'full' => __('Full')] + : ['full' => __('Full')]) + ->required(), + Select::make('frequency') + ->label(__('Frequency')) + ->options(['hourly' => __('Hourly'), 'daily' => __('Daily'), 'weekly' => __('Weekly'), 'monthly' => __('Monthly')]) + ->required() + ->live(), + TextInput::make('time')->label(__('Time (HH:MM)'))->visible(fn ($get) => in_array($get('frequency'), ['daily', 'weekly', 'monthly'])), + Select::make('day_of_week') + ->label(__('Day of Week')) + ->options([0 => __('Sunday'), 1 => __('Monday'), 2 => __('Tuesday'), 3 => __('Wednesday'), 4 => __('Thursday'), 5 => __('Friday'), 6 => __('Saturday')]) + ->visible(fn ($get) => $get('frequency') === 'weekly'), + Select::make('day_of_month') + ->label(__('Day of Month')) + ->options(array_combine(range(1, 28), range(1, 28))) + ->visible(fn ($get) => $get('frequency') === 'monthly'), + TextInput::make('retention_count')->label(__('Keep Last N Backups'))->numeric(), + Section::make(__('Include')) + ->schema([ + Grid::make(2)->schema([ + Toggle::make('include_files')->label(__('Website Files')), + Toggle::make('include_databases')->label(__('Databases')), + Toggle::make('include_mailboxes')->label(__('Mailboxes')), + Toggle::make('include_dns')->label(__('DNS Records')), + ]), + ]), + ]) + ->action(function (array $data, array $arguments) { + $schedule = BackupSchedule::find($arguments['id']); + if (! $schedule) { + return; + } + + $schedule->update([ + 'name' => $data['name'], + 'frequency' => $data['frequency'], + 'time' => $data['time'] ?? '02:00', + 'day_of_week' => $data['day_of_week'] ?? null, + 'day_of_month' => $data['day_of_month'] ?? null, + 'destination_id' => ! empty($data['destination_id']) ? $data['destination_id'] : null, + 'retention_count' => $data['retention_count'] ?? 7, + 'include_files' => $data['include_files'] ?? true, + 'include_databases' => $data['include_databases'] ?? true, + 'include_mailboxes' => $data['include_mailboxes'] ?? true, + 'include_dns' => $data['include_dns'] ?? true, + 'metadata' => array_merge($schedule->metadata ?? [], ['backup_type' => $data['backup_type'] ?? 'full']), + ]); + + $schedule->calculateNextRun(); + $schedule->save(); + + Notification::make()->title(__('Schedule updated'))->success()->send(); + $this->resetTable(); + }); + } + + public function runScheduleNow(int $id): void + { + $schedule = BackupSchedule::find($id); + if (! $schedule) { + return; + } + + $runningBackup = Backup::where('schedule_id', $id)->running()->first(); + if ($runningBackup) { + Notification::make()->title(__('Backup already running'))->warning()->send(); + + return; + } + + $this->createServerBackup([ + 'name' => $schedule->name.' - '.__('Manual Run').' '.now()->format('Y-m-d H:i'), + 'backup_type' => $schedule->metadata['backup_type'] ?? 'full', + 'destination_id' => $schedule->destination_id, + 'schedule_id' => $schedule->id, + 'include_files' => $schedule->include_files, + 'include_databases' => $schedule->include_databases, + 'include_mailboxes' => $schedule->include_mailboxes, + 'include_dns' => $schedule->include_dns, + 'users' => $schedule->users, + ]); + } + + public function stopScheduleBackup(int $id): void + { + $backup = Backup::where('schedule_id', $id)->running()->first(); + if ($backup) { + $backup->update([ + 'status' => 'failed', + 'error_message' => __('Cancelled by user'), + 'completed_at' => now(), + ]); + Notification::make()->title(__('Backup cancelled'))->success()->send(); + $this->resetTable(); + } + } + + public function createUserBackupAction(): Action + { + return Action::make('createUserBackup') + ->label(__('Backup User')) + ->icon('heroicon-o-user') + ->color('gray') + ->form([ + Select::make('user_id') + ->label(__('User')) + ->options(fn () => User::where('is_admin', false) + ->where('is_active', true) + ->pluck('username', 'id')) + ->required() + ->searchable(), + Section::make(__('Include')) + ->schema([ + Grid::make(2)->schema([ + Toggle::make('include_files')->label(__('Website Files'))->default(true), + Toggle::make('include_databases')->label(__('Databases'))->default(true), + Toggle::make('include_mailboxes')->label(__('Mailboxes'))->default(true), + Toggle::make('include_dns')->label(__('DNS Records'))->default(true), + ]), + ]), + ]) + ->action(function (array $data) { + $user = User::find($data['user_id']); + if (! $user) { + Notification::make()->title(__('User not found'))->danger()->send(); + + return; + } + + $timestamp = now()->format('Y-m-d_His'); + $filename = "backup_{$timestamp}.tar.gz"; + $outputPath = "/home/{$user->username}/backups/{$filename}"; + + $backup = Backup::create([ + 'user_id' => $user->id, + 'name' => "{$user->username} ".__('Backup').' '.now()->format('Y-m-d H:i'), + 'filename' => $filename, + 'type' => 'full', + 'include_files' => $data['include_files'] ?? true, + 'include_databases' => $data['include_databases'] ?? true, + 'include_mailboxes' => $data['include_mailboxes'] ?? true, + 'include_dns' => $data['include_dns'] ?? true, + 'status' => 'pending', + 'local_path' => $outputPath, + 'metadata' => ['backup_type' => 'full'], + ]); + + try { + $backup->update(['status' => 'running', 'started_at' => now()]); + + $result = $this->getAgent()->backupCreate($user->username, $outputPath, [ + 'backup_type' => 'full', + 'include_files' => $data['include_files'] ?? true, + 'include_databases' => $data['include_databases'] ?? true, + 'include_mailboxes' => $data['include_mailboxes'] ?? true, + 'include_dns' => $data['include_dns'] ?? true, + ]); + + if ($result['success']) { + $backup->update([ + 'status' => 'completed', + 'completed_at' => now(), + 'size_bytes' => $result['size'] ?? 0, + 'checksum' => $result['checksum'] ?? null, + 'domains' => $result['domains'] ?? null, + 'databases' => $result['databases'] ?? null, + 'mailboxes' => $result['mailboxes'] ?? null, + ]); + Notification::make()->title(__('Backup created for :username', ['username' => $user->username]))->success()->send(); + } else { + throw new Exception($result['error'] ?? __('Backup failed')); + } + } catch (Exception $e) { + $backup->update([ + 'status' => 'failed', + 'completed_at' => now(), + 'error_message' => $e->getMessage(), + ]); + Notification::make()->title(__('Backup failed'))->body($e->getMessage())->danger()->send(); + } + + $this->resetTable(); + }); + } + + protected function executeRestore(Backup $backup, array $data): void + { + // Use username from form (allows selecting user for server backups) + $username = $data['restore_username'] ?? ''; + + if (empty($username)) { + $manifest = $this->getBackupManifest($backup); + $username = $manifest['username'] ?? ($backup->users[0] ?? ''); + } + + if (empty($username)) { + Notification::make()->title(__('Cannot determine user for this backup'))->danger()->send(); + + return; + } + + // Prepare backup path + $backupPath = $backup->local_path; + $tempDownloadPath = null; + + // For remote backups, download first + if ((! $backupPath || ! file_exists($backupPath)) && $backup->remote_path && $backup->destination) { + $destination = $backup->destination; + + if (! $destination) { + Notification::make()->title(__('Backup destination not found'))->danger()->send(); + + return; + } + + // Create temp directory for download + $tempDownloadPath = sys_get_temp_dir().'/jabali_restore_download_'.uniqid(); + mkdir($tempDownloadPath, 0755, true); + + // For incremental backups, we need to download the specific user's directory + $remotePath = $backup->remote_path; + + // If it's a server backup with per-user directories, construct the user-specific path + if (str_contains($remotePath, '/') && ! str_ends_with($remotePath, '.tar.gz')) { + // Incremental backup - download the user's directory + $userRemotePath = rtrim($remotePath, '/').'/'.$username; + } else { + $userRemotePath = $remotePath; + } + + Notification::make() + ->title(__('Downloading backup')) + ->body(__('Downloading from remote destination...')) + ->info() + ->send(); + + try { + $downloadResult = $this->getAgent()->send('backup.download_remote', [ + 'remote_path' => $userRemotePath, + 'local_path' => $tempDownloadPath, + 'destination' => array_merge( + $destination->config ?? [], + ['type' => $destination->type] + ), + ]); + + if (! ($downloadResult['success'] ?? false)) { + throw new Exception($downloadResult['error'] ?? __('Failed to download backup')); + } + + $backupPath = $tempDownloadPath; + } catch (Exception $e) { + // Cleanup temp directory on failure + if (is_dir($tempDownloadPath)) { + exec('rm -rf '.escapeshellarg($tempDownloadPath)); + } + Notification::make() + ->title(__('Download failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + + return; + } + } + + if (! $backupPath || ! file_exists($backupPath)) { + Notification::make()->title(__('Backup file not found'))->danger()->send(); + + return; + } + + try { + $result = $this->getAgent()->send('backup.restore', [ + 'username' => $username, + 'backup_path' => $backupPath, + 'restore_files' => $data['restore_files'] ?? false, + 'restore_databases' => $data['restore_databases'] ?? false, + 'restore_mailboxes' => $data['restore_mailboxes'] ?? false, + 'restore_dns' => $data['restore_dns'] ?? false, + 'restore_ssl' => $data['restore_ssl'] ?? false, + 'selected_domains' => ! empty($data['selected_domains']) ? $data['selected_domains'] : null, + 'selected_databases' => ! empty($data['selected_databases']) ? $data['selected_databases'] : null, + 'selected_mailboxes' => ! empty($data['selected_mailboxes']) ? $data['selected_mailboxes'] : null, + ]); + + // Cleanup temp download if used + if ($tempDownloadPath && is_dir($tempDownloadPath)) { + exec('rm -rf '.escapeshellarg($tempDownloadPath)); + } + + if ($result['success'] ?? false) { + $restored = $result['restored'] ?? []; + $summary = []; + + if (! empty($restored['files'])) { + $summary[] = count($restored['files']).' '.__('domain(s)'); + } + if (! empty($restored['databases'])) { + $summary[] = count($restored['databases']).' '.__('database(s)'); + } + if (! empty($restored['mailboxes'])) { + $summary[] = count($restored['mailboxes']).' '.__('mailbox(es)'); + } + if (! empty($restored['ssl_certificates'])) { + $summary[] = count($restored['ssl_certificates']).' '.__('SSL cert(s)'); + } + if (! empty($restored['dns_zones'])) { + $summary[] = count($restored['dns_zones']).' '.__('DNS zone(s)'); + } + if ($restored['mysql_users'] ?? false) { + $summary[] = __('MySQL users'); + } + + Notification::make() + ->title(__('Restore completed')) + ->body(! empty($summary) ? __('Restored: :items', ['items' => implode(', ', $summary)]) : __('Nothing was restored')) + ->success() + ->send(); + } else { + throw new Exception($result['error'] ?? __('Restore failed')); + } + } catch (Exception $e) { + // Cleanup temp download on failure + if ($tempDownloadPath && is_dir($tempDownloadPath)) { + exec('rm -rf '.escapeshellarg($tempDownloadPath)); + } + Notification::make() + ->title(__('Restore failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + protected function getBackupManifest(Backup $backup, ?string $forUser = null): array + { + $backupPath = $backup->local_path; + + if (! $backupPath || ! file_exists($backupPath)) { + // For remote backups, try to get info from stored metadata + return [ + 'username' => $forUser ?? ($backup->users[0] ?? ''), + 'domains' => $backup->domains ?? [], + 'databases' => $backup->databases ?? [], + 'mailboxes' => $backup->mailboxes ?? [], + 'mysql_users' => $backup->metadata['mysql_users'] ?? [], + 'ssl_certificates' => $backup->ssl_certificates ?? [], + 'dns_zones' => $backup->dns_zones ?? [], + 'users' => $backup->users ?? [], + ]; + } + + try { + $result = $this->getAgent()->send('backup.get_info', [ + 'backup_path' => $backupPath, + ]); + + if ($result['success'] ?? false) { + $manifest = $result['manifest'] ?? []; + + // Handle server backup manifest format (has nested 'users' object) + if (isset($manifest['users']) && is_array($manifest['users']) && $manifest['type'] === 'server') { + $userList = array_keys($manifest['users']); + + // If no specific user requested, return aggregated data + if ($forUser === null) { + return [ + 'username' => $userList[0] ?? '', + 'users' => $userList, + 'type' => 'server', + 'domains' => $this->aggregateFromUsers($manifest['users'], 'domains'), + 'databases' => $this->aggregateFromUsers($manifest['users'], 'databases'), + 'mailboxes' => $this->aggregateFromUsers($manifest['users'], 'mailboxes'), + 'mysql_users' => [], + 'ssl_certificates' => [], + 'dns_zones' => [], + ]; + } + + // Return specific user's data + if (isset($manifest['users'][$forUser])) { + $userData = $manifest['users'][$forUser]; + + return [ + 'username' => $forUser, + 'users' => $userList, + 'type' => 'server', + 'domains' => $userData['domains'] ?? [], + 'databases' => $userData['databases'] ?? [], + 'mailboxes' => $userData['mailboxes'] ?? [], + 'mysql_users' => [], + 'ssl_certificates' => [], + 'dns_zones' => [], + ]; + } + } + + return $manifest; + } + } catch (Exception $e) { + // Fall back to stored data + } + + return [ + 'username' => $forUser ?? ($backup->users[0] ?? ''), + 'domains' => $backup->domains ?? [], + 'databases' => $backup->databases ?? [], + 'mailboxes' => $backup->mailboxes ?? [], + 'mysql_users' => [], + 'ssl_certificates' => [], + 'dns_zones' => [], + 'users' => $backup->users ?? [], + ]; + } + + protected function aggregateFromUsers(array $users, string $key): array + { + $result = []; + foreach ($users as $userData) { + if (isset($userData[$key]) && is_array($userData[$key])) { + $result = array_merge($result, $userData[$key]); + } + } + + return array_unique($result); + } + + /** + * Get the system timezone for display purposes. + * Laravel uses UTC internally but we display times in server's local timezone. + */ + protected function getSystemTimezone(): string + { + static $timezone = null; + if ($timezone === null) { + $timezone = trim(shell_exec('cat /etc/timezone 2>/dev/null') ?? '') ?: 'UTC'; + } + + return $timezone; + } +} diff --git a/app/Filament/Admin/Pages/CpanelMigration.php b/app/Filament/Admin/Pages/CpanelMigration.php new file mode 100644 index 0000000..137f0b6 --- /dev/null +++ b/app/Filament/Admin/Pages/CpanelMigration.php @@ -0,0 +1,2417 @@ +label(__('Start Over')) + ->icon('heroicon-o-arrow-path') + ->color('gray') + ->requiresConfirmation() + ->modalHeading(__('Start Over')) + ->modalDescription(__('This will reset all migration data. Are you sure?')) + ->action('resetMigration'), + ]; + } + + public function mount(): void + { + // Restore credentials from session if page was reloaded (e.g., after auto-advance) + $this->restoreCredentialsFromSession(); + $this->restoreMigrationStateFromSession(); + } + + public function updatedHostname(): void + { + $this->resetConnection(); + } + + public function updatedCpanelUsername(): void + { + $this->resetConnection(); + } + + public function updatedApiToken(): void + { + $this->resetConnection(); + } + + public function updatedPort(): void + { + $this->resetConnection(); + } + + public function updatedUseSSL(): void + { + $this->resetConnection(); + } + + protected function resetConnection(): void + { + $this->cpanel = null; + $this->isConnected = false; + $this->connectionInfo = []; + } + + public function getAgent(): AgentClient + { + return $this->agent ??= new AgentClient; + } + + protected function getTargetUser(): ?User + { + if ($this->userMode === 'existing') { + return $this->targetUserId ? User::find($this->targetUserId) : null; + } + + // 'create' mode - return null here, user will be created in startRestore() + return null; + } + + /** + * Create a new user from the cPanel backup. + */ + protected function createUserFromBackup(): ?User + { + if (empty($this->cpanelUsername)) { + $this->addLog(__('No cPanel username available'), 'error'); + + return null; + } + + // Check if panel user already exists + $existingUser = User::where('username', $this->cpanelUsername)->first(); + if ($existingUser) { + $this->addLog(__('User :username already exists, using existing user', ['username' => $this->cpanelUsername]), 'info'); + $this->targetUserId = $existingUser->id; + + return $existingUser; + } + + // Get the main domain from discovered data for email + $mainDomain = null; + foreach ($this->discoveredData['domains'] ?? [] as $domain) { + if (($domain['type'] ?? '') === 'main') { + $mainDomain = $domain['name'] ?? null; + break; + } + } + // Fallback to first domain or generate placeholder + if (! $mainDomain && ! empty($this->discoveredData['domains'])) { + $mainDomain = $this->discoveredData['domains'][0]['name'] ?? null; + } + $emailDomain = $mainDomain ?? 'example.com'; + $userEmail = $this->cpanelUsername.'@'.$emailDomain; + + // Check if email already exists (must be unique) + if (User::where('email', $userEmail)->exists()) { + $userEmail = $this->cpanelUsername.'.'.time().'@'.$emailDomain; + } + + // Generate a secure random password + $password = bin2hex(random_bytes(12)); + + try { + // Check if Linux user already exists + exec('id '.escapeshellarg($this->cpanelUsername).' 2>/dev/null', $output, $exitCode); + $linuxUserExists = ($exitCode === 0); + + if (! $linuxUserExists) { + // Create Linux user via agent + $this->addLog(__('Creating system user: :username', ['username' => $this->cpanelUsername]), 'pending'); + + $result = $this->getAgent()->send('user.create', [ + 'username' => $this->cpanelUsername, + 'password' => $password, + ]); + + if (! ($result['success'] ?? false)) { + throw new Exception($result['error'] ?? __('Failed to create system user')); + } + + $this->addLog(__('System user created: :username', ['username' => $this->cpanelUsername]), 'success'); + } else { + $this->addLog(__('System user already exists: :username', ['username' => $this->cpanelUsername]), 'info'); + } + + // Create panel user record + $user = User::create([ + 'name' => ucfirst($this->cpanelUsername), + 'username' => $this->cpanelUsername, + 'email' => $userEmail, + 'password' => Hash::make($password), + 'home_directory' => '/home/'.$this->cpanelUsername, + 'disk_quota_mb' => null, // Unlimited + 'is_active' => true, + 'is_admin' => false, + ]); + + $this->targetUserId = $user->id; + $this->addLog(__('Created panel user: :username (email: :email)', ['username' => $user->username, 'email' => $userEmail]), 'success'); + + return $user; + } catch (Exception $e) { + Log::error('Failed to create user from backup', [ + 'username' => $this->cpanelUsername, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + $this->addLog(__('Failed to create user: :error', ['error' => $e->getMessage()]), 'error'); + + return null; + } + } + + protected function getCpanel(): ?CpanelApiService + { + // Try to restore credentials from session if not set (page was reloaded) + if (! $this->hostname || ! $this->cpanelUsername || ! $this->apiToken) { + $this->restoreCredentialsFromSession(); + } + + if (! $this->hostname || ! $this->cpanelUsername || ! $this->apiToken) { + return null; + } + + return $this->cpanel ??= new CpanelApiService( + trim($this->hostname), + trim($this->cpanelUsername), + trim($this->apiToken), + $this->port, + $this->useSSL + ); + } + + /** + * Store cPanel credentials in session to survive page reloads. + */ + protected function storeCredentialsInSession(): void + { + session()->put('cpanel_migration.hostname', $this->hostname); + session()->put('cpanel_migration.username', $this->cpanelUsername); + session()->put('cpanel_migration.token', $this->apiToken); + session()->put('cpanel_migration.port', $this->port); + session()->put('cpanel_migration.useSSL', $this->useSSL); + session()->put('cpanel_migration.targetUserId', $this->targetUserId); + session()->put('cpanel_migration.isConnected', $this->isConnected); + session()->put('cpanel_migration.connectionInfo', $this->connectionInfo); + session()->put('cpanel_migration.apiSummary', $this->apiSummary); + session()->put('cpanel_migration.sourceType', $this->sourceType); + session()->put('cpanel_migration.localBackupPath', $this->localBackupPath); + session()->put('cpanel_migration.backupPath', $this->backupPath); + session()->put('cpanel_migration.backupFilename', $this->backupFilename); + session()->put('cpanel_migration.backupSize', $this->backupSize); + session()->put('cpanel_migration.backupInitiated', $this->backupInitiated); + session()->put('cpanel_migration.backupMethod', $this->backupMethod); + session()->put('cpanel_migration.backupInitiatedAt', $this->backupInitiatedAt); + session()->put('cpanel_migration.discoveredData', $this->discoveredData); + session()->put('cpanel_migration.step1Complete', $this->step1Complete); + + // Ensure session is saved before any redirect + session()->save(); + } + + /** + * Restore cPanel credentials from session after page reload. + */ + protected function restoreCredentialsFromSession(): void + { + if (session()->has('cpanel_migration.hostname')) { + $this->hostname = session('cpanel_migration.hostname'); + $this->cpanelUsername = session('cpanel_migration.username'); + $this->apiToken = session('cpanel_migration.token'); + $this->port = session('cpanel_migration.port', 2083); + $this->useSSL = session('cpanel_migration.useSSL', true); + $this->targetUserId = session('cpanel_migration.targetUserId'); + $this->isConnected = session('cpanel_migration.isConnected', false); + $this->connectionInfo = session('cpanel_migration.connectionInfo', []); + $this->apiSummary = session('cpanel_migration.apiSummary', []); + $this->sourceType = session('cpanel_migration.sourceType', 'remote'); + $this->localBackupPath = session('cpanel_migration.localBackupPath'); + $this->backupPath = session('cpanel_migration.backupPath'); + $this->backupFilename = session('cpanel_migration.backupFilename'); + $this->backupSize = session('cpanel_migration.backupSize', 0); + $this->backupInitiated = session('cpanel_migration.backupInitiated', false); + $this->backupMethod = session('cpanel_migration.backupMethod', 'download'); + $this->backupInitiatedAt = session('cpanel_migration.backupInitiatedAt'); + $this->discoveredData = session('cpanel_migration.discoveredData', []); + $this->step1Complete = session('cpanel_migration.step1Complete', false); + } + } + + /** + * Clear stored session credentials. + */ + protected function clearSessionCredentials(): void + { + session()->forget([ + 'cpanel_migration.hostname', + 'cpanel_migration.username', + 'cpanel_migration.token', + 'cpanel_migration.port', + 'cpanel_migration.useSSL', + 'cpanel_migration.targetUserId', + 'cpanel_migration.isConnected', + 'cpanel_migration.connectionInfo', + 'cpanel_migration.apiSummary', + 'cpanel_migration.sourceType', + 'cpanel_migration.localBackupPath', + 'cpanel_migration.backupPath', + 'cpanel_migration.backupFilename', + 'cpanel_migration.backupSize', + 'cpanel_migration.backupInitiated', + 'cpanel_migration.backupMethod', + 'cpanel_migration.backupInitiatedAt', + 'cpanel_migration.discoveredData', + 'cpanel_migration.step1Complete', + ]); + } + + protected function getBackupDestPath(): string + { + return '/var/backups/jabali/cpanel-migrations'; + } + + protected function getJabaliPublicIp(): string + { + $ip = trim(shell_exec('curl -s ifconfig.me 2>/dev/null') ?? ''); + + if (empty($ip)) { + $ip = gethostbyname(gethostname()); + } + + return $ip; + } + + protected function getForms(): array + { + return ['migrationForm']; + } + + public function migrationForm(Schema $schema): Schema + { + return $schema->schema([ + Wizard::make([ + $this->getConnectStep(), + $this->getBackupStep(), + $this->getReviewStep(), + $this->getRestoreStep(), + ]) + ->nextAction( + fn (Action $action) => $action + ->disabled(fn () => $this->isNextStepDisabled()) + ->hidden(fn () => $this->isNextButtonHidden()) + ) + ->persistStepInQueryString('cpanel-step'), + ]); + } + + /** + * Check if Next button should be hidden (during backup transfer). + */ + protected function isNextButtonHidden(): bool + { + // Hide Next during backup transfer on step 2 (backup started but not complete) + // We know we're on step 2 if step1Complete is true + return $this->step1Complete && $this->backupInitiated && ! $this->backupPath; + } + + /** + * Get normalized current step name from query string. + */ + protected function getCurrentStepName(): string + { + // Use Livewire URL-synced property (works in both initial load and Livewire requests) + $step = $this->wizardStep ?? 'connect'; + + // Handle full wizard step IDs like "migrationForm.connect::wizard-step" + if (str_contains($step, '.')) { + // Extract step name after the dot and before :: + if (preg_match('/\.(\w+)(?:::|$)/', $step, $matches)) { + return $matches[1]; + } + } + + return $step ?: 'connect'; + } + + protected function getConnectStep(): Step + { + return Step::make(__('Connect')) + ->id('connect') + ->icon('heroicon-o-link') + ->description($this->sourceType === 'local' ? __('Select local backup file') : __('Enter cPanel credentials')) + ->schema([ + Section::make(__('Target User')) + ->description(__('Choose how to handle the user account')) + ->icon('heroicon-o-user') + ->iconColor('primary') + ->schema([ + Radio::make('userMode') + ->label(__('User Account')) + ->options([ + 'create' => __('Create new user from backup'), + 'existing' => __('Restore to existing user'), + ]) + ->descriptions([ + 'create' => __('Creates a new user with the cPanel username and password from backup (unlimited disk space)'), + 'existing' => __('Restore to an existing user account'), + ]) + ->default('create') + ->live() + ->required(), + Select::make('targetUserId') + ->label(__('Select User')) + ->options(fn () => User::where('is_active', true) + ->orderBy('username') + ->pluck('username', 'id') + ->mapWithKeys(fn ($username, $id) => [ + $id => User::find($id)->name.' ('.$username.')', + ]) + ) + ->searchable() + ->required(fn (Get $get) => $get('userMode') === 'existing') + ->visible(fn (Get $get) => $get('userMode') === 'existing') + ->helperText(__('All migrated domains, emails, and databases will be assigned to this user')), + ]), + + Section::make(__('Backup Source')) + ->description(__('Choose where to get the cPanel backup from')) + ->icon('heroicon-o-arrow-down-tray') + ->iconColor('primary') + ->schema([ + Select::make('sourceType') + ->label(__('Source Type')) + ->options([ + 'remote' => __('Remote cPanel Server (Create & Transfer Backup)'), + 'local' => __('Local File (Already on this server)'), + ]) + ->default('remote') + ->live() + ->afterStateUpdated(fn () => $this->resetConnection()) + ->helperText(__('Select "Local File" if you already have a cPanel backup on this server')), + ]), + + // Remote cPanel credentials (shown when sourceType is 'remote') + Section::make(__('cPanel Credentials')) + ->description(__('Enter the cPanel server connection details')) + ->icon('heroicon-o-server') + ->visible(fn () => $this->sourceType === 'remote') + ->schema([ + Grid::make(['default' => 1, 'sm' => 2])->schema([ + TextInput::make('hostname') + ->label(__('cPanel Hostname')) + ->placeholder('cpanel.example.com') + ->required(fn () => $this->sourceType === 'remote') + ->helperText(__('Your cPanel server hostname or IP address')), + TextInput::make('port') + ->label(__('Port')) + ->numeric() + ->default(2083) + ->required(fn () => $this->sourceType === 'remote') + ->helperText(__('Usually 2083 for SSL or 2082 without')), + ]), + Grid::make(['default' => 1, 'sm' => 2])->schema([ + TextInput::make('cpanelUsername') + ->label(__('cPanel Username')) + ->required(fn () => $this->sourceType === 'remote') + ->helperText(__('Your cPanel account username')), + TextInput::make('apiToken') + ->label(__('API Token')) + ->password() + ->required(fn () => $this->sourceType === 'remote') + ->revealable() + ->helperText(__('Generate from cPanel → Security → Manage API Tokens')), + ]), + Checkbox::make('useSSL') + ->label(__('Use SSL (HTTPS)')) + ->default(true) + ->helperText(__('Recommended. Disable only if your cPanel does not support SSL.')), + ]), + + // Local file selection (shown when sourceType is 'local') + Section::make(__('Local Backup File')) + ->description(__('Enter the path to the cPanel backup file on this server')) + ->icon('heroicon-o-folder') + ->visible(fn () => $this->sourceType === 'local') + ->schema([ + TextInput::make('localBackupPath') + ->label(__('Backup File Path')) + ->placeholder('/home/user/backups/backup-date_username.tar.gz') + ->required(fn () => $this->sourceType === 'local') + ->helperText(__('Full path to the cPanel backup file (e.g., /var/backups/backup.tar.gz)')), + Text::make(__('Supported formats: .tar.gz, .tgz'))->color('gray'), + Text::make(__('Tip: Upload backups to /var/backups/jabali/cpanel-migrations/'))->color('gray'), + ]), + + // Test Connection button (remote mode only) + FormActions::make([ + Action::make('testConnection') + ->label(fn () => $this->isConnected ? __('Connected') : __('Test Connection')) + ->icon(fn () => $this->isConnected ? 'heroicon-o-check-circle' : 'heroicon-o-signal') + ->color(fn () => $this->isConnected ? 'success' : 'primary') + ->disabled(fn () => $this->userMode === 'existing' && ! $this->targetUserId) + ->tooltip(fn () => $this->userMode === 'existing' && ! $this->targetUserId ? __('Please select a user first') : null) + ->action('testConnection'), + ]) + ->alignEnd() + ->visible(fn () => $this->sourceType === 'remote'), + + // Validate local file button (local mode only) + FormActions::make([ + Action::make('validateLocalFile') + ->label(fn () => $this->isConnected ? __('File Validated') : __('Validate Backup File')) + ->icon(fn () => $this->isConnected ? 'heroicon-o-check-circle' : 'heroicon-o-document-magnifying-glass') + ->color(fn () => $this->isConnected ? 'success' : 'primary') + ->disabled(fn () => $this->userMode === 'existing' && ! $this->targetUserId) + ->tooltip(fn () => $this->userMode === 'existing' && ! $this->targetUserId ? __('Please select a user first') : null) + ->action('validateLocalFile'), + ]) + ->alignEnd() + ->visible(fn () => $this->sourceType === 'local'), + + Section::make(__('Connection Successful')) + ->icon('heroicon-o-check-circle') + ->iconColor('success') + ->visible(fn () => $this->isConnected && $this->sourceType === 'remote') + ->schema([ + Grid::make(['default' => 2, 'sm' => 5])->schema([ + Text::make(fn () => __('Host: :host', ['host' => $this->hostname ?? '-'])), + Text::make(fn () => __('Domains: :count', ['count' => $this->connectionInfo['domains'] ?? 0])), + Text::make(fn () => __('Databases: :count', ['count' => $this->connectionInfo['databases'] ?? 0])), + Text::make(fn () => __('Emails: :count', ['count' => $this->connectionInfo['emails'] ?? 0])), + Text::make(fn () => __('SSL: :count', ['count' => $this->connectionInfo['ssl'] ?? 0])), + ]), + Text::make(__('You can proceed to the next step.'))->color('success'), + ]), + + Section::make(__('File Validated')) + ->icon('heroicon-o-check-circle') + ->iconColor('success') + ->visible(fn () => $this->isConnected && $this->sourceType === 'local') + ->schema([ + Text::make(fn () => __('File: :path', ['path' => basename($this->localBackupPath ?? '')])), + Text::make(fn () => __('Size: :size', ['size' => $this->formatBytes(filesize($this->localBackupPath ?? '') ?: 0)])), + Text::make(__('You can proceed to the next step.'))->color('success'), + ]), + ]) + ->afterValidation(function () { + // Only require target user if 'existing' mode is selected + if ($this->userMode === 'existing' && ! $this->targetUserId) { + Notification::make() + ->title(__('User required')) + ->body(__('Please select a target user')) + ->danger() + ->send(); + throw new Exception(__('Please select a target user first')); + } + + if (! $this->isConnected) { + $message = $this->sourceType === 'local' + ? __('Please validate the backup file before proceeding') + : __('Please test the connection first'); + Notification::make() + ->title($this->sourceType === 'local' ? __('Validation required') : __('Connection required')) + ->body($message) + ->danger() + ->send(); + throw new Exception($message); + } + + // Mark step 1 as complete - user is moving to step 2 + $this->step1Complete = true; + }); + } + + protected function getBackupStep(): Step + { + // For local files - analyze backup + if ($this->sourceType === 'local') { + return Step::make(__('Backup')) + ->id('backup') + ->icon('heroicon-o-folder-open') + ->description(__('Analyzing local backup')) + ->schema([ + Section::make(__('Local Backup')) + ->description(__('Click the button below to analyze the backup contents')) + ->icon('heroicon-o-folder-open') + ->iconColor('primary') + ->headerActions([ + Action::make('analyzeBackup') + ->label(__('Analyze Backup')) + ->icon('heroicon-o-magnifying-glass') + ->color('primary') + ->disabled(fn () => $this->isAnalyzing || ! empty($this->discoveredData)) + ->action('analyzeLocalBackup'), + ]) + ->schema([ + Text::make(__('Target User: :name (:username)', [ + 'name' => $this->getTargetUser()?->name ?? '-', + 'username' => $this->getTargetUser()?->username ?? '-', + ])), + Text::make(__('File: :name', ['name' => $this->backupFilename ?? basename($this->localBackupPath ?? '')])), + Text::make(__('Size: :size', ['size' => $this->formatBytes($this->backupSize)])), + ]), + + Section::make(__('Analysis Progress')) + ->icon($this->getAnalysisStatusIcon()) + ->iconColor($this->getAnalysisStatusColor()) + ->schema($this->getLocalBackupStatusSchema()) + ->extraAttributes($this->isAnalyzing ? ['wire:poll.1s' => 'pollAnalysisStatus'] : []), + ]) + ->afterValidation(function () { + if (empty($this->discoveredData)) { + Notification::make() + ->title(__('Analysis required')) + ->body(__('Please analyze the backup file before proceeding')) + ->danger() + ->send(); + $this->halt(); + } + }); + } + + // Remote cPanel - create and transfer backup + return Step::make(__('Backup')) + ->id('backup') + ->icon('heroicon-o-cloud-arrow-down') + ->description(__('Create and transfer backup')) + ->schema([ + Section::make(__('Backup Transfer')) + ->description(__('Click the button below to create and transfer the backup from cPanel')) + ->icon('heroicon-o-server') + ->headerActions([ + Action::make('startBackup') + ->label(__('Start Backup Transfer')) + ->icon('heroicon-o-cloud-arrow-down') + ->color('primary') + ->disabled(fn () => $this->backupInitiated || (bool) $this->backupPath) + ->action('startBackupTransfer'), + ]) + ->schema([ + Text::make(__('Target User: :name (:username)', [ + 'name' => $this->getTargetUser()?->name ?? '-', + 'username' => $this->getTargetUser()?->username ?? '-', + ])), + Text::make(__('Note: Large accounts may take several minutes.'))->color('warning'), + ]), + + Section::make(__('Transfer Status')) + ->icon($this->backupPath ? 'heroicon-o-check-circle' : 'heroicon-o-clock') + ->iconColor($this->backupPath ? 'success' : 'gray') + ->schema($this->getStatusLogSchema()) + ->extraAttributes($this->backupInitiated && ! $this->backupPath ? ['wire:poll.5s' => 'checkBackupStatus'] : []), + ]) + ->afterValidation(function () { + if (! $this->backupPath) { + Notification::make() + ->title(__('Backup required')) + ->body(__('Please complete the backup transfer first')) + ->danger() + ->send(); + $this->halt(); + } + }); + } + + protected function getStatusLogSchema(): array + { + if (empty($this->statusLog)) { + return [ + Text::make(__('Click "Start Backup Transfer" to begin.'))->color('gray'), + ]; + } + + $items = []; + foreach ($this->statusLog as $entry) { + $color = match ($entry['status']) { + 'success' => 'success', + 'error' => 'danger', + 'pending' => 'warning', + default => 'gray', + }; + + $prefix = match ($entry['status']) { + 'success' => '✓ ', + 'error' => '✗ ', + 'pending' => '○ ', + default => '• ', + }; + + $items[] = Text::make($prefix.$entry['message']) + ->color($color); + } + + if ($this->backupPath) { + $items[] = Section::make(__('Backup Complete')) + ->icon('heroicon-o-check-circle') + ->iconColor('success') + ->schema([ + Text::make(__('File: :name', ['name' => $this->backupFilename ?? basename($this->backupPath)])), + Text::make(__('Size: :size', ['size' => $this->formatBytes($this->backupSize)])), + ]) + ->compact(); + } + + return $items; + } + + protected function getLocalBackupStatusSchema(): array + { + $items = []; + + // Show analysis log entries + if (! empty($this->analysisLog)) { + foreach ($this->analysisLog as $entry) { + $prefix = match ($entry['status']) { + 'success' => '✓ ', + 'error' => '✗ ', + 'pending' => '○ ', + default => '• ', + }; + $color = match ($entry['status']) { + 'success' => 'success', + 'error' => 'danger', + 'pending' => 'warning', + default => 'gray', + }; + + $items[] = Text::make($prefix.$entry['message'])->color($color); + } + } + + // Show initial message if no log and not analyzing + if (empty($this->analysisLog) && ! $this->isAnalyzing && empty($this->discoveredData)) { + return [ + Text::make(__('Click "Analyze Backup" to discover the backup contents.'))->color('gray'), + ]; + } + + // Show results if analysis complete + if (! empty($this->discoveredData)) { + $domainCount = count($this->discoveredData['domains'] ?? []); + $dbCount = count($this->discoveredData['databases'] ?? []); + $mailCount = count($this->discoveredData['mailboxes'] ?? []); + $forwarderCount = count($this->discoveredData['forwarders'] ?? []); + $sslCount = count($this->discoveredData['ssl_certificates'] ?? []); + + $items[] = Grid::make(['default' => 2, 'sm' => 5])->schema([ + Section::make((string) $domainCount) + ->description(__('Domains')) + ->icon('heroicon-o-globe-alt') + ->iconColor('primary') + ->compact(), + Section::make((string) $dbCount) + ->description(__('Databases')) + ->icon('heroicon-o-circle-stack') + ->iconColor('warning') + ->compact(), + Section::make((string) $mailCount) + ->description(__('Mailboxes')) + ->icon('heroicon-o-envelope') + ->iconColor('success') + ->compact(), + Section::make((string) $forwarderCount) + ->description(__('Forwarders')) + ->icon('heroicon-o-arrow-uturn-right') + ->iconColor('gray') + ->compact(), + Section::make((string) $sslCount) + ->description(__('SSL Certs')) + ->icon('heroicon-o-lock-closed') + ->iconColor('info') + ->compact(), + ]); + $items[] = Text::make(__('You can proceed to the next step.'))->color('success'); + } + + return $items; + } + + protected function getAnalyzeButtonLabel(): string + { + if ($this->isAnalyzing) { + return __('Analyzing...'); + } + if (! empty($this->discoveredData)) { + return __('Analysis Complete'); + } + + return __('Analyze Backup'); + } + + protected function getAnalyzeButtonIcon(): string + { + if ($this->isAnalyzing) { + return 'heroicon-o-arrow-path'; + } + if (! empty($this->discoveredData)) { + return 'heroicon-o-check-circle'; + } + + return 'heroicon-o-magnifying-glass'; + } + + protected function getAnalyzeButtonColor(): string + { + if ($this->isAnalyzing) { + return 'warning'; + } + if (! empty($this->discoveredData)) { + return 'success'; + } + + return 'primary'; + } + + protected function getAnalysisStatusIcon(): string + { + if (! empty($this->discoveredData)) { + return 'heroicon-o-check-circle'; + } + if ($this->isAnalyzing) { + return 'heroicon-o-arrow-path'; + } + + return 'heroicon-o-clock'; + } + + protected function getAnalysisStatusColor(): string + { + if (! empty($this->discoveredData)) { + return 'success'; + } + if ($this->isAnalyzing) { + return 'warning'; + } + + return 'gray'; + } + + protected function addAnalysisLog(string $message, string $status = 'info'): void + { + // Update the last pending entry if this is a completion + $lastIndex = count($this->analysisLog) - 1; + if ($lastIndex >= 0 && $this->analysisLog[$lastIndex]['status'] === 'pending' && $status !== 'pending') { + $this->analysisLog[$lastIndex] = [ + 'message' => $message, + 'status' => $status, + 'time' => now()->format('H:i:s'), + ]; + + return; + } + + $this->analysisLog[] = [ + 'message' => $message, + 'status' => $status, + 'time' => now()->format('H:i:s'), + ]; + } + + public function pollAnalysisStatus(): void + { + // This method is called by wire:poll to refresh the UI during analysis + // The actual work is done in analyzeLocalBackup + } + + protected function getReviewStep(): Step + { + return Step::make(__('Review')) + ->id('review') + ->icon('heroicon-o-clipboard-document-check') + ->description(__('Review discovered data')) + ->schema($this->getReviewStepSchema()); + } + + protected function getReviewStepSchema(): array + { + if (empty($this->discoveredData)) { + return [ + Section::make(__('Waiting for Backup')) + ->icon('heroicon-o-clock') + ->iconColor('warning') + ->schema([ + Text::make(__('Please complete the backup transfer in the previous step.')), + ]), + ]; + } + + $user = $this->getTargetUser(); + $domainCount = count($this->discoveredData['domains'] ?? []); + $dbCount = count($this->discoveredData['databases'] ?? []); + $mailCount = count($this->discoveredData['mailboxes'] ?? []); + $forwarderCount = count($this->discoveredData['forwarders'] ?? []); + $sslCount = count($this->discoveredData['ssl_certificates'] ?? []); + + return [ + Section::make(__('Migration Summary')) + ->icon('heroicon-o-clipboard-document-list') + ->iconColor('primary') + ->schema([ + Text::make(__('Target User: :name (:username)', [ + 'name' => $user?->name ?? '-', + 'username' => $user?->username ?? '-', + ])), + Grid::make(['default' => 2, 'sm' => 5])->schema([ + Section::make((string) $domainCount) + ->description(__('Domains')) + ->icon('heroicon-o-globe-alt') + ->iconColor('primary') + ->compact(), + Section::make((string) $dbCount) + ->description(__('Databases')) + ->icon('heroicon-o-circle-stack') + ->iconColor('warning') + ->compact(), + Section::make((string) $mailCount) + ->description(__('Mailboxes')) + ->icon('heroicon-o-envelope') + ->iconColor('success') + ->compact(), + Section::make((string) $forwarderCount) + ->description(__('Forwarders')) + ->icon('heroicon-o-arrow-uturn-right') + ->iconColor('gray') + ->compact(), + Section::make((string) $sslCount) + ->description(__('SSL Certs')) + ->icon('heroicon-o-lock-closed') + ->iconColor('info') + ->compact(), + ]), + ]), + + Section::make(__('What to Restore')) + ->description(__('Select which parts of the backup to restore')) + ->icon('heroicon-o-check-circle') + ->schema([ + Grid::make(['default' => 1, 'sm' => 2])->schema([ + Checkbox::make('restoreFiles') + ->label(__('Website Files')) + ->helperText(__('Restore all website files to the user\'s domains folder')) + ->default(true), + Checkbox::make('restoreDatabases') + ->label(__('MySQL Databases')) + ->helperText(__('Restore databases with all data')) + ->default(true), + Checkbox::make('restoreEmails') + ->label(__('Email Mailboxes')) + ->helperText(__('Restore email accounts and messages')) + ->default(true), + Checkbox::make('restoreSsl') + ->label(__('SSL Certificates')) + ->helperText(__('Restore SSL certificates for domains')) + ->default(true), + ]), + ]), + + Section::make(__('Discovered Data')) + ->icon('heroicon-o-magnifying-glass') + ->schema([ + Tabs::make('DataTabs') + ->tabs([ + Tabs\Tab::make(__('Domains')) + ->icon('heroicon-o-globe-alt') + ->badge((string) $domainCount) + ->schema($this->getDomainsTabContent()), + Tabs\Tab::make(__('Databases')) + ->icon('heroicon-o-circle-stack') + ->badge((string) $dbCount) + ->schema($this->getDatabasesTabContent()), + Tabs\Tab::make(__('Mailboxes')) + ->icon('heroicon-o-envelope') + ->badge((string) $mailCount) + ->schema($this->getMailboxesTabContent()), + Tabs\Tab::make(__('Forwarders')) + ->icon('heroicon-o-arrow-uturn-right') + ->badge((string) $forwarderCount) + ->schema($this->getForwardersTabContent()), + Tabs\Tab::make(__('SSL')) + ->icon('heroicon-o-lock-closed') + ->badge((string) $sslCount) + ->schema($this->getSslTabContent()), + ]), + ]), + ]; + } + + protected function getDomainsTabContent(): array + { + $domains = $this->discoveredData['domains'] ?? []; + if (empty($domains)) { + return [Text::make(__('No domains found in backup.'))]; + } + + $items = []; + foreach ($domains as $domain) { + $typePrefix = match ($domain['type'] ?? 'addon') { + 'main' => '★ ', + 'addon' => '● ', + 'sub' => '◦ ', + default => '• ', + }; + $typeColor = match ($domain['type'] ?? 'addon') { + 'main' => 'success', + 'addon' => 'primary', + 'sub' => 'warning', + default => 'gray', + }; + + $items[] = Text::make($typePrefix.$domain['name'].' ('.$domain['type'].')') + ->color($typeColor); + } + + return $items; + } + + protected function getDatabasesTabContent(): array + { + $databases = $this->discoveredData['databases'] ?? []; + if (empty($databases)) { + return [Text::make(__('No databases found in backup.'))]; + } + + $user = $this->getTargetUser(); + $userPrefix = $user?->username ?? 'user'; + + $items = []; + foreach ($databases as $db) { + $oldName = $db['name']; + $newName = $userPrefix.'_'.preg_replace('/^[^_]+_/', '', $oldName); + $newName = substr($newName, 0, 64); + + $items[] = Text::make("→ {$oldName} → {$newName}") + ->color('primary'); + } + + return $items; + } + + protected function getMailboxesTabContent(): array + { + $mailboxes = $this->discoveredData['mailboxes'] ?? []; + if (empty($mailboxes)) { + return [Text::make(__('No mailboxes found in backup.'))]; + } + + $items = []; + foreach ($mailboxes as $mailbox) { + $items[] = Text::make('✉ '.$mailbox['email']) + ->color('success'); + } + + return $items; + } + + protected function getSslTabContent(): array + { + $sslCerts = $this->discoveredData['ssl_certificates'] ?? []; + if (empty($sslCerts)) { + return [Text::make(__('No SSL certificates found in backup.'))]; + } + + $items = []; + foreach ($sslCerts as $cert) { + $hasComplete = ($cert['has_key'] ?? false) && ($cert['has_cert'] ?? false); + $prefix = $hasComplete ? '🔒 ' : '⚠ '; + $items[] = Text::make($prefix.$cert['domain']) + ->color($hasComplete ? 'success' : 'warning'); + } + + return $items; + } + + protected function getForwardersTabContent(): array + { + $forwarders = $this->discoveredData['forwarders'] ?? []; + if (empty($forwarders)) { + return [Text::make(__('No forwarders found in backup.'))]; + } + + $items = []; + foreach ($forwarders as $forwarder) { + $email = $forwarder['email'] ?? ''; + $destinations = $forwarder['destinations'] ?? ''; + $destPreview = is_array($destinations) ? implode(', ', $destinations) : $destinations; + $destPreview = strlen($destPreview) > 40 ? substr($destPreview, 0, 37).'...' : $destPreview; + $items[] = Text::make('↪ '.$email.' → '.$destPreview) + ->color('gray'); + } + + return $items; + } + + protected function getRestoreStep(): Step + { + return Step::make(__('Restore')) + ->id('restore') + ->icon('heroicon-o-play') + ->description(__('Migration progress')) + ->schema([ + FormActions::make([ + Action::make('startRestore') + ->label(__('Start Restore')) + ->icon('heroicon-o-play') + ->color('success') + ->visible(fn () => ! $this->isProcessing && empty($this->migrationLog)) + ->requiresConfirmation() + ->modalHeading(__('Start Restore')) + ->modalDescription(__('This will restore the selected items. Existing data may be overwritten.')) + ->action('startRestore'), + + Action::make('newMigration') + ->label(__('New Migration')) + ->icon('heroicon-o-plus') + ->color('primary') + ->visible(fn () => ! $this->isProcessing && ! empty($this->migrationLog)) + ->action('resetMigration'), + ])->alignEnd(), + + Section::make(__('Migration Progress')) + ->icon($this->isProcessing ? 'heroicon-o-arrow-path' : ($this->migrationLog ? 'heroicon-o-check-circle' : 'heroicon-o-clock')) + ->iconColor($this->isProcessing ? 'warning' : ($this->migrationLog ? 'success' : 'gray')) + ->schema($this->getMigrationLogSchema()) + ->extraAttributes($this->isProcessing ? ['wire:poll.1s' => 'pollMigrationLog'] : []), + ]); + } + + protected function getMigrationLogSchema(): array + { + if (empty($this->migrationLog)) { + return [ + Text::make(__('Click "Start Restore" to begin the migration.'))->color('gray'), + ]; + } + + $items = []; + foreach ($this->migrationLog as $entry) { + $status = $entry['status'] ?? 'info'; + $message = $entry['message'] ?? ''; + + $color = match ($status) { + 'success' => 'success', + 'error' => 'danger', + 'warning' => 'warning', + 'pending' => 'warning', + default => 'gray', + }; + + $prefix = match ($status) { + 'success' => '✓ ', + 'error' => '✗ ', + 'warning' => '• ', + 'pending' => '○ ', + default => '• ', + }; + + $items[] = Text::make($prefix.$message)->color($color); + } + + return $items; + } + + public function table(Table $table): Table + { + // Empty table - data is displayed via schema components + return $table + ->query(User::query()->whereRaw('1 = 0')) + ->columns([]) + ->paginated(false); + } + + public function testConnection(): void + { + if ($this->userMode === 'existing' && ! $this->targetUserId) { + Notification::make() + ->title(__('User required')) + ->body(__('Please select a target user first')) + ->danger() + ->send(); + + return; + } + + if (empty($this->hostname) || empty($this->cpanelUsername) || empty($this->apiToken)) { + Notification::make() + ->title(__('Missing credentials')) + ->body(__('Please fill in all required fields')) + ->danger() + ->send(); + + return; + } + + try { + $cpanel = $this->getCpanel(); + $result = $cpanel->testConnection(); + + if ($result['success']) { + $this->isConnected = true; + + $summary = $cpanel->getMigrationSummary(); + + // Store full API summary for use during restore (skips expensive backup analysis) + $this->apiSummary = $summary; + + // Immediately populate discoveredData from API - no need to wait for backup analysis + $this->discoveredData = $this->convertApiDataToAgentFormat($summary); + + $this->connectionInfo = [ + 'domains' => count($this->discoveredData['domains'] ?? []), + 'emails' => count($this->discoveredData['mailboxes'] ?? []), + 'databases' => count($this->discoveredData['databases'] ?? []), + 'ssl' => count($this->discoveredData['ssl_certificates'] ?? []), + ]; + + Notification::make() + ->title(__('Connection successful')) + ->body(__('Found :domains domains, :emails email accounts, :dbs databases, :ssl SSL certificates', [ + 'domains' => $this->connectionInfo['domains'], + 'emails' => $this->connectionInfo['emails'], + 'dbs' => $this->connectionInfo['databases'], + 'ssl' => $this->connectionInfo['ssl'], + ])) + ->success() + ->send(); + } else { + throw new Exception($result['message'] ?? __('Connection failed')); + } + } catch (Exception $e) { + $this->isConnected = false; + Log::error('cPanel connection failed', ['error' => $e->getMessage()]); + + Notification::make() + ->title(__('Connection failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + /** + * Check if the Next button should be disabled based on current wizard step. + * Uses step1Complete flag to reliably track wizard progress. + */ + protected function isNextStepDisabled(): bool + { + // If step 1 is not complete, we're on step 1 + if (! $this->step1Complete) { + // Step 1: Must have connection (and target user if 'existing' mode) + return ! $this->isConnected || ($this->userMode === 'existing' && ! $this->targetUserId); + } + + // Step 1 is complete, we're on step 2 or beyond + // Check step 2 prerequisites (backup ready) + if ($this->sourceType === 'local') { + // Local: need analysis complete + if (empty($this->discoveredData)) { + return true; + } + } else { + // Remote: need backup downloaded + if (! $this->backupPath) { + return true; + } + } + + // All prerequisites met (step 3 and beyond) + return false; + } + + protected function countDomains(array $domains): int + { + $count = 0; + if (! empty($domains['main'])) { + $count++; + } + $count += count($domains['addon'] ?? []); + $count += count($domains['sub'] ?? []); + + return $count; + } + + /** + * Convert API migration summary to backup analysis format for the agent. + * API format: ['domains' => ['main' => 'x', 'addon' => [...]], 'databases' => [...], 'email_accounts' => [...], 'ssl_certificates' => [...]] + * Agent format: ['domains' => [['name' => 'x', 'type' => 'main']], 'databases' => [...], 'mailboxes' => [...], 'ssl_certificates' => [...]] + */ + protected function convertApiDataToAgentFormat(array $apiData): array + { + $result = [ + 'domains' => [], + 'databases' => [], + 'mailboxes' => [], + '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 (API returns array of database names or objects) + 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 SSL certificates - handle various cPanel API response formats + foreach ($apiData['ssl_certificates'] ?? [] as $cert) { + if (is_array($cert)) { + // cPanel API may return 'domain', 'domains' (array), or 'friendly_name' + $domain = $cert['domain'] ?? $cert['friendly_name'] ?? null; + if (! $domain && ! empty($cert['domains'])) { + // 'domains' can be array or comma-separated string + $domains = is_array($cert['domains']) ? $cert['domains'] : explode(',', $cert['domains']); + $domain = trim($domains[0] ?? ''); + } + if ($domain) { + $result['ssl_certificates'][] = [ + 'domain' => $domain, + 'has_key' => true, // API only lists valid certs + 'has_cert' => true, + ]; + } + } elseif (is_string($cert) && ! empty($cert)) { + $result['ssl_certificates'][] = [ + 'domain' => $cert, + 'has_key' => true, + 'has_cert' => true, + ]; + } + } + + return $result; + } + + /** + * Quick scan backup file for SSL certificates without full extraction. + * Updates discoveredData['ssl_certificates'] with found certs. + */ + protected function scanBackupForSsl(string $backupPath): int + { + if (! file_exists($backupPath)) { + return 0; + } + + // Quick scan of tar.gz contents for SSL files + $output = []; + exec('tar -tzf '.escapeshellarg($backupPath).' 2>/dev/null | grep -E "ssl/(certs|keys)/.*\.(crt|key|pem)$" | head -100', $output); + + $sslSet = []; + foreach ($output as $file) { + // Match cPanel SSL cert format: domain_keyid_timestamp_hash.crt + if (preg_match('/ssl\/certs\/(.+)_([a-f0-9]+_[a-f0-9]+)_\d+_[a-f0-9]+\.(crt|pem)$/i', $file, $matches)) { + $domain = str_replace('_', '.', $matches[1]); + $keyId = $matches[2]; + if (! isset($sslSet[$keyId])) { + $sslSet[$keyId] = ['domain' => $domain, 'has_cert' => true, 'has_key' => false]; + } else { + $sslSet[$keyId]['has_cert'] = true; + $sslSet[$keyId]['domain'] = $domain; + } + } + // Match key files: keyid_hash.key + elseif (preg_match('/ssl\/keys\/([a-f0-9]+_[a-f0-9]+)_[a-f0-9]+\.key$/i', $file, $matches)) { + $keyId = $matches[1]; + if (! isset($sslSet[$keyId])) { + $sslSet[$keyId] = ['domain' => '', 'has_cert' => false, 'has_key' => true]; + } else { + $sslSet[$keyId]['has_key'] = true; + } + } + } + + // Build SSL certificates list from matched cert+key pairs + $sslCerts = []; + foreach ($sslSet as $keyId => $info) { + if ($info['has_cert'] && $info['has_key'] && ! empty($info['domain'])) { + $sslCerts[] = [ + 'domain' => $info['domain'], + 'has_key' => true, + 'has_cert' => true, + ]; + } + } + + // Update discoveredData + $this->discoveredData['ssl_certificates'] = $sslCerts; + + return count($sslCerts); + } + + public function validateLocalFile(): void + { + if (empty($this->localBackupPath)) { + Notification::make() + ->title(__('Missing path')) + ->body(__('Please enter the backup file path')) + ->danger() + ->send(); + + return; + } + + $path = trim($this->localBackupPath); + + // Validate file exists + if (! file_exists($path)) { + Notification::make() + ->title(__('File not found')) + ->body(__('The specified file does not exist: :path', ['path' => $path])) + ->danger() + ->send(); + + return; + } + + // Validate it's a file (not directory) + if (! is_file($path)) { + Notification::make() + ->title(__('Invalid path')) + ->body(__('The specified path is not a file')) + ->danger() + ->send(); + + return; + } + + // Validate extension + if (! preg_match('/\.(tar\.gz|tgz)$/i', $path)) { + Notification::make() + ->title(__('Invalid format')) + ->body(__('Backup must be a .tar.gz or .tgz file')) + ->danger() + ->send(); + + return; + } + + // Validate it's readable + if (! is_readable($path)) { + Notification::make() + ->title(__('File not readable')) + ->body(__('Cannot read the backup file. Check permissions.')) + ->danger() + ->send(); + + return; + } + + // Quick validation - try to list contents + $output = []; + exec('tar -I pigz -tf '.escapeshellarg($path).' 2>&1 | head -5', $output, $returnCode); + + if ($returnCode !== 0) { + Notification::make() + ->title(__('Invalid backup')) + ->body(__('The file does not appear to be a valid cPanel backup archive')) + ->danger() + ->send(); + + return; + } + + // File is valid + $this->localBackupPath = $path; + $this->isConnected = true; + + // Set backup path immediately for local files + $this->backupPath = $path; + $this->backupFilename = basename($path); + $this->backupSize = filesize($path) ?: 0; + + Notification::make() + ->title(__('File validated')) + ->body(__('Backup file is valid. Size: :size', ['size' => $this->formatBytes($this->backupSize)])) + ->success() + ->send(); + } + + public function startBackupTransfer(): void + { + if ($this->backupPath) { + return; + } + + if ($this->backupInitiated) { + $this->checkBackupStatus(); + + return; + } + + $this->statusLog = []; + $this->addStatusLog(__('Starting backup transfer process...'), 'pending'); + + $cpanel = $this->getCpanel(); + if (! $cpanel) { + $this->addStatusLog(__('Error: cPanel credentials not available. Please go back and reconnect.'), 'error'); + Notification::make() + ->title(__('Connection lost')) + ->body(__('Please go back to the Connect step and test the connection again.')) + ->danger() + ->send(); + + return; + } + + $destPath = $this->getBackupDestPath(); + + if (! is_dir($destPath)) { + mkdir($destPath, 0755, true); + } + + // Method 1: Try to create backup to homedir and download via HTTP (more reliable) + try { + $this->addStatusLog(__('Initiating backup on cPanel (homedir method)...'), 'pending'); + + $backupResult = $cpanel->createBackup(); + if (! ($backupResult['success'] ?? false)) { + throw new Exception($backupResult['message'] ?? __('Failed to start backup')); + } + + $this->backupInitiated = true; + $this->backupPid = $backupResult['pid'] ?? null; + $this->backupMethod = 'download'; // Track which method we're using + $this->backupInitiatedAt = time(); // Track when backup started + $this->lastSeenBackupSize = null; // Reset size tracking + $this->pollCount = 0; + + $this->addStatusLog(__('Backup initiated on cPanel'), 'success'); + $this->addStatusLog(__('Waiting for backup to complete on cPanel...'), 'pending'); + + Notification::make() + ->title(__('Backup started')) + ->body(__('cPanel is creating the backup. Once complete, it will be downloaded.')) + ->info() + ->send(); + + return; + } catch (Exception $e) { + Log::warning('Homedir backup failed, trying SCP method', ['error' => $e->getMessage()]); + $this->addStatusLog(__('Homedir backup failed, trying SCP transfer...'), 'warning'); + } + + // Method 2: Fall back to SCP transfer (requires SSH access on cPanel) + $this->startScpBackupTransfer(); + } + + protected function startScpBackupTransfer(): void + { + $cpanel = $this->getCpanel(); + if (! $cpanel) { + $this->addStatusLog(__('Error: cPanel credentials not available. Please go back and reconnect.'), 'error'); + + return; + } + + $destPath = $this->getBackupDestPath(); + + if (! is_dir($destPath)) { + mkdir($destPath, 0755, true); + } + + try { + $this->addStatusLog(__('Checking Jabali SSH key...'), 'pending'); + + $sshKeyResult = $this->getAgent()->send('jabali_ssh.ensure_exists', []); + if (! ($sshKeyResult['success'] ?? false)) { + throw new Exception($sshKeyResult['error'] ?? __('Failed to generate Jabali SSH key')); + } + + $publicKey = $sshKeyResult['public_key'] ?? null; + $keyName = $sshKeyResult['key_name'] ?? 'jabali-system-key'; + + if (! $publicKey) { + throw new Exception(__('Failed to read Jabali public key')); + } + + $this->addStatusLog(__('Jabali SSH key ready'), 'success'); + + $this->addStatusLog(__('Configuring SSH access on Jabali...'), 'pending'); + + $privateKeyResult = $this->getAgent()->send('jabali_ssh.get_private_key', []); + if (! ($privateKeyResult['success'] ?? false) || ! ($privateKeyResult['exists'] ?? false)) { + throw new Exception(__('Failed to read Jabali private key')); + } + + $privateKey = $privateKeyResult['private_key'] ?? null; + if (! $privateKey) { + throw new Exception(__('Private key is empty')); + } + + $authKeysResult = $this->getAgent()->send('jabali_ssh.add_to_authorized_keys', [ + 'public_key' => $publicKey, + 'comment' => 'cpanel-migration-'.$this->cpanelUsername, + ]); + if (! ($authKeysResult['success'] ?? false)) { + throw new Exception($authKeysResult['error'] ?? __('Failed to add key to authorized_keys')); + } + + $this->addStatusLog(__('SSH access configured on Jabali'), 'success'); + + $this->addStatusLog(__('Preparing SSH key on cPanel...'), 'pending'); + + $cpanel->deleteSshKey($keyName, 'key'); + $cpanel->deleteSshKey($keyName, 'key.pub'); + + $this->addStatusLog(__('Importing SSH key to cPanel...'), 'pending'); + + $importResult = $cpanel->importSshPrivateKey($keyName, $privateKey); + if (! ($importResult['success'] ?? false)) { + throw new Exception($importResult['message'] ?? __('Failed to import SSH key')); + } + $this->addStatusLog(__('SSH key imported to cPanel'), 'success'); + + $this->addStatusLog(__('Authorizing SSH key...'), 'pending'); + $authResult = $cpanel->authorizeSshKey($keyName); + if (! ($authResult['success'] ?? false)) { + $this->addStatusLog(__('SSH key authorization skipped'), 'info'); + } else { + $this->addStatusLog(__('SSH key authorized'), 'success'); + } + + $this->addStatusLog(__('Initiating backup on cPanel (SCP method)...'), 'pending'); + + $jabaliIp = $this->getJabaliPublicIp(); + + $backupResult = $cpanel->createBackupToScpWithKey( + $jabaliIp, + 'root', + $destPath, + $keyName, + 22 + ); + + if (! ($backupResult['success'] ?? false)) { + throw new Exception($backupResult['message'] ?? __('Failed to start backup')); + } + + $this->backupInitiated = true; + $this->backupPid = $backupResult['pid'] ?? null; + $this->backupMethod = 'scp'; + $this->backupInitiatedAt = time(); + $this->lastSeenBackupSize = null; + $this->pollCount = 0; + + $this->addStatusLog(__('Backup initiated on cPanel'), 'success'); + $this->addStatusLog(__('Waiting for backup file to arrive...'), 'pending'); + + Notification::make() + ->title(__('Backup transfer started')) + ->body(__('cPanel is creating and transferring the backup. This may take several minutes.')) + ->info() + ->send(); + } catch (Exception $e) { + Log::error('Backup transfer failed', ['error' => $e->getMessage()]); + $this->addStatusLog(__('Error: :message', ['message' => $e->getMessage()]), 'error'); + + Notification::make() + ->title(__('Backup transfer failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function checkBackupStatus(): void + { + $this->pollCount++; + $destPath = $this->getBackupDestPath(); + + // For download method, we need to check cPanel for backup completion then download + if ($this->backupMethod === 'download') { + $this->checkAndDownloadBackup($destPath); + + return; + } + + // For SCP method, check for file arrival + $files = glob($destPath.'/backup-*.tar.gz'); + + // Filter to only files created after backup was initiated + if ($this->backupInitiatedAt) { + $files = array_filter($files, function ($file) { + return filemtime($file) >= ($this->backupInitiatedAt - 60); + }); + $files = array_values($files); + } + + if (! empty($files)) { + usort($files, fn ($a, $b) => filemtime($b) - filemtime($a)); + + $backupFile = $files[0]; + + $size1 = filesize($backupFile); + usleep(500000); + clearstatcache(true, $backupFile); + $size2 = filesize($backupFile); + + if ($size1 !== $size2) { + $this->addStatusLog(__('Receiving backup file... (:size)', ['size' => $this->formatBytes($size2)]), 'pending'); + + return; + } + + // Fix file permissions (SCP creates files as root, need agent to fix) + $this->getAgent()->send('cpanel.fix_backup_permissions', [ + 'backup_path' => $backupFile, + ]); + + // Verify the file is a valid gzip archive + $handle = fopen($backupFile, 'rb'); + $magic = $handle ? fread($handle, 2) : ''; + if ($handle) { + fclose($handle); + } + + if ($magic !== "\x1f\x8b") { + $this->addStatusLog(__('Received invalid backup file, waiting...'), 'pending'); + @unlink($backupFile); + + return; + } + + $this->backupPath = $backupFile; + $this->backupFilename = basename($backupFile); + $this->backupSize = filesize($backupFile); + + $this->addStatusLog(__('Backup file received'), 'success'); + + $this->cleanupCpanelSshKey(); + + // Always analyze backup to get accurate data (API may not have all permissions) + $this->analyzeBackup(); + + Notification::make() + ->title(__('Backup received')) + ->body(__('Backup file :name (:size) is ready. Click Next to continue.', [ + 'name' => $this->backupFilename, + 'size' => $this->formatBytes($this->backupSize), + ])) + ->success() + ->send(); + } else { + $this->addStatusLog(__('Waiting for backup file... (check :count)', ['count' => $this->pollCount]), 'pending'); + } + } + + protected function checkAndDownloadBackup(string $destPath): void + { + try { + $cpanel = $this->getCpanel(); + + // Check backup status on cPanel + $statusResult = $cpanel->getBackupStatus(); + + if (! ($statusResult['success'] ?? false)) { + $this->addStatusLog(__('Checking backup status... (attempt :count)', ['count' => $this->pollCount]), 'pending'); + + return; + } + + // Check if backup is still in progress + if ($statusResult['in_progress'] ?? false) { + $this->addStatusLog(__('Backup in progress on cPanel... (check :count)', ['count' => $this->pollCount]), 'pending'); + + return; + } + + // Look for completed backup files + $backups = $statusResult['backups'] ?? []; + if (empty($backups)) { + // Also try listBackups for older cPanel versions + $listResult = $cpanel->listBackups(); + $backups = $listResult['backups'] ?? []; + } + + // Filter to only backups created AFTER we initiated the backup + // This prevents picking up old backup files + if ($this->backupInitiatedAt) { + $backups = array_filter($backups, function ($backup) { + $mtime = $backup['mtime'] ?? 0; + + // Allow 60 second buffer before initiation time + return $mtime >= ($this->backupInitiatedAt - 60); + }); + $backups = array_values($backups); // Re-index array + } + + if (empty($backups)) { + $this->addStatusLog(__('Waiting for backup to complete... (check :count)', ['count' => $this->pollCount]), 'pending'); + + return; + } + + // Get the most recent backup + $latestBackup = $backups[0]; + $remoteFilename = $latestBackup['name'] ?? $latestBackup['file'] ?? ''; + $remotePath = $latestBackup['path'] ?? "/home/{$this->cpanelUsername}/{$remoteFilename}"; + $currentSize = (int) ($latestBackup['size'] ?? 0); + + if (empty($remoteFilename)) { + $this->addStatusLog(__('Waiting for backup file... (check :count)', ['count' => $this->pollCount]), 'pending'); + + return; + } + + // Check if file size has stabilized (backup still being written) + // Require size to be stable AND at least 100 KB (real backups are much larger) + $minBackupSize = 100 * 1024; // 100 KB minimum + if ($this->lastSeenBackupSize !== $currentSize || $currentSize < $minBackupSize) { + $this->lastSeenBackupSize = $currentSize; + $this->addStatusLog(__('Backup in progress... :size (check :count)', [ + 'size' => $this->formatBytes($currentSize), + 'count' => $this->pollCount, + ]), 'pending'); + + return; + } + + // Size is stable and large enough - backup is complete + $this->addStatusLog(__('Backup complete on cPanel: :name (:size)', [ + 'name' => $remoteFilename, + 'size' => $this->formatBytes($currentSize), + ]), 'success'); + $this->addStatusLog(__('Downloading backup file...'), 'pending'); + + // Download the backup + $localPath = $destPath.'/'.$remoteFilename; + $downloadResult = $cpanel->downloadFileToPath($remotePath, $localPath, function ($downloaded, $total) { + $percent = $total > 0 ? round(($downloaded / $total) * 100) : 0; + $this->addStatusLog(__('Downloading... :percent% (:downloaded / :total)', [ + 'percent' => $percent, + 'downloaded' => $this->formatBytes($downloaded), + 'total' => $this->formatBytes($total), + ]), 'pending'); + }); + + if (! ($downloadResult['success'] ?? false)) { + throw new Exception($downloadResult['message'] ?? __('Download failed')); + } + + // Verify the downloaded file is actually a gzip archive (not an HTML error page) + $handle = fopen($localPath, 'rb'); + $magic = $handle ? fread($handle, 2) : ''; + if ($handle) { + fclose($handle); + } + + // Gzip magic bytes: 0x1f 0x8b + if ($magic !== "\x1f\x8b") { + @unlink($localPath); // Delete invalid file + + // Clean up any old/invalid backup files in the destination + $oldFiles = glob($destPath.'/backup-*.tar.gz'); + foreach ($oldFiles as $oldFile) { + @unlink($oldFile); + } + + $this->addStatusLog(__('HTTP download blocked (403 Forbidden). Switching to SCP...'), 'warning'); + + // Reset state and switch to SCP method + $this->backupInitiated = false; + $this->backupMethod = 'scp'; + $this->lastSeenBackupSize = null; + $this->pollCount = 0; + + // Trigger SCP transfer + $this->startScpBackupTransfer(); + + return; + } + + $this->backupPath = $localPath; + $this->backupFilename = $remoteFilename; + $this->backupSize = filesize($localPath); + + $this->addStatusLog(__('Backup downloaded successfully'), 'success'); + + // Always analyze backup to get accurate data (API may not have all permissions) + $this->analyzeBackup(); + + Notification::make() + ->title(__('Backup downloaded')) + ->body(__('Backup file :name (:size) is ready. Click Next to continue.', [ + 'name' => $this->backupFilename, + 'size' => $this->formatBytes($this->backupSize), + ])) + ->success() + ->send(); + } catch (Exception $e) { + Log::error('Backup download failed', ['error' => $e->getMessage()]); + $this->addStatusLog(__('Download error: :message', ['message' => $e->getMessage()]), 'error'); + + Notification::make() + ->title(__('Download failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + protected function addStatusLog(string $message, string $status = 'info'): void + { + $lastIndex = count($this->statusLog) - 1; + if ($lastIndex >= 0 && $this->statusLog[$lastIndex]['status'] === 'pending' && $status !== 'pending') { + $this->statusLog[$lastIndex] = [ + 'message' => $message, + 'status' => $status, + 'time' => now()->format('H:i:s'), + ]; + + return; + } + + if ($status === 'pending') { + $this->statusLog = array_filter($this->statusLog, fn ($entry) => $entry['status'] !== 'pending' || ! str_contains($entry['message'], 'Waiting for backup')); + $this->statusLog = array_values($this->statusLog); + } + + $this->statusLog[] = [ + 'message' => $message, + 'status' => $status, + 'time' => now()->format('H:i:s'), + ]; + } + + protected function cleanupCpanelSshKey(): void + { + try { + $keyName = 'jabali-system-key'; + $cpanel = $this->getCpanel(); + + $cpanel->deleteSshKey($keyName, 'key'); + $cpanel->deleteSshKey($keyName, 'key.pub'); + + $this->addStatusLog(__('SSH key removed from cPanel'), 'success'); + } catch (Exception $e) { + Log::warning('Failed to cleanup cPanel SSH key: '.$e->getMessage()); + } + } + + public function analyzeBackup(): void + { + if (! $this->backupPath) { + return; + } + + $this->addStatusLog(__('Analyzing backup contents...'), 'pending'); + + try { + $result = $this->getAgent()->send('cpanel.analyze_backup', [ + 'backup_path' => $this->backupPath, + ]); + + if ($result['success'] ?? false) { + $this->discoveredData = $result['data'] ?? []; + $this->addStatusLog(__('Backup analyzed: :domains domains, :dbs databases, :mailboxes mailboxes', [ + 'domains' => count($this->discoveredData['domains'] ?? []), + 'dbs' => count($this->discoveredData['databases'] ?? []), + 'mailboxes' => count($this->discoveredData['mailboxes'] ?? []), + ]), 'success'); + } else { + throw new Exception($result['error'] ?? __('Failed to analyze backup')); + } + } catch (Exception $e) { + Log::error('Backup analysis failed', ['error' => $e->getMessage()]); + $this->addStatusLog(__('Analysis error: :message', ['message' => $e->getMessage()]), 'error'); + } + } + + public function analyzeLocalBackup(): void + { + if (! $this->backupPath) { + Notification::make() + ->title(__('No backup file')) + ->body(__('Please validate a backup file first')) + ->danger() + ->send(); + + return; + } + + // Reset and start analysis + $this->analysisLog = []; + $this->isAnalyzing = true; + $this->discoveredData = []; + + $this->addAnalysisLog(__('Starting backup analysis...'), 'pending'); + + try { + // Step 1: Extracting backup + $this->addAnalysisLog(__('Extracting backup archive...'), 'pending'); + + $result = $this->getAgent()->send('cpanel.analyze_backup', [ + 'backup_path' => $this->backupPath, + ]); + + if ($result['success'] ?? false) { + $this->addAnalysisLog(__('Backup archive extracted'), 'success'); + + $this->discoveredData = $result['data'] ?? []; + + // Extract cPanel username from backup analysis (needed for user creation) + if (! empty($this->discoveredData['cpanel_username'])) { + $this->cpanelUsername = $this->discoveredData['cpanel_username']; + $this->addAnalysisLog(__('cPanel user: :user', ['user' => $this->cpanelUsername]), 'success'); + } + + // Show what was found + $domainCount = count($this->discoveredData['domains'] ?? []); + $dbCount = count($this->discoveredData['databases'] ?? []); + $mailCount = count($this->discoveredData['mailboxes'] ?? []); + $sslCount = count($this->discoveredData['ssl_certificates'] ?? []); + + if ($domainCount > 0) { + $this->addAnalysisLog(__('Found :count domain(s)', ['count' => $domainCount]), 'success'); + } + if ($dbCount > 0) { + $this->addAnalysisLog(__('Found :count database(s)', ['count' => $dbCount]), 'success'); + } + if ($mailCount > 0) { + $this->addAnalysisLog(__('Found :count mailbox(es)', ['count' => $mailCount]), 'success'); + } + if ($sslCount > 0) { + $this->addAnalysisLog(__('Found :count SSL certificate(s)', ['count' => $sslCount]), 'success'); + } + + $this->addAnalysisLog(__('Analysis complete'), 'success'); + + Notification::make() + ->title(__('Analysis complete')) + ->body(__('Found :domains domains, :dbs databases, :mailboxes mailboxes. Click Next to continue.', [ + 'domains' => $domainCount, + 'dbs' => $dbCount, + 'mailboxes' => $mailCount, + ])) + ->success() + ->send(); + } else { + throw new Exception($result['error'] ?? __('Failed to analyze backup')); + } + } catch (Exception $e) { + Log::error('Local backup analysis failed', ['error' => $e->getMessage()]); + $this->addAnalysisLog(__('Error: :message', ['message' => $e->getMessage()]), 'error'); + + Notification::make() + ->title(__('Analysis failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } finally { + $this->isAnalyzing = false; + } + } + + public function startRestore(): void + { + if (! $this->backupPath) { + return; + } + + $this->isProcessing = true; + $this->migrationLog = []; + $this->restoreStatus = 'queued'; + + // Get or create the target user + $user = null; + if ($this->userMode === 'create') { + $this->addLog(__('Creating new user from cPanel backup...'), 'pending'); + $user = $this->createUserFromBackup(); + if (! $user) { + Notification::make() + ->title(__('User creation failed')) + ->body(__('Could not create user from backup')) + ->danger() + ->send(); + $this->isProcessing = false; + + return; + } + } else { + $user = $this->getTargetUser(); + if (! $user) { + Notification::make() + ->title(__('No user selected')) + ->body(__('Please select a target user')) + ->danger() + ->send(); + $this->isProcessing = false; + + return; + } + } + + $this->enqueueRestore($user); + } + + protected function addLog(string $message, string $status = 'info'): void + { + $this->migrationLog[] = [ + 'message' => $message, + 'status' => $status, + 'time' => now()->format('H:i:s'), + ]; + } + + protected function enqueueRestore(User $user): void + { + $this->restoreJobId = (string) Str::uuid(); + $logDir = storage_path('app/migrations/cpanel'); + File::ensureDirectoryExists($logDir); + $this->restoreLogPath = $logDir.'/'.$this->restoreJobId.'.log'; + File::put($this->restoreLogPath, ''); + @chmod($this->restoreLogPath, 0644); + + $this->appendMigrationLog(__('Restore queued for user: :user', ['user' => $user->username]), 'pending'); + + Cache::put($this->getRestoreCacheKey(), ['status' => 'queued'], now()->addHours(2)); + session()->put('cpanel_restore_job_id', $this->restoreJobId); + session()->put('cpanel_restore_log_path', $this->restoreLogPath); + session()->put('cpanel_restore_processing', true); + session()->save(); + + RunCpanelRestore::dispatch( + jobId: $this->restoreJobId, + logPath: $this->restoreLogPath, + backupPath: $this->backupPath, + username: $user->username, + restoreFiles: $this->restoreFiles, + restoreDatabases: $this->restoreDatabases, + restoreEmails: $this->restoreEmails, + restoreSsl: $this->restoreSsl, + discoveredData: ! empty($this->discoveredData) ? $this->discoveredData : null, + ); + } + + public function pollMigrationLog(): void + { + if (! $this->restoreJobId || ! $this->restoreLogPath) { + return; + } + + $this->migrationLog = $this->readMigrationLog($this->restoreLogPath); + + $status = Cache::get($this->getRestoreCacheKey()); + if (is_array($status)) { + $this->restoreStatus = $status['status'] ?? $this->restoreStatus; + } + + if (in_array($this->restoreStatus, ['completed', 'failed'], true)) { + $this->isProcessing = false; + session()->forget(['cpanel_restore_job_id', 'cpanel_restore_log_path', 'cpanel_restore_processing']); + session()->save(); + } + } + + protected function readMigrationLog(string $path): array + { + if (! file_exists($path)) { + return []; + } + + $entries = []; + $lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + foreach ($lines as $line) { + $decoded = json_decode($line, true); + if (is_array($decoded) && isset($decoded['message'], $decoded['status'])) { + $entries[] = [ + 'message' => $decoded['message'], + 'status' => $decoded['status'], + 'time' => $decoded['time'] ?? now()->format('H:i:s'), + ]; + } + } + + return $entries; + } + + protected function appendMigrationLog(string $message, string $status): void + { + $entry = [ + 'message' => $message, + 'status' => $status, + 'time' => now()->format('H:i:s'), + ]; + + $this->migrationLog[] = $entry; + + if ($this->restoreLogPath) { + file_put_contents( + $this->restoreLogPath, + json_encode($entry).PHP_EOL, + FILE_APPEND | LOCK_EX + ); + @chmod($this->restoreLogPath, 0644); + } + } + + protected function getRestoreCacheKey(): string + { + return 'cpanel_restore_status_'.$this->restoreJobId; + } + + protected function restoreMigrationStateFromSession(): void + { + $this->restoreJobId = session()->get('cpanel_restore_job_id'); + $this->restoreLogPath = session()->get('cpanel_restore_log_path'); + $this->isProcessing = (bool) session()->get('cpanel_restore_processing', false); + + if ($this->restoreJobId && $this->restoreLogPath) { + $this->pollMigrationLog(); + } + } + + public function resetMigration(): void + { + $this->userMode = 'create'; + $this->targetUserId = null; + $this->sourceType = 'remote'; + $this->localBackupPath = null; + $this->hostname = null; + $this->cpanelUsername = null; + $this->apiToken = null; + $this->port = 2083; + $this->useSSL = true; + $this->isConnected = false; + $this->connectionInfo = []; + $this->backupInitiated = false; + $this->backupPid = null; + $this->backupFilename = null; + $this->backupPath = null; + $this->backupSize = 0; + $this->pollCount = 0; + $this->discoveredData = []; + $this->restoreFiles = true; + $this->restoreDatabases = true; + $this->restoreEmails = true; + $this->restoreSsl = true; + $this->isProcessing = false; + $this->isAnalyzing = false; + $this->migrationLog = []; + $this->statusLog = []; + $this->analysisLog = []; + $this->cpanel = null; + $this->restoreJobId = null; + $this->restoreLogPath = null; + $this->restoreStatus = null; + + // Clear session credentials + $this->clearSessionCredentials(); + session()->forget(['cpanel_restore_job_id', 'cpanel_restore_log_path', 'cpanel_restore_processing']); + session()->save(); + + $this->redirect(static::getUrl()); + } + + protected 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]; + } +} diff --git a/app/Filament/Admin/Pages/Dashboard.php b/app/Filament/Admin/Pages/Dashboard.php new file mode 100644 index 0000000..3ed3bb6 --- /dev/null +++ b/app/Filament/Admin/Pages/Dashboard.php @@ -0,0 +1,123 @@ +components([ + // Recent Activity + Section::make(__('Recent Activity')) + ->icon('heroicon-o-clock') + ->schema([ + EmbeddedTable::make(RecentActivityTable::class), + ]), + ]); + } + + #[On('tour-completed')] + public function completeTour(): void + { + DnsSetting::set('tour_completed', '1'); + DnsSetting::clearCache(); + } + + protected function getHeaderActions(): array + { + return [ + Action::make('refresh') + ->label(__('Refresh')) + ->icon('heroicon-o-arrow-path') + ->color('gray') + ->action(fn () => $this->redirect(request()->url())), + + Action::make('onboarding') + ->label(__('Setup Wizard')) + ->icon('heroicon-o-sparkles') + ->visible(fn () => ! DnsSetting::get('onboarding_completed', false)) + ->modalHeading(__('Welcome to Jabali!')) + ->modalDescription(__('Let\'s get your server control panel set up.')) + ->modalWidth('md') + ->form([ + TextInput::make('admin_email') + ->label(__('Your Email Address')) + ->helperText(__('Enter your email to receive important server notifications.')) + ->email() + ->placeholder('admin@example.com'), + ]) + ->modalSubmitActionLabel(__('Get Started')) + ->action(function (array $data): void { + if (! empty($data['admin_email'])) { + DnsSetting::set('admin_email_recipients', $data['admin_email']); + } + DnsSetting::set('onboarding_completed', '1'); + DnsSetting::clearCache(); + + $this->dispatch('start-admin-tour'); + }), + + Action::make('takeTour') + ->label(__('Take Tour')) + ->icon('heroicon-o-academic-cap') + ->color('gray') + ->action(function (): void { + $this->dispatch('start-admin-tour'); + }), + ]; + } +} diff --git a/app/Filament/Admin/Pages/DnsZones.php b/app/Filament/Admin/Pages/DnsZones.php new file mode 100644 index 0000000..3eaa5de --- /dev/null +++ b/app/Filament/Admin/Pages/DnsZones.php @@ -0,0 +1,892 @@ + [field => value]] + + public array $pendingDeletes = []; // [record_id, ...] + + public array $pendingAdds = []; // [[field => value], ...] + + public static function getNavigationLabel(): string + { + return __('DNS Zones'); + } + + public function getTitle(): string|Htmlable + { + return __('DNS Zone Manager'); + } + + public function getAgent(): AgentClient + { + return $this->agent ??= new AgentClient; + } + + public function mount(): void + { + // Start with no domain selected + $this->selectedDomainId = null; + } + + public function form(Schema $form): Schema + { + return $form + ->schema([ + Select::make('selectedDomainId') + ->label(__('Select Domain')) + ->options(fn () => Domain::orderBy('domain')->pluck('domain', 'id')->toArray()) + ->searchable() + ->preload() + ->live() + ->afterStateUpdated(fn () => $this->onDomainChange()) + ->placeholder(__('Select a domain to manage DNS records')), + ]); + } + + public function content(Schema $schema): Schema + { + return $schema->schema([ + Section::make(__('Select Domain')) + ->description(__('Choose a domain to manage DNS records.')) + ->schema([ + EmbeddedSchema::make('form'), + ]), + Section::make(__('Zone Status')) + ->description(fn () => $this->getSelectedDomain()?->domain) + ->icon('heroicon-o-signal') + ->headerActions([ + Action::make('rebuildZone') + ->label(__('Rebuild Zone')) + ->icon('heroicon-o-arrow-path') + ->color('gray') + ->action(fn () => $this->rebuildCurrentZone()), + Action::make('deleteZone') + ->label(__('Delete Zone')) + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->modalHeading(__('Delete DNS Zone')) + ->modalDescription(__('Delete DNS zone for this domain? All records will be removed.')) + ->action(fn () => $this->deleteCurrentZone()), + ]) + ->schema([ + Grid::make(['default' => 1, 'sm' => 3])->schema([ + Text::make(fn () => (($this->getZoneStatus() ?? [])['zone_file_exists'] ?? false) ? __('Active') : __('Missing')) + ->badge() + ->color(fn () => (($this->getZoneStatus() ?? [])['zone_file_exists'] ?? false) ? 'success' : 'danger'), + Text::make(fn () => __(':count records', ['count' => ($this->getZoneStatus() ?? [])['records_count'] ?? 0])) + ->badge() + ->color('gray'), + Text::make(fn () => __('Owner: :owner', ['owner' => ($this->getZoneStatus() ?? [])['user'] ?? 'N/A'])) + ->color('gray'), + ]), + ]) + ->visible(fn () => $this->selectedDomainId !== null), + Section::make(__('New Records to Add')) + ->description(__('These records will be created when you save changes.')) + ->icon('heroicon-o-plus-circle') + ->iconColor('success') + ->collapsible() + ->schema([ + EmbeddedTable::make(DnsPendingAddsTable::class, fn () => [ + 'records' => $this->pendingAdds, + ]), + ]) + ->headerActions([ + Action::make('clearPending') + ->label(__('Clear All')) + ->icon('heroicon-o-trash') + ->color('danger') + ->size('sm') + ->requiresConfirmation() + ->action(fn () => $this->clearPendingAdds()), + ]) + ->visible(fn () => $this->selectedDomainId !== null && count($this->pendingAdds) > 0), + EmbeddedTable::make() + ->visible(fn () => $this->selectedDomainId !== null), + EmptyState::make(__('No Domain Selected')) + ->description(__('Select a domain from the dropdown above to manage DNS records.')) + ->icon('heroicon-o-globe-alt') + ->iconColor('gray') + ->visible(fn () => $this->selectedDomainId === null), + ]); + } + + public function onDomainChange(): void + { + // Discard pending changes when switching domains + $this->pendingEdits = []; + $this->pendingDeletes = []; + $this->pendingAdds = []; + $this->resetTable(); + } + + public function updatedSelectedDomainId(): void + { + $this->onDomainChange(); + } + + // Pending changes helpers + public function hasPendingChanges(): bool + { + return count($this->pendingEdits) > 0 || count($this->pendingDeletes) > 0 || count($this->pendingAdds) > 0; + } + + public function getPendingChangesCount(): int + { + return count($this->pendingEdits) + count($this->pendingDeletes) + count($this->pendingAdds); + } + + public function isRecordPendingDelete(int $recordId): bool + { + return in_array($recordId, $this->pendingDeletes); + } + + public function isRecordPendingEdit(int $recordId): bool + { + return isset($this->pendingEdits[$recordId]); + } + + public function clearPendingAdds(): void + { + $this->pendingAdds = []; + Notification::make()->title(__('Pending records cleared'))->success()->send(); + } + + public function table(Table $table): Table + { + return $table + ->query( + DnsRecord::query() + ->when($this->selectedDomainId, fn (Builder $query) => $query->where('domain_id', $this->selectedDomainId)) + ->orderByRaw("CASE type + WHEN 'NS' THEN 1 + WHEN 'A' THEN 2 + WHEN 'AAAA' THEN 3 + WHEN 'CNAME' THEN 4 + WHEN 'MX' THEN 5 + WHEN 'TXT' THEN 6 + WHEN 'SRV' THEN 7 + WHEN 'CAA' THEN 8 + ELSE 9 END") + ->orderBy('name') + ) + ->columns([ + TextColumn::make('type') + ->label(__('Type')) + ->badge() + ->color(fn (DnsRecord $record) => $this->isRecordPendingDelete($record->id) ? 'danger' : match ($record->type) { + 'A', 'AAAA' => 'info', + 'CNAME' => 'primary', + 'MX' => 'warning', + 'TXT' => 'success', + 'NS' => 'danger', + 'SRV' => 'primary', + 'CAA' => 'warning', + default => 'gray', + }) + ->sortable(), + TextColumn::make('name') + ->label(__('Name')) + ->fontFamily('mono') + ->color(fn (DnsRecord $record) => $this->isRecordPendingDelete($record->id) ? 'danger' : null) + ->searchable(), + TextColumn::make('content') + ->label(__('Content')) + ->fontFamily('mono') + ->limit(50) + ->tooltip(fn ($record) => $record->content) + ->color(fn (DnsRecord $record) => $this->isRecordPendingDelete($record->id) ? 'danger' : ($this->isRecordPendingEdit($record->id) ? 'warning' : null)) + ->searchable(), + TextColumn::make('ttl') + ->label(__('TTL')) + ->color(fn (DnsRecord $record) => $this->isRecordPendingDelete($record->id) ? 'danger' : null) + ->sortable(), + TextColumn::make('priority') + ->label(__('Priority')) + ->placeholder('-') + ->color(fn (DnsRecord $record) => $this->isRecordPendingDelete($record->id) ? 'danger' : null) + ->sortable(), + TextColumn::make('domain.user.username') + ->label(__('Owner')) + ->placeholder('N/A') + ->sortable(), + ]) + ->filters([]) + ->headerActions([ + Action::make('resetToDefaults') + ->label(__('Reset to Defaults')) + ->icon('heroicon-o-arrow-path') + ->color('gray') + ->requiresConfirmation() + ->modalHeading(__('Reset DNS Records')) + ->modalDescription(__('This will delete all existing DNS records and create default records. This action cannot be undone.')) + ->modalIcon('heroicon-o-exclamation-triangle') + ->modalIconColor('warning') + ->action(fn () => $this->resetToDefaults()), + Action::make('saveChanges') + ->label(__('Save')) + ->icon('heroicon-o-check') + ->color('primary') + ->action(fn () => $this->saveChanges()), + ]) + ->recordActions([ + Action::make('edit') + ->label(__('Edit')) + ->icon('heroicon-o-pencil') + ->color(fn (DnsRecord $record) => $this->isRecordPendingEdit($record->id) ? 'warning' : 'gray') + ->hidden(fn (DnsRecord $record) => $this->isRecordPendingDelete($record->id)) + ->modalHeading(__('Edit DNS Record')) + ->modalDescription(__('Changes will be queued until you click "Save Changes".')) + ->modalIcon('heroicon-o-pencil-square') + ->modalIconColor('primary') + ->modalSubmitActionLabel(__('Queue Changes')) + ->fillForm(fn (DnsRecord $record) => [ + 'type' => $this->pendingEdits[$record->id]['type'] ?? $record->type, + 'name' => $this->pendingEdits[$record->id]['name'] ?? $record->name, + 'content' => $this->pendingEdits[$record->id]['content'] ?? $record->content, + 'ttl' => $this->pendingEdits[$record->id]['ttl'] ?? $record->ttl, + 'priority' => $this->pendingEdits[$record->id]['priority'] ?? $record->priority, + ]) + ->form([ + Select::make('type') + ->label(__('Record Type')) + ->options([ + 'A' => __('A - IPv4 Address'), + 'AAAA' => __('AAAA - IPv6 Address'), + 'CNAME' => __('CNAME - Canonical Name'), + 'MX' => __('MX - Mail Exchange'), + 'TXT' => __('TXT - Text Record'), + 'NS' => __('NS - Nameserver'), + 'SRV' => __('SRV - Service'), + 'CAA' => __('CAA - Certificate Authority'), + ]) + ->required() + ->reactive(), + TextInput::make('name') + ->label(__('Name')) + ->placeholder(__('@ for root, or subdomain')) + ->required() + ->maxLength(255), + TextInput::make('content') + ->label(__('Content')) + ->required() + ->maxLength(1024), + TextInput::make('ttl') + ->label(__('TTL (seconds)')) + ->numeric() + ->minValue(60) + ->maxValue(86400), + TextInput::make('priority') + ->label(__('Priority')) + ->numeric() + ->visible(fn ($get) => in_array($get('type'), ['MX', 'SRV'])), + ]) + ->action(function (DnsRecord $record, array $data): void { + // Queue the edit + $this->pendingEdits[$record->id] = [ + 'type' => $data['type'], + 'name' => $data['name'], + 'content' => $data['content'], + 'ttl' => $data['ttl'] ?? 3600, + 'priority' => $data['priority'] ?? null, + ]; + Notification::make() + ->title(__('Edit queued')) + ->body(__('Click "Save Changes" to apply.')) + ->info() + ->send(); + }), + Action::make('delete') + ->label(__('Delete')) + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->modalHeading(__('Delete Record')) + ->modalDescription(fn (DnsRecord $record) => __('Delete the :type record for :name?', ['type' => $record->type, 'name' => $record->name])) + ->modalIcon('heroicon-o-trash') + ->modalIconColor('danger') + ->modalSubmitActionLabel(__('Delete')) + ->action(function (DnsRecord $record): void { + if (! in_array($record->id, $this->pendingDeletes)) { + $this->pendingDeletes[] = $record->id; + } + unset($this->pendingEdits[$record->id]); + }), + ]) + ->emptyStateHeading(__('No DNS records')) + ->emptyStateDescription(__('Add DNS records to manage this domain\'s DNS configuration.')) + ->emptyStateIcon('heroicon-o-server-stack') + ->striped(); + } + + public function getSelectedDomain(): ?Domain + { + return $this->selectedDomainId ? Domain::find($this->selectedDomainId) : null; + } + + public function getZoneStatus(): ?array + { + if (! $this->selectedDomainId) { + return null; + } + + $domain = Domain::find($this->selectedDomainId); + if (! $domain) { + return null; + } + + $zoneFile = "/etc/bind/zones/db.{$domain->domain}"; + $recordsCount = DnsRecord::where('domain_id', $this->selectedDomainId)->count(); + + return [ + 'domain' => $domain->domain, + 'user' => $domain->user->username ?? 'N/A', + 'records_count' => $recordsCount, + 'zone_file_exists' => file_exists($zoneFile), + ]; + } + + #[On('dns-pending-add-remove')] + public function removePendingAddFromTable(string $key): void + { + $this->removePendingAdd($key); + } + + public function removePendingAdd(int|string $identifier): void + { + if (is_int($identifier)) { + unset($this->pendingAdds[$identifier]); + $this->pendingAdds = array_values($this->pendingAdds); + Notification::make()->title(__('Pending record removed'))->success()->send(); + + return; + } + + $this->pendingAdds = array_values(array_filter( + $this->pendingAdds, + fn (array $record): bool => ($record['key'] ?? null) !== $identifier + )); + Notification::make()->title(__('Pending record removed'))->success()->send(); + } + + protected function queuePendingAdd(array $record): void + { + $record['key'] ??= (string) Str::uuid(); + $this->pendingAdds[] = $record; + } + + protected function sanitizePendingAdd(array $record): array + { + unset($record['key']); + + return $record; + } + + public function saveChanges(bool $notify = true): void + { + if (! $this->hasPendingChanges()) { + if ($notify) { + Notification::make()->title(__('No changes to save'))->warning()->send(); + } + + return; + } + + $domain = Domain::find($this->selectedDomainId); + if (! $domain) { + Notification::make()->title(__('Domain not found'))->danger()->send(); + + return; + } + + try { + // Apply deletes + foreach ($this->pendingDeletes as $recordId) { + DnsRecord::where('id', $recordId)->delete(); + } + + // Apply edits + foreach ($this->pendingEdits as $recordId => $data) { + $record = DnsRecord::find($recordId); + if ($record) { + $record->update($data); + } + } + + // Apply adds + foreach ($this->pendingAdds as $data) { + DnsRecord::create(array_merge(['domain_id' => $this->selectedDomainId], $this->sanitizePendingAdd($data))); + } + + // Sync zone file + $this->syncZoneFile($domain->domain); + + // Clear pending changes + $this->pendingEdits = []; + $this->pendingDeletes = []; + $this->pendingAdds = []; + + // Reset table to refresh data + $this->resetTable(); + + if ($notify) { + Notification::make() + ->title(__('Changes saved')) + ->body(__('DNS records updated. Changes may take up to 48 hours to propagate.')) + ->success() + ->send(); + } + + } catch (Exception $e) { + Notification::make() + ->title(__('Failed to save changes')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function discardChanges(): void + { + $this->pendingEdits = []; + $this->pendingDeletes = []; + $this->pendingAdds = []; + Notification::make()->title(__('Changes discarded'))->success()->send(); + } + + public function resetToDefaults(): void + { + $domain = Domain::find($this->selectedDomainId); + if (! $domain) { + Notification::make()->title(__('Domain not found'))->danger()->send(); + + return; + } + + try { + // Delete all existing records + DnsRecord::where('domain_id', $this->selectedDomainId)->delete(); + + // Create default records + $settings = DnsSetting::getAll(); + $serverIp = $domain->ip_address ?: ($settings['default_ip'] ?? trim(shell_exec("hostname -I | awk '{print $1}'") ?? '') ?: '127.0.0.1'); + $serverIpv6 = $domain->ipv6_address ?: ($settings['default_ipv6'] ?? null); + $ns1 = $settings['ns1'] ?? 'ns1.'.$domain->domain; + $ns2 = $settings['ns2'] ?? 'ns2.'.$domain->domain; + + $defaultRecords = [ + ['name' => '@', 'type' => 'NS', 'content' => $ns1, 'ttl' => 3600, 'priority' => null], + ['name' => '@', 'type' => 'NS', 'content' => $ns2, 'ttl' => 3600, 'priority' => null], + ['name' => '@', 'type' => 'A', 'content' => $serverIp, 'ttl' => 3600, 'priority' => null], + ['name' => 'www', 'type' => 'A', 'content' => $serverIp, 'ttl' => 3600, 'priority' => null], + ['name' => 'mail', 'type' => 'A', 'content' => $serverIp, 'ttl' => 3600, 'priority' => null], + ['name' => '@', 'type' => 'MX', 'content' => 'mail.'.$domain->domain, 'ttl' => 3600, 'priority' => 10], + ['name' => '@', 'type' => 'TXT', 'content' => 'v=spf1 mx a ~all', 'ttl' => 3600, 'priority' => null], + ]; + + if (! empty($serverIpv6)) { + $defaultRecords[] = ['name' => '@', 'type' => 'AAAA', 'content' => $serverIpv6, 'ttl' => 3600, 'priority' => null]; + $defaultRecords[] = ['name' => 'www', 'type' => 'AAAA', 'content' => $serverIpv6, 'ttl' => 3600, 'priority' => null]; + $defaultRecords[] = ['name' => 'mail', 'type' => 'AAAA', 'content' => $serverIpv6, 'ttl' => 3600, 'priority' => null]; + } + + foreach ($defaultRecords as $record) { + DnsRecord::create(array_merge(['domain_id' => $this->selectedDomainId], $record)); + } + + // Sync zone file + $this->syncZoneFile($domain->domain); + + // Clear any pending changes + $this->pendingEdits = []; + $this->pendingDeletes = []; + $this->pendingAdds = []; + + $this->resetTable(); + + Notification::make() + ->title(__('DNS records reset')) + ->body(__('Default records have been created for :domain', ['domain' => $domain->domain])) + ->success() + ->send(); + + } catch (Exception $e) { + Notification::make() + ->title(__('Failed to reset records')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + protected function getHeaderActions(): array + { + return [ + $this->getTourAction(), + Action::make('syncAllZones') + ->label(__('Sync All Zones')) + ->icon('heroicon-o-arrow-path') + ->color('primary') + ->requiresConfirmation() + ->modalDescription(__('This will regenerate all zone files from the database.')) + ->action(function () { + $count = 0; + $domains = Domain::all(); + $settings = DnsSetting::getAll(); + + foreach ($domains as $domain) { + try { + $records = DnsRecord::where('domain_id', $domain->id)->get()->toArray(); + $this->getAgent()->send('dns.sync_zone', [ + 'domain' => $domain->domain, + 'records' => $records, + 'ns1' => $settings['ns1'] ?? 'ns1.example.com', + 'ns2' => $settings['ns2'] ?? 'ns2.example.com', + 'admin_email' => $settings['admin_email'] ?? 'admin.example.com', + 'default_ttl' => $settings['default_ttl'] ?? 3600, + ]); + $count++; + } catch (Exception $e) { + // Continue with other zones + } + } + + Notification::make()->title(__(':count zones synced', ['count' => $count]))->success()->send(); + }), + $this->applyTemplateAction() + ->visible(fn () => $this->selectedDomainId !== null), + $this->addRecordAction() + ->visible(fn () => $this->selectedDomainId !== null), + ]; + } + + public function addRecordAction(): Action + { + return Action::make('addRecord') + ->label(__('Add Record')) + ->icon('heroicon-o-plus') + ->color('primary') + ->modalHeading(__('Add DNS Record')) + ->modalDescription(__('The record will be queued until you click "Save Changes".')) + ->modalIcon('heroicon-o-plus-circle') + ->modalIconColor('primary') + ->modalSubmitActionLabel(__('Queue Record')) + ->modalWidth('lg') + ->form([ + Select::make('type') + ->label(__('Record Type')) + ->options([ + 'A' => __('A - IPv4 Address'), + 'AAAA' => __('AAAA - IPv6 Address'), + 'CNAME' => __('CNAME - Canonical Name'), + 'MX' => __('MX - Mail Exchange'), + 'TXT' => __('TXT - Text Record'), + 'NS' => __('NS - Nameserver'), + 'SRV' => __('SRV - Service'), + 'CAA' => __('CAA - Certificate Authority'), + ]) + ->required() + ->reactive(), + TextInput::make('name') + ->label(__('Name')) + ->placeholder(__('@ for root, or subdomain')) + ->required(), + TextInput::make('content') + ->label(__('Content')) + ->required(), + TextInput::make('ttl') + ->label(__('TTL')) + ->numeric() + ->default(3600), + TextInput::make('priority') + ->label(__('Priority')) + ->numeric() + ->visible(fn ($get) => in_array($get('type'), ['MX', 'SRV'])), + ]) + ->action(function (array $data) { + // Queue the add + $this->queuePendingAdd([ + 'type' => $data['type'], + 'name' => $data['name'], + 'content' => $data['content'], + 'ttl' => $data['ttl'] ?? 3600, + 'priority' => $data['priority'] ?? null, + ]); + Notification::make() + ->title(__('Record queued')) + ->body(__('Click "Save Changes" to apply.')) + ->info() + ->send(); + }); + } + + public function applyTemplateAction(): Action + { + return Action::make('applyTemplate') + ->label(__('Apply Template')) + ->icon('heroicon-o-document-duplicate') + ->color('gray') + ->modalHeading(__('Apply Email Template')) + ->modalDescription(__('This will apply the selected email DNS records immediately.')) + ->modalIcon('heroicon-o-envelope') + ->modalIconColor('warning') + ->modalSubmitActionLabel(__('Apply Template')) + ->modalWidth('lg') + ->form([ + Select::make('template') + ->label(__('Email Provider')) + ->options([ + 'google' => __('Google Workspace (Gmail)'), + 'microsoft' => __('Microsoft 365 (Outlook)'), + 'zoho' => __('Zoho Mail'), + 'protonmail' => __('ProtonMail'), + 'fastmail' => __('Fastmail'), + 'local' => __('Local Mail Server (This Server)'), + 'none' => __('Remove All Email Records'), + ]) + ->required() + ->reactive(), + TextInput::make('verification_code') + ->label(__('Domain Verification Code (optional)')) + ->placeholder(__('e.g., google-site-verification=xxx')) + ->visible(fn ($get) => $get('template') && $get('template') !== 'none'), + ]) + ->action(function (array $data) { + $domain = Domain::find($this->selectedDomainId); + if (! $domain) { + Notification::make()->title(__('Domain not found'))->danger()->send(); + + return; + } + + $domainName = $domain->domain; + $template = $data['template']; + $verificationCode = $data['verification_code'] ?? null; + + // Queue deletion of existing MX and email-related records + $recordsToDelete = DnsRecord::where('domain_id', $this->selectedDomainId) + ->where(function ($query) { + $query->where('type', 'MX') + ->orWhere(function ($q) { + $q->where('type', 'A')->where('name', 'mail'); + }) + ->orWhere(function ($q) { + $q->where('type', 'CNAME')->where('name', 'autodiscover'); + }) + ->orWhere(function ($q) { + $q->where('type', 'TXT') + ->where(function ($inner) { + $inner->where('content', 'like', '%spf%') + ->orWhere('content', 'like', '%v=spf1%') + ->orWhere('content', 'like', '%google-site-verification%') + ->orWhere('content', 'like', '%MS=%') + ->orWhere('content', 'like', '%zoho-verification%') + ->orWhere('content', 'like', '%protonmail-verification%') + ->orWhere('name', 'like', '%_domainkey%'); + }); + }); + }) + ->pluck('id') + ->toArray(); + + foreach ($recordsToDelete as $id) { + if (! in_array($id, $this->pendingDeletes)) { + $this->pendingDeletes[] = $id; + } + unset($this->pendingEdits[$id]); + } + + // Queue new records + if ($template !== 'none') { + $records = $this->getTemplateRecords($template, $domainName, $verificationCode); + foreach ($records as $record) { + $this->queuePendingAdd($record); + } + } + + if (! $this->hasPendingChanges()) { + Notification::make() + ->title(__('No changes to apply')) + ->warning() + ->send(); + + return; + } + + $this->saveChanges(false); + + $message = $template === 'none' + ? __('Email records removed.') + : __('Email records for :provider have been applied.', ['provider' => ucfirst($template)]); + + Notification::make() + ->title(__('Template applied')) + ->body($message.' '.__('Changes may take up to 48 hours to propagate.')) + ->success() + ->send(); + }); + } + + protected function getTemplateRecords(string $template, string $domain, ?string $verificationCode): array + { + $settings = DnsSetting::getAll(); + $serverIp = $settings['default_ip'] ?? trim(shell_exec("hostname -I | awk '{print $1}'") ?? '') ?: '127.0.0.1'; + + $records = match ($template) { + 'google' => [ + ['name' => '@', 'type' => 'MX', 'content' => 'aspmx.l.google.com', 'ttl' => 3600, 'priority' => 1], + ['name' => '@', 'type' => 'MX', 'content' => 'alt1.aspmx.l.google.com', 'ttl' => 3600, 'priority' => 5], + ['name' => '@', 'type' => 'MX', 'content' => 'alt2.aspmx.l.google.com', 'ttl' => 3600, 'priority' => 5], + ['name' => '@', 'type' => 'MX', 'content' => 'alt3.aspmx.l.google.com', 'ttl' => 3600, 'priority' => 10], + ['name' => '@', 'type' => 'MX', 'content' => 'alt4.aspmx.l.google.com', 'ttl' => 3600, 'priority' => 10], + ['name' => '@', 'type' => 'TXT', 'content' => 'v=spf1 include:_spf.google.com ~all', 'ttl' => 3600, 'priority' => null], + ], + 'microsoft' => [ + ['name' => '@', 'type' => 'MX', 'content' => str_replace('.', '-', $domain).'.mail.protection.outlook.com', 'ttl' => 3600, 'priority' => 0], + ['name' => '@', 'type' => 'TXT', 'content' => 'v=spf1 include:spf.protection.outlook.com ~all', 'ttl' => 3600, 'priority' => null], + ['name' => 'autodiscover', 'type' => 'CNAME', 'content' => 'autodiscover.outlook.com', 'ttl' => 3600, 'priority' => null], + ], + 'zoho' => [ + ['name' => '@', 'type' => 'MX', 'content' => 'mx.zoho.com', 'ttl' => 3600, 'priority' => 10], + ['name' => '@', 'type' => 'MX', 'content' => 'mx2.zoho.com', 'ttl' => 3600, 'priority' => 20], + ['name' => '@', 'type' => 'MX', 'content' => 'mx3.zoho.com', 'ttl' => 3600, 'priority' => 50], + ['name' => '@', 'type' => 'TXT', 'content' => 'v=spf1 include:zoho.com ~all', 'ttl' => 3600, 'priority' => null], + ], + 'protonmail' => [ + ['name' => '@', 'type' => 'MX', 'content' => 'mail.protonmail.ch', 'ttl' => 3600, 'priority' => 10], + ['name' => '@', 'type' => 'MX', 'content' => 'mailsec.protonmail.ch', 'ttl' => 3600, 'priority' => 20], + ['name' => '@', 'type' => 'TXT', 'content' => 'v=spf1 include:_spf.protonmail.ch mx ~all', 'ttl' => 3600, 'priority' => null], + ], + 'fastmail' => [ + ['name' => '@', 'type' => 'MX', 'content' => 'in1-smtp.messagingengine.com', 'ttl' => 3600, 'priority' => 10], + ['name' => '@', 'type' => 'MX', 'content' => 'in2-smtp.messagingengine.com', 'ttl' => 3600, 'priority' => 20], + ['name' => '@', 'type' => 'TXT', 'content' => 'v=spf1 include:spf.messagingengine.com ~all', 'ttl' => 3600, 'priority' => null], + ], + 'local' => [ + ['name' => '@', 'type' => 'MX', 'content' => 'mail.'.$domain, 'ttl' => 3600, 'priority' => 10], + ['name' => 'mail', 'type' => 'A', 'content' => $serverIp, 'ttl' => 3600, 'priority' => null], + ['name' => '@', 'type' => 'TXT', 'content' => 'v=spf1 mx a ~all', 'ttl' => 3600, 'priority' => null], + ], + default => [], + }; + + if ($verificationCode) { + $records[] = ['name' => '@', 'type' => 'TXT', 'content' => $verificationCode, 'ttl' => 3600, 'priority' => null]; + } + + return $records; + } + + public function rebuildCurrentZone(): void + { + if (! $this->selectedDomainId) { + return; + } + + $domain = Domain::find($this->selectedDomainId); + if (! $domain) { + return; + } + + try { + $this->syncZoneFile($domain->domain); + Notification::make()->title(__('Zone rebuilt for :domain', ['domain' => $domain->domain]))->success()->send(); + } catch (Exception $e) { + Notification::make()->title(__('Failed'))->body($e->getMessage())->danger()->send(); + } + } + + public function deleteCurrentZone(): void + { + if (! $this->selectedDomainId) { + return; + } + + $domain = Domain::find($this->selectedDomainId); + if (! $domain) { + return; + } + + try { + $this->getAgent()->send('dns.delete_zone', ['domain' => $domain->domain]); + DnsRecord::where('domain_id', $this->selectedDomainId)->delete(); + + Notification::make()->title(__('Zone deleted for :domain', ['domain' => $domain->domain]))->success()->send(); + + $this->selectedDomainId = null; + $this->pendingEdits = []; + $this->pendingDeletes = []; + $this->pendingAdds = []; + } catch (Exception $e) { + Notification::make()->title(__('Failed'))->body($e->getMessage())->danger()->send(); + } + } + + protected function syncZoneFile(string $domain): void + { + $records = DnsRecord::whereHas('domain', fn ($q) => $q->where('domain', $domain))->get()->toArray(); + $settings = DnsSetting::getAll(); + + $this->getAgent()->send('dns.sync_zone', [ + 'domain' => $domain, + 'records' => $records, + 'ns1' => $settings['ns1'] ?? 'ns1.example.com', + 'ns2' => $settings['ns2'] ?? 'ns2.example.com', + 'admin_email' => $settings['admin_email'] ?? 'admin.example.com', + 'default_ttl' => $settings['default_ttl'] ?? 3600, + ]); + } +} diff --git a/app/Filament/Admin/Pages/IpAddresses.php b/app/Filament/Admin/Pages/IpAddresses.php new file mode 100644 index 0000000..d18de59 --- /dev/null +++ b/app/Filament/Admin/Pages/IpAddresses.php @@ -0,0 +1,330 @@ +loadAddresses(); + } + + protected function getAgent(): AgentClient + { + return $this->agent ??= new AgentClient; + } + + protected function loadAddresses(): void + { + try { + $result = $this->getAgent()->ipList(); + $this->addresses = $result['addresses'] ?? []; + $this->interfaces = $result['interfaces'] ?? []; + } catch (Exception $e) { + $this->addresses = []; + $this->interfaces = []; + } + + $settings = DnsSetting::getAll(); + $this->defaultIp = $settings['default_ip'] ?? null; + $this->defaultIpv6 = $settings['default_ipv6'] ?? null; + + $this->flushCachedTableRecords(); + } + + protected function getHeaderActions(): array + { + return [ + Action::make('refresh') + ->label(__('Refresh')) + ->icon('heroicon-o-arrow-path') + ->color('gray') + ->action(fn () => $this->loadAddresses()), + Action::make('addIp') + ->label(__('Add IP')) + ->icon('heroicon-o-plus-circle') + ->color('primary') + ->modalHeading(__('Add IP Address')) + ->modalDescription(__('Assign a new IPv4 or IPv6 address to a network interface.')) + ->modalSubmitActionLabel(__('Add IP')) + ->form([ + TextInput::make('ip') + ->label(__('IP Address')) + ->placeholder('203.0.113.10') + ->live() + ->afterStateUpdated(function (?string $state, callable $set): void { + if (! $state) { + return; + } + + if (str_contains($state, ':')) { + $set('cidr', 64); + + return; + } + + $set('cidr', 24); + }) + ->rule('ip') + ->required(), + TextInput::make('cidr') + ->label(__('CIDR')) + ->numeric() + ->minValue(1) + ->maxValue(128) + ->default(24) + ->required(), + Select::make('interface') + ->label(__('Interface')) + ->options(fn () => $this->getInterfaceOptions()) + ->searchable() + ->required(), + ]) + ->action(function (array $data): void { + try { + $result = $this->getAgent()->ipAdd($data['ip'], (int) $data['cidr'], $data['interface']); + + if ($result['success'] ?? false) { + $message = $result['message'] ?? __('IP added successfully'); + if (! ($result['persistent'] ?? true)) { + $message .= ' '.__('(Persistence not configured for this system)'); + } + + Notification::make() + ->title(__('IP address added')) + ->body($message) + ->success() + ->send(); + $this->dispatch('notificationsSent'); + + $this->loadAddresses(); + $this->dispatch('ip-defaults-updated'); + } else { + throw new Exception($result['error'] ?? __('Failed to add IP address')); + } + } catch (Exception $e) { + Notification::make() + ->title(__('Failed to add IP')) + ->body($e->getMessage()) + ->danger() + ->send(); + $this->dispatch('notificationsSent'); + } + }), + ]; + } + + public function table(Table $table): Table + { + return $table + ->records(fn () => $this->addresses) + ->columns([ + TextColumn::make('ip') + ->label(__('IP Address')) + ->fontFamily('mono') + ->copyable() + ->searchable(), + TextColumn::make('version') + ->label(__('Version')) + ->badge() + ->formatStateUsing(fn ($state): string => (int) $state === 6 ? 'IPv6' : 'IPv4') + ->color(fn ($state): string => (int) $state === 6 ? 'primary' : 'info'), + TextColumn::make('interface') + ->label(__('Interface')) + ->badge() + ->color('gray'), + TextColumn::make('cidr') + ->label(__('CIDR')) + ->alignCenter(), + TextColumn::make('scope') + ->label(__('Scope')) + ->badge() + ->color(fn (?string $state): string => $state === 'global' ? 'success' : 'gray') + ->formatStateUsing(fn (?string $state): string => $state ? ucfirst($state) : '-'), + IconColumn::make('is_primary') + ->label(__('Primary')) + ->boolean(), + TextColumn::make('default') + ->label(__('Default')) + ->getStateUsing(fn (array $record): ?string => $this->getDefaultLabel($record)) + ->badge() + ->color('success') + ->placeholder('-'), + ]) + ->recordActions([ + Action::make('setDefault') + ->label(fn (array $record): string => ($record['version'] ?? 4) === 6 ? __('Set Default IPv6') : __('Set Default IPv4')) + ->icon('heroicon-o-star') + ->color('primary') + ->visible(fn (array $record): bool => ! $this->isDefaultIp($record)) + ->action(fn (array $record) => $this->setDefaultIp($record)), + Action::make('remove') + ->label(__('Remove')) + ->icon('heroicon-o-trash') + ->color('danger') + ->visible(fn (array $record): bool => ! $this->isDefaultIp($record)) + ->requiresConfirmation() + ->modalHeading(__('Remove IP Address')) + ->modalDescription(fn (array $record): string => __('Remove :ip from :iface?', ['ip' => $record['ip'] ?? '-', 'iface' => $record['interface'] ?? '-'])) + ->modalSubmitActionLabel(__('Remove IP')) + ->action(fn (array $record) => $this->removeIp($record)), + ]) + ->striped() + ->paginated(false) + ->emptyStateHeading(__('No IP addresses found')) + ->emptyStateDescription(__('Add an IP address to begin managing assignments.')) + ->emptyStateIcon('heroicon-o-signal'); + } + + protected function getInterfaceOptions(): array + { + if (empty($this->interfaces)) { + $this->loadAddresses(); + } + + $options = []; + foreach ($this->interfaces as $interface) { + $options[$interface] = $interface; + } + + return $options; + } + + protected function getDefaultLabel(array $record): ?string + { + $ip = $record['ip'] ?? null; + if (! $ip) { + return null; + } + + if ($this->defaultIp === $ip) { + return __('Default IPv4'); + } + + if ($this->defaultIpv6 === $ip) { + return __('Default IPv6'); + } + + return null; + } + + protected function isDefaultIp(array $record): bool + { + $ip = $record['ip'] ?? null; + if (! $ip) { + return false; + } + + return $ip === $this->defaultIp || $ip === $this->defaultIpv6; + } + + protected function setDefaultIp(array $record): void + { + $ip = $record['ip'] ?? null; + $version = (int) ($record['version'] ?? 4); + + if (! $ip) { + return; + } + + if ($version === 6) { + DnsSetting::set('default_ipv6', $ip); + $this->defaultIpv6 = $ip; + } else { + DnsSetting::set('default_ip', $ip); + $this->defaultIp = $ip; + } + + DnsSetting::clearCache(); + + Notification::make() + ->title(__('Default IP updated')) + ->success() + ->send(); + + $this->dispatch('ip-defaults-updated'); + } + + protected function removeIp(array $record): void + { + $ip = $record['ip'] ?? null; + $cidr = $record['cidr'] ?? null; + $interface = $record['interface'] ?? null; + + if (! $ip || ! $cidr || ! $interface) { + return; + } + + try { + $result = $this->getAgent()->ipRemove($ip, (int) $cidr, $interface); + if (! ($result['success'] ?? false)) { + throw new Exception($result['error'] ?? __('Failed to remove IP address')); + } + + Notification::make() + ->title(__('IP address removed')) + ->success() + ->send(); + $this->dispatch('notificationsSent'); + + $this->loadAddresses(); + $this->dispatch('ip-defaults-updated'); + } catch (Exception $e) { + Notification::make() + ->title(__('Failed to remove IP')) + ->body($e->getMessage()) + ->danger() + ->send(); + $this->dispatch('notificationsSent'); + } + } +} diff --git a/app/Filament/Admin/Pages/Migration.php b/app/Filament/Admin/Pages/Migration.php new file mode 100644 index 0000000..c55a195 --- /dev/null +++ b/app/Filament/Admin/Pages/Migration.php @@ -0,0 +1,85 @@ +activeTab, ['cpanel', 'whm'], true)) { + $this->activeTab = 'cpanel'; + } + } + + public function updatedActiveTab(string $activeTab): void + { + if (! in_array($activeTab, ['cpanel', 'whm'], true)) { + $this->activeTab = 'cpanel'; + } + } + + protected function getForms(): array + { + return ['migrationForm']; + } + + public function migrationForm(Schema $schema): Schema + { + return $schema->schema([ + Tabs::make(__('Migration Type')) + ->livewireProperty('activeTab') + ->tabs([ + 'cpanel' => Tabs\Tab::make(__('cPanel Migration')) + ->icon('heroicon-o-arrow-down-tray') + ->schema([ + View::make('filament.admin.pages.migration-cpanel-tab'), + ]), + 'whm' => Tabs\Tab::make(__('WHM Migration')) + ->icon('heroicon-o-server-stack') + ->schema([ + View::make('filament.admin.pages.migration-whm-tab'), + ]), + ]), + ]); + } +} diff --git a/app/Filament/Admin/Pages/PhpManager.php b/app/Filament/Admin/Pages/PhpManager.php new file mode 100644 index 0000000..c2a7171 --- /dev/null +++ b/app/Filament/Admin/Pages/PhpManager.php @@ -0,0 +1,294 @@ +loadPhpVersions(); + } + + public function loadPhpVersions(): void + { + $result = $this->getAgent()->send('php.list_versions', []); + + if ($result['success'] ?? false) { + $this->installedVersions = $result['versions'] ?? []; + $this->defaultVersion = $result['default'] ?? null; + } else { + $this->installedVersions = []; + $this->defaultVersion = null; + } + + $allVersions = ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4']; + $installed = array_column($this->installedVersions, 'version'); + $this->availableVersions = array_diff($allVersions, $installed); + } + + protected function getForms(): array + { + return ['statsForm']; + } + + public function statsForm(Schema $schema): Schema + { + return $schema->schema([ + Section::make(__('Warning: Modifying PHP versions can cause server downtime')) + ->description(__('Uninstalling PHP versions may break websites that depend on them. Ensure you understand the impact before making changes.')) + ->icon('heroicon-o-exclamation-triangle') + ->iconColor('warning') + ->collapsed(false) + ->compact(), + Grid::make(['default' => 1, 'sm' => 2]) + ->schema([ + Section::make('PHP '.($this->defaultVersion ?? __('N/A'))) + ->description(__('Default CLI Version')) + ->icon('heroicon-o-command-line') + ->iconColor('primary'), + Section::make((string) count($this->installedVersions)) + ->description(__('Installed Versions')) + ->icon('heroicon-o-squares-2x2') + ->iconColor('success'), + ]), + ]); + } + + public function getAllVersionsData(): array + { + $allVersions = ['8.4', '8.3', '8.2', '8.1', '8.0', '7.4']; + $installedMap = []; + + foreach ($this->installedVersions as $php) { + $installedMap[$php['version']] = $php; + } + + $result = []; + foreach ($allVersions as $version) { + $installed = isset($installedMap[$version]); + $result[] = [ + 'version' => $version, + 'installed' => $installed, + 'fpm_status' => $installed ? ($installedMap[$version]['fpm_status'] ?? 'inactive') : null, + 'is_default' => $version === $this->defaultVersion, + ]; + } + + return $result; + } + + public function table(Table $table): Table + { + return $table + ->records(fn () => $this->getAllVersionsData()) + ->columns([ + TextColumn::make('version') + ->label(__('PHP Version')) + ->formatStateUsing(fn (string $state): string => 'PHP '.$state) + ->icon('heroicon-o-code-bracket') + ->weight('bold') + ->sortable(), + TextColumn::make('installed') + ->label(__('Status')) + ->badge() + ->formatStateUsing(fn (bool $state): string => $state ? __('Installed') : __('Not Installed')) + ->color(fn (bool $state): string => $state ? 'success' : 'gray'), + IconColumn::make('is_default') + ->label(__('Default')) + ->boolean() + ->trueIcon('heroicon-o-check-badge') + ->falseIcon('heroicon-o-minus') + ->trueColor('info') + ->falseColor('gray'), + TextColumn::make('fpm_status') + ->label(__('FPM')) + ->badge() + ->formatStateUsing(fn (?string $state): string => $state === 'active' ? __('Running') : ($state ? __('Stopped') : '-')) + ->color(fn (?string $state): string => $state === 'active' ? 'success' : ($state ? 'danger' : 'gray')), + ]) + ->recordActions([ + Action::make('install') + ->label(__('Install')) + ->icon('heroicon-o-arrow-down-tray') + ->color('primary') + ->size('sm') + ->visible(fn (array $record): bool => ! $record['installed']) + ->action(fn (array $record) => $this->installPhp($record['version'])), + Action::make('reload') + ->label(__('Reload')) + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->size('sm') + ->visible(fn (array $record): bool => $record['installed']) + ->requiresConfirmation() + ->modalHeading(__('Reload PHP-FPM')) + ->modalDescription(fn (array $record): string => __('Are you sure you want to reload PHP :version FPM?', ['version' => $record['version']])) + ->action(fn (array $record) => $this->reloadFpm($record['version'])), + Action::make('uninstall') + ->label(__('Uninstall')) + ->icon('heroicon-o-trash') + ->color('danger') + ->size('sm') + ->visible(fn (array $record): bool => $record['installed'] && ! $record['is_default']) + ->action(fn (array $record) => $this->uninstallPhp($record['version'])), + ]) + ->heading(__('PHP Versions')) + ->description(__('Install, manage and configure PHP versions')) + ->striped() + ->poll('30s'); + } + + public function installPhp(string $version): void + { + $result = $this->getAgent()->send('php.install', ['version' => $version]); + + if ($result['success'] ?? false) { + $this->loadPhpVersions(); + $this->resetTable(); + Notification::make() + ->title(__('PHP :version installed successfully!', ['version' => $version])) + ->success() + ->send(); + } else { + Notification::make() + ->title(__('Failed to install PHP :version', ['version' => $version])) + ->body($result['error'] ?? __('Unknown error')) + ->danger() + ->send(); + } + } + + public function uninstallPhp(string $version): void + { + if ($version === $this->defaultVersion) { + Notification::make() + ->title(__('Cannot uninstall default PHP version')) + ->body(__('Please set a different PHP version as default first')) + ->danger() + ->send(); + + return; + } + + $result = $this->getAgent()->send('php.uninstall', ['version' => $version]); + + if ($result['success'] ?? false) { + $this->loadPhpVersions(); + $this->resetTable(); + Notification::make() + ->title(__('PHP :version uninstalled successfully!', ['version' => $version])) + ->success() + ->send(); + } else { + Notification::make() + ->title(__('Failed to uninstall PHP :version', ['version' => $version])) + ->body($result['error'] ?? __('Unknown error')) + ->danger() + ->send(); + } + } + + public function setDefaultPhp(string $version): void + { + $result = $this->getAgent()->send('php.set_default', ['version' => $version]); + + if ($result['success'] ?? false) { + $this->defaultVersion = $version; + $this->loadPhpVersions(); + $this->resetTable(); + Notification::make() + ->title(__('PHP :version set as default', ['version' => $version])) + ->success() + ->send(); + } else { + Notification::make() + ->title(__('Failed to set default PHP version')) + ->body($result['error'] ?? __('Unknown error')) + ->danger() + ->send(); + } + } + + public function reloadFpm(string $version): void + { + $result = $this->getAgent()->send('php.reload_fpm', ['version' => $version]); + + if ($result['success'] ?? false) { + $this->loadPhpVersions(); + $this->resetTable(); + Notification::make() + ->title(__('PHP :version FPM reloaded', ['version' => $version])) + ->success() + ->send(); + } else { + Notification::make() + ->title(__('Failed to reload PHP-FPM')) + ->body($result['error'] ?? __('Unknown error')) + ->danger() + ->send(); + } + } + + protected function getHeaderActions(): array + { + return [ + $this->getTourAction(), + ]; + } +} diff --git a/app/Filament/Admin/Pages/Security.php b/app/Filament/Admin/Pages/Security.php new file mode 100644 index 0000000..921cb83 --- /dev/null +++ b/app/Filament/Admin/Pages/Security.php @@ -0,0 +1,2454 @@ +agent ??= new AgentClient; + } + + protected function normalizeTabName(?string $tab): string + { + return match ($tab) { + 'overview', 'firewall', 'fail2ban', 'antivirus', 'ssh', 'scanner' => $tab, + default => 'overview', + }; + } + + public function setTab(string $tab): void + { + $this->activeTab = $this->normalizeTabName($tab); + + if ($this->activeTab === 'fail2ban') { + $this->loadFail2banStatus(); + } + + if ($this->activeTab === 'antivirus') { + $this->loadClamavStatus(); + $this->loadClamScanResults(); + } + + if ($this->activeTab === 'scanner') { + $this->checkScannerToolStatus(); + $this->loadLastScans(); + } + } + + public function mount(): void + { + $this->activeTab = $this->normalizeTabName($this->activeTab); + $this->loadFirewallStatus(); + $this->loadFail2banStatusLight(); + $this->loadClamavStatusLight(); + $this->loadSshSettings(); + + $this->data = [ + 'selectedWpSiteId' => null, + 'selectedClamUser' => null, + ]; + + if ($this->activeTab === 'fail2ban') { + $this->loadFail2banStatus(); + } elseif ($this->activeTab === 'antivirus') { + $this->loadClamavStatus(); + $this->loadClamScanResults(); + } elseif ($this->activeTab === 'scanner') { + $this->checkScannerToolStatus(); + $this->loadLastScans(); + } + } + + #[On('refresh-security-data')] + public function refreshSecurityData(): void + { + $this->loadFail2banStatus(); + $this->loadClamavStatus(); + } + + protected function getForms(): array + { + return [ + 'securityForm', + ]; + } + + public function table(Table $table): Table + { + return $table + ->records(fn () => $this->firewallRules) + ->columns([ + TextColumn::make('number') + ->label(__('#')) + ->sortable() + ->width('60px'), + TextColumn::make('action') + ->label(__('Action')) + ->badge() + ->color(fn (string $state): string => match (strtoupper($state)) { + 'ALLOW' => 'success', + 'DENY' => 'danger', + 'LIMIT' => 'warning', + default => 'gray', + }) + ->formatStateUsing(fn (string $state): string => strtoupper($state)), + TextColumn::make('to') + ->label(__('To')) + ->default('-'), + TextColumn::make('from') + ->label(__('From')) + ->default(__('Anywhere')) + ->formatStateUsing(fn (?string $state): string => $state ?: __('Anywhere')), + TextColumn::make('direction') + ->label(__('Direction')) + ->badge() + ->color('gray'), + ]) + ->recordAction(null) + ->recordUrl(null) + ->striped() + ->emptyStateHeading(__('No firewall rules')) + ->emptyStateDescription(__('Use the buttons above to add rules.')) + ->emptyStateIcon('heroicon-o-shield-exclamation') + ->actions([ + TableAction::make('delete') + ->label(__('Delete')) + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->action(fn (array $record) => $this->deleteRule($record['number'] ?? null)), + ]); + } + + public function securityForm(Schema $schema): Schema + { + return $schema + ->schema([ + View::make('filament.admin.components.security-tabs-nav'), + ...$this->getTabContent(), + ]); + } + + protected function getTabContent(): array + { + return match ($this->activeTab) { + 'overview' => $this->overviewTabContent(), + 'firewall' => $this->firewallTabContent(), + 'fail2ban' => $this->fail2banTabContent(), + 'antivirus' => $this->antivirusTabContent(), + 'ssh' => $this->sshTabContent(), + 'scanner' => $this->scannerTabContent(), + default => $this->overviewTabContent(), + }; + } + + protected function overviewTabContent(): array + { + return [ + Grid::make(['default' => 1, 'sm' => 3]) + ->schema([ + Section::make($this->firewallEnabled ? __('Active') : __('Inactive')) + ->description(__('Firewall')) + ->icon('heroicon-o-shield-check') + ->iconColor($this->firewallEnabled ? 'success' : 'danger'), + Section::make($this->totalBanned !== null ? (string) $this->totalBanned : __('N/A')) + ->description(__('IPs Banned')) + ->icon('heroicon-o-lock-closed') + ->iconColor($this->fail2banRunning ? 'success' : 'danger'), + Section::make((string) count($this->recentThreats)) + ->description(__('Threats Detected')) + ->icon('heroicon-o-bug-ant') + ->iconColor($this->clamavInstalled ? 'success' : 'gray'), + ]), + Section::make(__('Quick Actions')) + ->icon('heroicon-o-bolt') + ->schema([ + FormActions::make([ + FormAction::make('installFirewall') + ->label(__('Install Firewall')) + ->action('installFirewall') + ->visible(fn () => ! $this->firewallInstalled), + FormAction::make('installFail2ban') + ->label(__('Install Fail2ban')) + ->action('installFail2ban') + ->visible(fn () => ! $this->fail2banInstalled), + FormAction::make('installClamav') + ->label(__('Install Antivirus')) + ->action('installClamav') + ->visible(fn () => ! $this->clamavInstalled), + ])->visible(fn () => ! $this->firewallInstalled || ! $this->fail2banInstalled || ! $this->clamavInstalled), + Text::make(__('All essential security tools are installed.')) + ->visible(fn () => $this->firewallInstalled && $this->fail2banInstalled), + ]), + Section::make(__('Recent Audit Logs')) + ->icon('heroicon-o-clipboard-document-list') + ->schema([ + EmbeddedTable::make(\App\Filament\Admin\Widgets\Security\AuditLogsTable::class), + ]), + ]; + } + + protected function firewallTabContent(): array + { + return [ + // Not installed state + Section::make() + ->schema([ + Text::make(__('UFW Firewall is not installed.')), + FormActions::make([ + FormAction::make('installFirewall') + ->label(__('Install Firewall')) + ->action('installFirewall'), + ])->alignment(Alignment::Center), + ]) + ->visible(fn () => ! $this->firewallInstalled), + // Installed state + Group::make([ + Section::make(__('Firewall Status')) + ->icon('heroicon-o-shield-check') + ->iconColor($this->firewallEnabled ? 'success' : 'danger') + ->schema([ + Grid::make(['default' => 1, 'sm' => 2, 'lg' => 4]) + ->schema([ + Section::make($this->firewallEnabled ? __('ACTIVE') : __('INACTIVE')) + ->description(__('Status')) + ->icon('heroicon-o-signal') + ->iconColor($this->firewallEnabled ? 'success' : 'danger'), + Section::make(strtoupper($this->defaultIncoming)) + ->description(__('Default Incoming')) + ->icon('heroicon-o-arrow-down-circle') + ->iconColor($this->defaultIncoming === 'deny' ? 'success' : 'warning'), + Section::make(strtoupper($this->defaultOutgoing)) + ->description(__('Default Outgoing')) + ->icon('heroicon-o-arrow-up-circle') + ->iconColor($this->defaultOutgoing === 'allow' ? 'success' : 'warning'), + Section::make((string) count($this->firewallRules)) + ->description(__('Active Rules')) + ->icon('heroicon-o-queue-list') + ->iconColor('gray'), + ]), + FormActions::make([ + FormAction::make('toggleFirewall') + ->label(fn () => $this->firewallEnabled ? __('Disable Firewall') : __('Enable Firewall')) + ->icon(fn () => $this->firewallEnabled ? 'heroicon-o-x-circle' : 'heroicon-o-shield-check') + ->color(fn () => $this->firewallEnabled ? 'danger' : 'success') + ->action('toggleFirewall'), + FormAction::make('reloadFirewall') + ->label(__('Reload')) + ->icon('heroicon-o-arrow-path') + ->color('gray') + ->outlined() + ->action('reloadFirewall'), + FormAction::make('resetFirewall') + ->label(__('Reset All')) + ->icon('heroicon-o-trash') + ->color('danger') + ->outlined() + ->action('resetFirewall'), + ]), + ]), + Section::make(__('Add Rule')) + ->icon('heroicon-o-plus-circle') + ->schema([ + FormActions::make([ + FormAction::make('allowPort') + ->label(__('Allow Port')) + ->icon('heroicon-o-plus-circle') + ->color('success') + ->action('openAllowPort'), + FormAction::make('denyPort') + ->label(__('Block Port')) + ->icon('heroicon-o-x-circle') + ->color('danger') + ->action('openDenyPort'), + FormAction::make('allowIp') + ->label(__('Allow IP')) + ->icon('heroicon-o-check-circle') + ->color('success') + ->action('openAllowIp'), + FormAction::make('denyIp') + ->label(__('Block IP')) + ->icon('heroicon-o-no-symbol') + ->color('danger') + ->action('openDenyIp'), + FormAction::make('allowService') + ->label(__('Allow Service')) + ->icon('heroicon-o-server') + ->color('info') + ->action('openAllowService'), + FormAction::make('limitPort') + ->label(__('Rate Limit')) + ->icon('heroicon-o-clock') + ->color('warning') + ->action('openLimitPort'), + ]), + ]), + Section::make(__('Firewall Rules')) + ->icon('heroicon-o-queue-list') + ->schema([ + EmbeddedTable::make(), + ]), + Section::make(__('Quick Tips')) + ->icon('heroicon-o-light-bulb') + ->collapsible() + ->collapsed() + ->schema([ + Text::make(__('Allow Port: Opens a port for all incoming connections (e.g., 80 for web, 443 for HTTPS)')), + Text::make(__('Block IP: Denies all traffic from a specific IP address or subnet')), + Text::make(__('Rate Limit: Limits connections to prevent brute-force attacks (6 connections in 30 seconds)')), + Text::make(__('Default Policy: Controls what happens to traffic that doesn\'t match any rule')), + Text::make(__('Important: Always ensure SSH (port 22) is allowed before enabling the firewall!')), + ]), + ])->visible(fn () => $this->firewallInstalled), + ]; + } + + protected function fail2banTabContent(): array + { + return [ + // Not installed state + Section::make() + ->schema([ + Text::make(__('Fail2ban is not installed.')), + FormActions::make([ + FormAction::make('installFail2ban') + ->label(__('Install Fail2ban')) + ->action('installFail2ban'), + ])->alignment(Alignment::Center), + ]) + ->visible(fn () => ! $this->fail2banInstalled), + // Installed state + Group::make([ + Section::make(__('Fail2ban Status')) + ->icon('heroicon-o-lock-closed') + ->headerActions([ + FormAction::make('fail2banStatus') + ->label(fn () => $this->fail2banRunning ? __('Running') : __('Stopped')) + ->color(fn () => $this->fail2banRunning ? 'success' : 'danger') + ->badge(), + FormAction::make('toggleFail2ban') + ->label(fn () => $this->fail2banRunning ? __('Stop') : __('Start')) + ->color(fn () => $this->fail2banRunning ? 'danger' : 'success') + ->size('sm') + ->action(fn () => $this->fail2banRunning ? $this->stopFail2ban() : $this->startFail2ban()), + ]) + ->schema([ + Grid::make(['default' => 1, 'md' => 3]) + ->schema([ + TextInput::make('maxRetry') + ->label(__('Max Retry')) + ->numeric() + ->minValue(1) + ->maxValue(20), + TextInput::make('banTime') + ->label(__('Ban Time (seconds)')) + ->numeric() + ->minValue(60), + TextInput::make('findTime') + ->label(__('Find Time (seconds)')) + ->numeric() + ->minValue(60), + ]), + FormActions::make([ + FormAction::make('saveFail2banSettings') + ->label(__('Save Settings')) + ->action('saveFail2banSettings'), + ]), + ]), + Section::make(__('Protection Modules')) + ->icon('heroicon-o-shield-exclamation') + ->description(__('Enable or disable protection modules for different services.')) + ->schema([ + EmbeddedTable::make(JailsTable::class, ['jails' => $this->availableJails]), + ]), + Section::make(__('Banned IPs').' ('.($this->totalBanned ?? __('N/A')).')') + ->icon('heroicon-o-no-symbol') + ->schema([ + EmbeddedTable::make(BannedIpsTable::class, ['jails' => $this->jails]), + ]) + ->visible(fn () => ($this->totalBanned ?? 0) > 0), + ])->visible(fn () => $this->fail2banInstalled), + ]; + } + + protected function antivirusTabContent(): array + { + return [ + // Warning for non-installed + Section::make(__('Memory Requirements')) + ->icon('heroicon-o-exclamation-triangle') + ->iconColor('warning') + ->description(__('ClamAV requires significant memory (~500MB+). Only install if your server has at least 2GB RAM available.')) + ->visible(fn () => ! $this->clamavInstalled), + // Not installed state + Section::make() + ->schema([ + Text::make(__('ClamAV Antivirus is not installed.')), + FormActions::make([ + FormAction::make('installClamav') + ->label(__('Install ClamAV')) + ->action('installClamav') + ->requiresConfirmation() + ->modalDescription(__('ClamAV uses significant memory. Are you sure you want to install it?')), + ])->alignment(Alignment::Center), + ]) + ->visible(fn () => ! $this->clamavInstalled), + // Installed state + Group::make([ + Section::make(__('Resource Warning')) + ->icon('heroicon-o-exclamation-triangle') + ->iconColor('warning') + ->description(__('ClamAV uses significant memory (~500MB+) and CPU resources. Running the daemon or real-time protection on low-resource servers may impact performance. Consider using on-demand scanning instead.')), + Section::make(__('ClamAV Status')) + ->icon('heroicon-o-shield-check') + ->headerActions([ + FormAction::make('daemonStatus') + ->label(fn () => __('Daemon').' '.($this->clamavRunning ? __('Running') : __('Stopped'))) + ->color(fn () => $this->clamavRunning ? 'success' : 'gray') + ->badge(), + FormAction::make('toggleClamav') + ->label(fn () => $this->clamavRunning ? __('Stop') : __('Start')) + ->color(fn () => $this->clamavRunning ? 'danger' : 'success') + ->size('sm') + ->action(fn () => $this->clamavRunning ? $this->stopClamav() : $this->startClamav()) + ->requiresConfirmation(fn () => ! $this->clamavRunning) + ->modalDescription(__('Starting ClamAV daemon uses ~500MB RAM. Continue?')), + FormAction::make('updateSignatures') + ->label(__('Update Signatures')) + ->color('gray') + ->size('sm') + ->action('updateSignatures'), + ]) + ->schema([ + Grid::make(['default' => 1, 'md' => 3]) + ->schema([ + Section::make($this->clamavVersion ?: __('Unknown')) + ->description(__('Version')) + ->icon('heroicon-o-code-bracket') + ->iconColor('gray'), + Section::make(number_format($this->signatureCount)) + ->description(__('Signatures')) + ->icon('heroicon-o-document-text') + ->iconColor('gray'), + Section::make($this->lastUpdate ?: __('Unknown')) + ->description(__('Last Update')) + ->icon('heroicon-o-clock') + ->iconColor('gray'), + ]), + Grid::make(['default' => 1, 'md' => 2]) + ->schema([ + Section::make(__('Real-time Protection')) + ->description(__('Monitors /home for new PHP, HTML, JS files')) + ->icon($this->realtimeRunning ? 'heroicon-o-shield-check' : 'heroicon-o-shield-exclamation') + ->iconColor($this->realtimeRunning ? 'success' : 'gray') + ->schema([ + FormActions::make([ + FormAction::make('toggleRealtime') + ->label(fn () => $this->realtimeRunning ? __('Disable') : __('Enable')) + ->color(fn () => $this->realtimeRunning ? 'danger' : 'success') + ->size('sm') + ->action('toggleRealtime'), + ]), + ]), + Section::make(__('Database Mode')) + ->description(fn () => $this->clamavLightMode + ? __('Web hosting only: PHP, email, scripts (~50K sigs)') + : __('Full database: all malware signatures')) + ->icon($this->clamavLightMode ? 'heroicon-o-bolt' : 'heroicon-o-circle-stack') + ->iconColor($this->clamavLightMode ? 'warning' : 'gray') + ->schema([ + FormActions::make([ + FormAction::make('toggleLightMode') + ->label(fn () => $this->clamavLightMode ? __('Full') : __('Light')) + ->color(fn () => $this->clamavLightMode ? 'warning' : 'gray') + ->size('sm') + ->action('toggleLightMode') + ->requiresConfirmation() + ->modalDescription(fn () => $this->clamavLightMode + ? __('Switch to full database? This will download ~400MB of signatures.') + : __('Switch to lightweight mode? This reduces signatures to web hosting essentials only.')), + ]), + ]), + ]), + ]), + Section::make(__('On-Demand Scanner')) + ->icon('heroicon-o-magnifying-glass') + ->description(__('Manually scan user directories or the entire server for malware and threats.')) + ->schema([ + Grid::make(['default' => 1, 'md' => 2]) + ->schema([ + Section::make(__('Scan User Directory')) + ->description(__('Scan a specific user\'s home directory for malware.')) + ->icon('heroicon-o-user') + ->iconColor('gray') + ->schema([ + Select::make('selectedClamUser') + ->label(__('Select User')) + ->options(fn () => $this->getClamScanUsers()) + ->placeholder(__('Select a user to scan')) + ->searchable() + ->live(), + FormActions::make([ + FormAction::make('runClamScanUser') + ->label(fn () => $this->isScanning && $this->currentScan === 'clamav' ? __('Scanning...') : __('Scan User')) + ->icon('heroicon-o-play') + ->action('runClamScanUser') + ->disabled(fn () => $this->isScanning || ! $this->selectedClamUser), + ]), + ]), + Section::make(__('Server-Wide Scan')) + ->description(__('Scan all user directories (/home). This may take a long time.')) + ->icon('heroicon-o-server') + ->iconColor('warning') + ->schema([ + Text::make(__('Server-wide scans can take 30+ minutes depending on data size.')), + FormActions::make([ + FormAction::make('runClamScanServer') + ->label(fn () => $this->isScanning && $this->currentScan === 'clamav' ? __('Scanning...') : __('Scan All Users')) + ->icon('heroicon-o-server') + ->color('warning') + ->action('runClamScanServer') + ->disabled(fn () => $this->isScanning) + ->requiresConfirmation() + ->modalDescription(__('This will scan all user directories and may take a long time. Continue?')), + ]), + ]), + ]), + Text::make($this->lastClamScan ? __('Last scan:').' '.$this->lastClamScan : '') + ->visible(fn () => (bool) $this->lastClamScan), + ]), + Section::make(__('Scan Output')) + ->icon('heroicon-o-command-line') + ->collapsible() + ->schema(fn () => $this->buildClamScanOutputSchema()) + ->visible(fn () => $this->isScanning && $this->currentScan === 'clamav' || ! empty($this->clamScanResults['raw_output'] ?? '')), + Section::make(__('Scan Results')) + ->icon('heroicon-o-document-chart-bar') + ->collapsible() + ->schema(fn () => $this->buildClamavScanResultsSchema()) + ->visible(fn () => ! empty($this->clamScanResults)), + Section::make(__('Quarantined Files').' ('.count($this->quarantinedFiles).')') + ->icon('heroicon-o-archive-box') + ->schema([ + EmbeddedTable::make(QuarantinedFilesTable::class, ['files' => $this->quarantinedFiles]), + ]) + ->visible(fn () => count($this->quarantinedFiles) > 0), + Section::make(__('Recent Threats')) + ->icon('heroicon-o-exclamation-triangle') + ->schema([ + EmbeddedTable::make(ThreatsTable::class, ['threats' => $this->recentThreats]), + ]) + ->visible(fn () => count($this->recentThreats) > 0), + ])->visible(fn () => $this->clamavInstalled), + ]; + } + + protected function sshTabContent(): array + { + return [ + // Current Configuration - 3 widgets on top + Grid::make(['default' => 1, 'sm' => 3]) + ->schema([ + Section::make($this->sshPasswordAuth ? __('Enabled') : __('Disabled')) + ->description(__('Password Auth')) + ->icon('heroicon-o-key') + ->iconColor($this->sshPasswordAuth ? 'warning' : 'success'), + Section::make($this->sshPubkeyAuth ? __('Enabled') : __('Disabled')) + ->description(__('Public Key Auth')) + ->icon('heroicon-o-finger-print') + ->iconColor($this->sshPubkeyAuth ? 'success' : 'danger'), + Section::make((string) $this->sshPort) + ->description(__('SSH Port')) + ->icon('heroicon-o-server') + ->iconColor($this->sshPort != 22 ? 'success' : 'gray'), + ]), + Section::make(__('Important Notice')) + ->icon('heroicon-o-exclamation-triangle') + ->iconColor('warning') + ->description(__('Changing SSH settings can lock you out of the server. Ensure you have console access or an active SSH session before disabling password authentication.')), + Section::make(__('SSH Authentication Settings')) + ->icon('heroicon-o-command-line') + ->schema([ + Toggle::make('sshPasswordAuth') + ->label(__('Password Authentication')) + ->helperText(__('Allow users to log in using passwords. Disable for key-only access.')), + Toggle::make('sshPubkeyAuth') + ->label(__('Public Key Authentication')) + ->helperText(__('Allow users to log in using SSH keys. Recommended for security.')), + TextInput::make('sshPort') + ->label(__('SSH Port')) + ->numeric() + ->minValue(1) + ->maxValue(65535) + ->helperText(__('Default is 22. Changing port can help reduce automated attacks.')) + ->columnSpan(1), + FormActions::make([ + FormAction::make('saveSshSettings') + ->label(__('Save SSH Settings')) + ->action('saveSshSettings') + ->requiresConfirmation() + ->modalDescription(__('Are you sure? This will restart the SSH service immediately.')), + ]), + ]), + Section::make(__('Security Recommendations')) + ->icon('heroicon-o-light-bulb') + ->schema(fn () => $this->buildSshRecommendationsSchema()), + ]; + } + + protected function scannerTabContent(): array + { + return [ + Grid::make(['default' => 1, 'md' => 3]) + ->schema([ + // Lynis + Section::make(__('Lynis')) + ->icon('heroicon-o-shield-check') + ->description(__('System security auditing tool. Checks configurations, permissions, and hardening settings.')) + ->headerActions([ + FormAction::make('lynisStatus') + ->label(fn () => $this->lynisInstalled ? __('Installed') : __('Not Installed')) + ->color(fn () => $this->lynisInstalled ? 'success' : 'danger') + ->badge(), + ]) + ->schema([ + Group::make([ + Text::make($this->lynisVersion) + ->visible(fn () => $this->lynisInstalled && $this->lynisVersion), + Text::make($this->lastLynisScan ? __('Last:').' '.$this->lastLynisScan : '') + ->visible(fn () => $this->lynisInstalled && $this->lastLynisScan), + ])->visible(fn () => $this->lynisInstalled), + + FormActions::make([ + FormAction::make('runLynisScan') + ->label(fn () => $this->isScanning && $this->currentScan === 'lynis' ? __('Scanning...') : __('Run System Audit')) + ->icon('heroicon-o-play') + ->color('success') + ->action('runLynisScan') + ->disabled(fn () => $this->isScanning), + ])->visible(fn () => $this->lynisInstalled), + + FormActions::make([ + FormAction::make('installLynis') + ->label(__('Install Lynis')) + ->icon('heroicon-o-arrow-down-tray') + ->action('installLynis'), + ])->visible(fn () => ! $this->lynisInstalled), + ]), + + // WPScan + Section::make(__('WPScan')) + ->icon('heroicon-o-globe-alt') + ->description(__('WordPress vulnerability scanner. Checks for vulnerable plugins, themes, and core issues.')) + ->headerActions([ + FormAction::make('wpscanStatus') + ->label(fn () => $this->wpscanInstalled ? __('Installed') : __('Not Installed')) + ->color(fn () => $this->wpscanInstalled ? 'success' : 'danger') + ->badge(), + ]) + ->schema([ + Group::make([ + Text::make($this->wpscanVersion) + ->visible(fn () => $this->wpscanInstalled && $this->wpscanVersion), + Text::make($this->lastWpscanScan ? __('Last:').' '.$this->lastWpscanScan : '') + ->visible(fn () => $this->wpscanInstalled && $this->lastWpscanScan), + + Select::make('selectedWpSiteId') + ->label(__('WordPress Site')) + ->options(fn () => $this->getLocalWordPressSites()) + ->placeholder(__('Select a WordPress site')) + ->searchable() + ->live() + ->visible(fn () => count($this->getLocalWordPressSites()) > 0), + + Text::make(__('No WordPress sites found')) + ->visible(fn () => count($this->getLocalWordPressSites()) === 0), + ])->visible(fn () => $this->wpscanInstalled), + + FormActions::make([ + FormAction::make('runWpscanOnSite') + ->label(fn () => $this->isScanning && $this->currentScan === 'wpscan' ? __('Scanning...') : __('Scan WordPress Site')) + ->icon('heroicon-o-play') + ->color('info') + ->action('runWpscanOnSite') + ->disabled(fn () => $this->isScanning || ! $this->selectedWpSiteId), + ])->visible(fn () => $this->wpscanInstalled && count($this->getLocalWordPressSites()) > 0), + + FormActions::make([ + FormAction::make('installWpscan') + ->label(__('Install WPScan')) + ->icon('heroicon-o-arrow-down-tray') + ->action('installWpscan'), + ])->visible(fn () => ! $this->wpscanInstalled), + ]), + + // Nikto + Section::make(__('Nikto')) + ->icon('heroicon-o-server') + ->description(__('Web server scanner. Finds server misconfigurations and known vulnerabilities on localhost.')) + ->headerActions([ + FormAction::make('niktoStatus') + ->label(fn () => $this->niktoInstalled ? __('Installed') : __('Not Installed')) + ->color(fn () => $this->niktoInstalled ? 'success' : 'danger') + ->badge(), + ]) + ->schema([ + Group::make([ + Text::make($this->niktoVersion) + ->visible(fn () => $this->niktoInstalled && $this->niktoVersion), + Text::make($this->lastNiktoScan ? __('Last:').' '.$this->lastNiktoScan : '') + ->visible(fn () => $this->niktoInstalled && $this->lastNiktoScan), + ])->visible(fn () => $this->niktoInstalled), + + FormActions::make([ + FormAction::make('runNiktoScan') + ->label(fn () => $this->isScanning && $this->currentScan === 'nikto' ? __('Scanning...') : __('Scan Local Server')) + ->icon('heroicon-o-play') + ->color('warning') + ->action('runNiktoScan') + ->disabled(fn () => $this->isScanning), + ])->visible(fn () => $this->niktoInstalled), + + FormActions::make([ + FormAction::make('installNikto') + ->label(__('Install Nikto')) + ->icon('heroicon-o-arrow-down-tray') + ->action('installNikto'), + ])->visible(fn () => ! $this->niktoInstalled), + ]), + ]), + + // Lynis Results + Section::make(__('Lynis System Audit Results')) + ->icon('heroicon-o-document-chart-bar') + ->schema([ + Grid::make(['default' => 1, 'md' => 3]) + ->schema([ + Section::make((string) ($this->lynisResults['hardening_index'] ?? 0)) + ->description(__('Hardening Index')) + ->icon('heroicon-o-shield-check') + ->iconColor(fn () => ($this->lynisResults['hardening_index'] ?? 0) >= 70 ? 'success' : (($this->lynisResults['hardening_index'] ?? 0) >= 50 ? 'warning' : 'danger')), + Section::make((string) count($this->lynisResults['warnings'] ?? [])) + ->description(__('Warnings')) + ->icon('heroicon-o-exclamation-triangle') + ->iconColor('warning'), + Section::make((string) count($this->lynisResults['suggestions'] ?? [])) + ->description(__('Suggestions')) + ->icon('heroicon-o-light-bulb') + ->iconColor('primary'), + ]), + Section::make(__('Warnings')) + ->icon('heroicon-o-exclamation-triangle') + ->iconColor('warning') + ->collapsible() + ->schema([ + EmbeddedTable::make(LynisResultsTable::class, ['results' => $this->lynisResults, 'type' => 'warnings']), + ]) + ->visible(fn () => ! empty($this->lynisResults['warnings'] ?? [])), + Section::make(__('Suggestions')) + ->icon('heroicon-o-light-bulb') + ->iconColor('info') + ->collapsible() + ->schema([ + EmbeddedTable::make(LynisResultsTable::class, ['results' => $this->lynisResults, 'type' => 'suggestions']), + ]) + ->visible(fn () => ! empty($this->lynisResults['suggestions'] ?? [])), + ]) + ->visible(fn () => ! empty($this->lynisResults)), + + // WPScan Results + Section::make(__('WPScan Results')) + ->icon('heroicon-o-globe-alt') + ->schema([ + Section::make('WordPress '.($this->wpscanResults['version']['number'] ?? __('Unknown'))) + ->icon('heroicon-o-code-bracket') + ->iconColor('info') + ->visible(fn () => isset($this->wpscanResults['version']['number'])), + EmbeddedTable::make(WpscanResultsTable::class, ['results' => $this->wpscanResults]), + ]) + ->visible(fn () => ! empty($this->wpscanResults) && ! isset($this->wpscanResults['error'])), + + // Nikto Results + Section::make(__('Nikto Scan Results')) + ->icon('heroicon-o-server') + ->schema([ + Section::make(__('Vulnerabilities')) + ->icon('heroicon-o-exclamation-triangle') + ->iconColor('danger') + ->collapsible() + ->schema([ + EmbeddedTable::make(NiktoResultsTable::class, ['results' => $this->niktoResults, 'type' => 'vulnerabilities']), + ]) + ->visible(fn () => ! empty($this->niktoResults['vulnerabilities'] ?? [])), + Section::make(__('Information')) + ->icon('heroicon-o-information-circle') + ->iconColor('info') + ->collapsible() + ->schema([ + EmbeddedTable::make(NiktoResultsTable::class, ['results' => $this->niktoResults, 'type' => 'info']), + ]) + ->visible(fn () => ! empty($this->niktoResults['info'] ?? [])), + ]) + ->visible(fn () => ! empty($this->niktoResults)), + + // Scan Output + Section::make(__('Scan Output')) + ->icon('heroicon-o-command-line') + ->schema(fn () => $this->buildScanOutputSchema()) + ->visible(fn () => (bool) $this->scanOutput), + ]; + } + + protected function getHeaderActions(): array + { + return [ + $this->getTourAction(), + ]; + } + + // Load methods + protected function loadFirewallStatus(): void + { + try { + $result = $this->getAgent()->send('ufw.status'); + $this->firewallInstalled = true; + $this->firewallEnabled = $result['active'] ?? false; + $this->defaultIncoming = $result['default_incoming'] ?? 'deny'; + $this->defaultOutgoing = $result['default_outgoing'] ?? 'allow'; + $this->firewallStatusText = $result['status_text'] ?? ''; + + $rulesResult = $this->getAgent()->send('ufw.list_rules'); + $this->firewallRules = $rulesResult['rules'] ?? []; + } catch (Exception $e) { + $this->firewallInstalled = false; + } + } + + protected function loadFail2banStatusLight(): void + { + try { + $result = $this->getAgent()->send('fail2ban.status_light'); + $this->fail2banInstalled = $result['installed'] ?? false; + $this->fail2banRunning = $result['running'] ?? false; + $this->fail2banVersion = $result['version'] ?? 'Unknown'; + $this->jails = []; + $this->availableJails = []; + $this->totalBanned = null; + } catch (Exception $e) { + $this->fail2banInstalled = false; + $this->fail2banRunning = false; + $this->fail2banVersion = ''; + $this->jails = []; + $this->availableJails = []; + $this->totalBanned = null; + } + } + + protected function loadFail2banStatus(): void + { + try { + $result = $this->getAgent()->send('fail2ban.status'); + $this->fail2banInstalled = $result['installed'] ?? false; + + if ($this->fail2banInstalled) { + $this->fail2banRunning = $result['running'] ?? false; + $this->fail2banVersion = $result['version'] ?? 'Unknown'; + $this->jails = $result['jails'] ?? []; + $this->totalBanned = $result['total_banned'] ?? 0; + $this->maxRetry = $result['max_retry'] ?? 5; + $this->banTime = $result['ban_time'] ?? 600; + $this->findTime = $result['find_time'] ?? 600; + + $jailsResult = $this->getAgent()->send('fail2ban.list_jails'); + $this->availableJails = $jailsResult['jails'] ?? []; + } else { + $this->fail2banRunning = false; + $this->fail2banVersion = ''; + $this->jails = []; + $this->availableJails = []; + $this->totalBanned = null; + } + } catch (Exception $e) { + $this->fail2banInstalled = false; + $this->fail2banRunning = false; + $this->fail2banVersion = ''; + $this->jails = []; + $this->availableJails = []; + $this->totalBanned = null; + } + } + + protected function loadClamavStatusLight(): void + { + try { + $result = $this->getAgent()->send('clamav.status_light'); + $this->clamavInstalled = $result['installed'] ?? false; + $this->clamavRunning = $result['running'] ?? false; + $this->clamavVersion = $result['version'] ?? 'Unknown'; + $this->realtimeEnabled = $result['realtime_enabled'] ?? false; + $this->realtimeRunning = $result['realtime_running'] ?? false; + $this->clamavLightMode = $result['light_mode'] ?? false; + + $this->signatureCount = 0; + $this->lastUpdate = ''; + $this->recentThreats = []; + $this->quarantinedFiles = []; + $this->signatureDatabases = []; + } catch (Exception $e) { + $this->clamavInstalled = false; + $this->clamavRunning = false; + $this->clamavVersion = ''; + $this->realtimeEnabled = false; + $this->realtimeRunning = false; + $this->clamavLightMode = false; + + $this->signatureCount = 0; + $this->lastUpdate = ''; + $this->recentThreats = []; + $this->quarantinedFiles = []; + $this->signatureDatabases = []; + } + } + + protected function loadClamavStatus(): void + { + try { + $result = $this->getAgent()->send('clamav.status'); + $this->clamavInstalled = $result['installed'] ?? false; + + if ($this->clamavInstalled) { + $this->clamavRunning = $result['running'] ?? false; + $this->clamavVersion = $result['version'] ?? 'Unknown'; + $this->signatureCount = $result['signature_count'] ?? 0; + $this->lastUpdate = $result['last_update'] ?? ''; + $this->recentThreats = $result['recent_threats'] ?? []; + $this->quarantinedFiles = $result['quarantined_files'] ?? []; + $this->realtimeEnabled = $result['realtime_enabled'] ?? false; + $this->realtimeRunning = $result['realtime_running'] ?? false; + $this->clamavLightMode = $result['light_mode'] ?? false; + $this->signatureDatabases = $result['signature_databases'] ?? []; + } else { + $this->clamavRunning = false; + $this->clamavVersion = ''; + $this->signatureCount = 0; + $this->lastUpdate = ''; + $this->recentThreats = []; + $this->quarantinedFiles = []; + $this->realtimeEnabled = false; + $this->realtimeRunning = false; + $this->clamavLightMode = false; + $this->signatureDatabases = []; + } + } catch (Exception $e) { + $this->clamavInstalled = false; + $this->clamavRunning = false; + $this->clamavVersion = ''; + $this->signatureCount = 0; + $this->lastUpdate = ''; + $this->recentThreats = []; + $this->quarantinedFiles = []; + $this->realtimeEnabled = false; + $this->realtimeRunning = false; + $this->clamavLightMode = false; + $this->signatureDatabases = []; + } + } + + protected function loadSshSettings(): void + { + try { + $result = $this->getAgent()->send('ssh.get_settings'); + if ($result['success'] ?? false) { + $this->sshPasswordAuth = $result['password_auth'] ?? false; + $this->sshPubkeyAuth = $result['pubkey_auth'] ?? true; + $this->sshPort = $result['port'] ?? 22; + } + } catch (Exception $e) { + // Use defaults + } + } + + public function saveSshSettings(): void + { + try { + if (! $this->sshPasswordAuth && ! $this->sshPubkeyAuth) { + Notification::make() + ->title(__('Invalid Configuration')) + ->body(__('At least one authentication method must be enabled.')) + ->danger() + ->send(); + + return; + } + + $result = $this->getAgent()->send('ssh.save_settings', [ + 'password_auth' => $this->sshPasswordAuth, + 'pubkey_auth' => $this->sshPubkeyAuth, + 'port' => $this->sshPort, + ]); + + if ($result['success'] ?? false) { + Notification::make() + ->title(__('SSH settings saved')) + ->body(__('Changes will take effect immediately. Make sure you have key access if disabling passwords.')) + ->success() + ->send(); + } else { + throw new Exception($result['error'] ?? __('Failed to save settings')); + } + } catch (Exception $e) { + Notification::make() + ->title(__('Failed to save SSH settings')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + $this->loadSshSettings(); + } + + // Firewall actions + public function toggleFirewall(): void + { + try { + $action = $this->firewallEnabled ? 'ufw.disable' : 'ufw.enable'; + $result = $this->getAgent()->send($action); + + if ($result['success'] ?? false) { + $this->firewallEnabled = ! $this->firewallEnabled; + $auditAction = $this->firewallEnabled ? 'enabled' : 'disabled'; + AuditLog::logFirewallAction($auditAction); + Notification::make() + ->title($this->firewallEnabled ? __('Firewall enabled') : __('Firewall disabled')) + ->success() + ->send(); + } else { + throw new Exception($result['message'] ?? __('Unknown error')); + } + } catch (Exception $e) { + Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); + } + + $this->loadFirewallStatus(); + } + + public function installFirewall(): void + { + Notification::make()->title(__('Installing firewall...'))->info()->send(); + try { + $this->getAgent()->send('ufw.enable'); + $this->getAgent()->send('ufw.allow_service', ['service' => 'ssh']); + $this->getAgent()->send('ufw.allow_service', ['service' => 'http']); + $this->getAgent()->send('ufw.allow_service', ['service' => 'https']); + AuditLog::logFirewallAction('installed', 'default rules configured'); + Notification::make()->title(__('Firewall configured with default rules'))->success()->send(); + } catch (Exception $e) { + Notification::make()->title(__('Failed to configure firewall'))->body($e->getMessage())->danger()->send(); + } + $this->loadFirewallStatus(); + } + + public function deleteRule(int $ruleNumber): void + { + $this->ruleToDelete = $ruleNumber; + $this->mountAction('deleteRuleAction'); + } + + public function deleteRuleAction(): Action + { + return Action::make('deleteRuleAction') + ->requiresConfirmation() + ->modalHeading(__('Delete Firewall Rule')) + ->modalDescription(fn () => __('Are you sure you want to delete rule #:number?', ['number' => $this->ruleToDelete])) + ->modalSubmitActionLabel(__('Delete')) + ->color('danger') + ->action(function (): void { + try { + $result = $this->getAgent()->send('ufw.delete_rule', ['rule_number' => $this->ruleToDelete]); + if ($result['success'] ?? false) { + AuditLog::logFirewallAction('deleted', "rule #{$this->ruleToDelete}"); + Notification::make()->title(__('Rule deleted'))->success()->send(); + $this->loadFirewallStatus(); + } else { + throw new Exception($result['message'] ?? __('Unknown error')); + } + } catch (Exception $e) { + Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); + } + }); + } + + public function setDefaultPolicy(string $direction): void + { + $this->mountAction('setDefaultPolicyAction', ['direction' => $direction]); + } + + public function setDefaultPolicyAction(): Action + { + return Action::make('setDefaultPolicyAction') + ->modalHeading(__('Set Default Policy')) + ->form([ + Select::make('policy') + ->label(__('Policy')) + ->options([ + 'allow' => __('Allow'), + 'deny' => __('Deny'), + 'reject' => __('Reject'), + ]) + ->required(), + ]) + ->action(function (array $data, array $arguments): void { + try { + $result = $this->getAgent()->send('ufw.set_default', [ + 'direction' => $arguments['direction'] ?? 'incoming', + 'policy' => $data['policy'], + ]); + if ($result['success'] ?? false) { + Notification::make()->title(__('Default policy updated'))->success()->send(); + $this->loadFirewallStatus(); + } else { + throw new Exception($result['message'] ?? __('Unknown error')); + } + } catch (Exception $e) { + Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); + } + }); + } + + public function reloadFirewall(): void + { + try { + $result = $this->getAgent()->send('ufw.reload'); + if ($result['success'] ?? false) { + Notification::make()->title(__('Firewall reloaded'))->success()->send(); + $this->loadFirewallStatus(); + } else { + throw new Exception($result['message'] ?? __('Unknown error')); + } + } catch (Exception $e) { + Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); + } + } + + public function resetFirewall(): void + { + $this->mountAction('resetFirewallAction'); + } + + public function resetFirewallAction(): Action + { + return Action::make('resetFirewallAction') + ->requiresConfirmation() + ->modalHeading(__('Reset Firewall')) + ->modalDescription(__('This will delete ALL firewall rules and disable the firewall. Are you sure?')) + ->modalSubmitActionLabel(__('Reset Everything')) + ->color('danger') + ->action(function (): void { + try { + $result = $this->getAgent()->send('ufw.reset'); + if ($result['success'] ?? false) { + AuditLog::logFirewallAction('reset', 'all rules deleted'); + Notification::make()->title(__('Firewall reset'))->body(__('All rules have been deleted.'))->success()->send(); + $this->loadFirewallStatus(); + } else { + throw new Exception($result['message'] ?? __('Unknown error')); + } + } catch (Exception $e) { + Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); + } + }); + } + + protected function allowPortAction(): Action + { + return Action::make('allowPort') + ->label(__('Allow Port')) + ->icon('heroicon-o-plus-circle') + ->color('success') + ->form([ + TextInput::make('port') + ->label(__('Port')) + ->placeholder(__('e.g., 80, 443, 8000:8100')) + ->required() + ->helperText(__('Single port or range (e.g., 8000:8100)')), + Select::make('protocol') + ->label(__('Protocol')) + ->options([ + '' => __('Both (TCP & UDP)'), + 'tcp' => __('TCP only'), + 'udp' => __('UDP only'), + ]) + ->default(''), + TextInput::make('comment') + ->label(__('Comment (optional)')) + ->placeholder(__('e.g., Web server')), + ]) + ->action(function (array $data): void { + try { + $result = $this->getAgent()->send('ufw.allow_port', $data); + if ($result['success'] ?? false) { + $rule = "allow port {$data['port']}".($data['protocol'] ? "/{$data['protocol']}" : ''); + AuditLog::logFirewallAction('added', $rule, $data); + Notification::make()->title(__('Port allowed'))->success()->send(); + $this->loadFirewallStatus(); + } else { + throw new Exception($result['error'] ?? $result['message'] ?? __('Unknown error')); + } + } catch (Exception $e) { + Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); + } + }); + } + + protected function denyPortAction(): Action + { + return Action::make('denyPort') + ->label(__('Block Port')) + ->icon('heroicon-o-x-circle') + ->color('danger') + ->form([ + TextInput::make('port') + ->label(__('Port')) + ->placeholder(__('e.g., 3306')) + ->required(), + Select::make('protocol') + ->label(__('Protocol')) + ->options([ + '' => __('Both (TCP & UDP)'), + 'tcp' => __('TCP only'), + 'udp' => __('UDP only'), + ]) + ->default(''), + TextInput::make('comment') + ->label(__('Comment (optional)')), + ]) + ->action(function (array $data): void { + try { + $result = $this->getAgent()->send('ufw.deny_port', $data); + if ($result['success'] ?? false) { + $rule = "deny port {$data['port']}".($data['protocol'] ? "/{$data['protocol']}" : ''); + AuditLog::logFirewallAction('added', $rule, $data); + Notification::make()->title(__('Port blocked'))->success()->send(); + $this->loadFirewallStatus(); + } else { + throw new Exception($result['error'] ?? $result['message'] ?? __('Unknown error')); + } + } catch (Exception $e) { + Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); + } + }); + } + + protected function allowIpAction(): Action + { + return Action::make('allowIp') + ->label(__('Allow IP')) + ->icon('heroicon-o-check-circle') + ->color('success') + ->form([ + TextInput::make('ip') + ->label(__('IP Address')) + ->placeholder(__('e.g., 192.168.1.100 or 10.0.0.0/8')) + ->required() + ->helperText(__('Single IP or CIDR notation')), + TextInput::make('port') + ->label(__('Port (optional)')) + ->placeholder(__('Leave empty to allow all ports')), + Select::make('protocol') + ->label(__('Protocol')) + ->options([ + '' => __('Any'), + 'tcp' => __('TCP'), + 'udp' => __('UDP'), + ]) + ->default(''), + TextInput::make('comment') + ->label(__('Comment (optional)')), + ]) + ->action(function (array $data): void { + try { + $result = $this->getAgent()->send('ufw.allow_ip', $data); + if ($result['success'] ?? false) { + $rule = "allow from {$data['ip']}".($data['port'] ? " to port {$data['port']}" : ''); + AuditLog::logFirewallAction('added', $rule, $data); + Notification::make()->title(__('IP allowed'))->success()->send(); + $this->loadFirewallStatus(); + } else { + throw new Exception($result['error'] ?? $result['message'] ?? __('Unknown error')); + } + } catch (Exception $e) { + Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); + } + }); + } + + protected function denyIpAction(): Action + { + return Action::make('denyIp') + ->label(__('Block IP')) + ->icon('heroicon-o-no-symbol') + ->color('danger') + ->form([ + TextInput::make('ip') + ->label(__('IP Address')) + ->placeholder(__('e.g., 192.168.1.100 or 10.0.0.0/8')) + ->required(), + TextInput::make('port') + ->label(__('Port (optional)')) + ->placeholder(__('Leave empty to block all ports')), + Select::make('protocol') + ->label(__('Protocol')) + ->options([ + '' => __('Any'), + 'tcp' => __('TCP'), + 'udp' => __('UDP'), + ]) + ->default(''), + TextInput::make('comment') + ->label(__('Comment (optional)')), + ]) + ->action(function (array $data): void { + try { + $result = $this->getAgent()->send('ufw.deny_ip', $data); + if ($result['success'] ?? false) { + $rule = "deny from {$data['ip']}".($data['port'] ? " to port {$data['port']}" : ''); + AuditLog::logFirewallAction('added', $rule, $data); + Notification::make()->title(__('IP blocked'))->success()->send(); + $this->loadFirewallStatus(); + } else { + throw new Exception($result['error'] ?? $result['message'] ?? __('Unknown error')); + } + } catch (Exception $e) { + Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); + } + }); + } + + protected function allowServiceAction(): Action + { + return Action::make('allowService') + ->label(__('Allow Service')) + ->icon('heroicon-o-server') + ->color('info') + ->form([ + Select::make('service') + ->label(__('Service')) + ->options([ + 'ssh' => __('SSH (22)'), + 'http' => __('HTTP (80)'), + 'https' => __('HTTPS (443)'), + 'ftp' => __('FTP (21)'), + 'smtp' => __('SMTP (25)'), + 'pop3' => __('POP3 (110)'), + 'imap' => __('IMAP (143)'), + 'dns' => __('DNS (53)'), + 'mysql' => __('MySQL (3306)'), + 'postgresql' => __('PostgreSQL (5432)'), + ]) + ->required() + ->searchable(), + ]) + ->action(function (array $data): void { + try { + $result = $this->getAgent()->send('ufw.allow_service', $data); + if ($result['success'] ?? false) { + AuditLog::logFirewallAction('added', "allow service {$data['service']}", $data); + Notification::make()->title(__('Service allowed'))->success()->send(); + $this->loadFirewallStatus(); + } else { + throw new Exception($result['error'] ?? $result['message'] ?? __('Unknown error')); + } + } catch (Exception $e) { + Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); + } + }); + } + + protected function limitPortAction(): Action + { + return Action::make('limitPort') + ->label(__('Rate Limit')) + ->icon('heroicon-o-clock') + ->color('warning') + ->form([ + TextInput::make('port') + ->label(__('Port')) + ->placeholder(__('e.g., 22')) + ->required() + ->helperText(__('Limit connections (6 in 30 seconds)')), + Select::make('protocol') + ->label(__('Protocol')) + ->options([ + 'tcp' => __('TCP'), + 'udp' => __('UDP'), + ]) + ->default('tcp'), + ]) + ->action(function (array $data): void { + try { + $result = $this->getAgent()->send('ufw.limit_port', $data); + if ($result['success'] ?? false) { + AuditLog::logFirewallAction('added', "limit port {$data['port']}/{$data['protocol']}", $data); + Notification::make()->title(__('Rate limit applied'))->success()->send(); + $this->loadFirewallStatus(); + } else { + throw new Exception($result['error'] ?? $result['message'] ?? __('Unknown error')); + } + } catch (Exception $e) { + Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); + } + }); + } + + public function getActionColor(string $action): string + { + return match (strtoupper($action)) { + 'ALLOW' => 'success', + 'DENY' => 'danger', + 'REJECT' => 'warning', + 'LIMIT' => 'warning', + default => 'gray', + }; + } + + // Firewall action mount helpers + public function openAllowPort(): void + { + $this->mountAction('allowPort'); + } + + public function openDenyPort(): void + { + $this->mountAction('denyPort'); + } + + public function openAllowIp(): void + { + $this->mountAction('allowIp'); + } + + public function openDenyIp(): void + { + $this->mountAction('denyIp'); + } + + public function openAllowService(): void + { + $this->mountAction('allowService'); + } + + public function openLimitPort(): void + { + $this->mountAction('limitPort'); + } + + // Fail2ban actions + public function installFail2ban(): void + { + Notification::make()->title(__('Installing Fail2ban...'))->info()->send(); + try { + $result = $this->getAgent()->send('fail2ban.install'); + if ($result['success'] ?? false) { + Notification::make()->title(__('Fail2ban installed'))->success()->send(); + } else { + throw new Exception($result['error'] ?? __('Installation failed')); + } + } catch (Exception $e) { + Notification::make()->title(__('Failed to install Fail2ban'))->body($e->getMessage())->danger()->send(); + } + $this->loadFail2banStatus(); + } + + public function startFail2ban(): void + { + try { + $result = $this->getAgent()->send('fail2ban.start'); + if ($result['success'] ?? false) { + Notification::make()->title(__('Fail2ban started'))->success()->send(); + } else { + throw new Exception($result['error'] ?? __('Failed to start')); + } + } catch (Exception $e) { + Notification::make()->title(__('Failed to start Fail2ban'))->body($e->getMessage())->danger()->send(); + } + $this->loadFail2banStatus(); + } + + public function stopFail2ban(): void + { + try { + $result = $this->getAgent()->send('fail2ban.stop'); + if ($result['success'] ?? false) { + Notification::make()->title(__('Fail2ban stopped'))->success()->send(); + } else { + throw new Exception($result['error'] ?? __('Failed to stop')); + } + } catch (Exception $e) { + Notification::make()->title(__('Failed to stop Fail2ban'))->body($e->getMessage())->danger()->send(); + } + $this->loadFail2banStatus(); + } + + public function saveFail2banSettings(): void + { + try { + $result = $this->getAgent()->send('fail2ban.save_settings', [ + 'max_retry' => $this->maxRetry, + 'ban_time' => $this->banTime, + 'find_time' => $this->findTime, + ]); + if ($result['success'] ?? false) { + Notification::make()->title(__('Settings saved'))->success()->send(); + } else { + throw new Exception($result['error'] ?? __('Failed to save')); + } + } catch (Exception $e) { + Notification::make()->title(__('Failed to save settings'))->body($e->getMessage())->danger()->send(); + } + $this->loadFail2banStatus(); + } + + public function unbanIp(string $jail, string $ip): void + { + try { + $result = $this->getAgent()->send('fail2ban.unban_ip', ['jail' => $jail, 'ip' => $ip]); + if ($result['success'] ?? false) { + Notification::make()->title(__('Unbanned :ip', ['ip' => $ip]))->success()->send(); + } else { + throw new Exception($result['error'] ?? __('Failed to unban')); + } + } catch (Exception $e) { + Notification::make()->title(__('Failed to unban IP'))->body($e->getMessage())->danger()->send(); + } + $this->loadFail2banStatus(); + } + + public function enableJail(string $jail): void + { + try { + $result = $this->getAgent()->send('fail2ban.enable_jail', ['jail' => $jail]); + if ($result['success'] ?? false) { + Notification::make()->title(__('Jail :jail enabled', ['jail' => $jail]))->success()->send(); + } else { + throw new Exception($result['error'] ?? __('Failed to enable jail')); + } + } catch (Exception $e) { + Notification::make()->title(__('Failed to enable jail'))->body($e->getMessage())->danger()->send(); + } + $this->loadFail2banStatus(); + } + + public function disableJail(string $jail): void + { + try { + $result = $this->getAgent()->send('fail2ban.disable_jail', ['jail' => $jail]); + if ($result['success'] ?? false) { + Notification::make()->title(__('Jail :jail disabled', ['jail' => $jail]))->success()->send(); + } else { + throw new Exception($result['error'] ?? __('Failed to disable jail')); + } + } catch (Exception $e) { + Notification::make()->title(__('Failed to disable jail'))->body($e->getMessage())->danger()->send(); + } + $this->loadFail2banStatus(); + } + + // ClamAV actions + public function installClamav(): void + { + Notification::make()->title(__('Installing ClamAV...'))->body(__('This may take a few minutes.'))->info()->send(); + try { + $result = $this->getAgent()->send('clamav.install'); + if ($result['success'] ?? false) { + Notification::make()->title(__('ClamAV installed'))->body(__('Daemon disabled by default to save memory.'))->success()->send(); + } else { + throw new Exception($result['error'] ?? __('Installation failed')); + } + } catch (Exception $e) { + Notification::make()->title(__('Failed to install ClamAV'))->body($e->getMessage())->danger()->send(); + } + $this->loadClamavStatus(); + } + + public function updateSignatures(): void + { + try { + $result = $this->getAgent()->send('clamav.update_signatures'); + if ($result['success'] ?? false) { + Notification::make()->title(__('Signatures updated'))->success()->send(); + } else { + Notification::make()->title(__('Update may have issues'))->body($result['output'] ?? '')->warning()->send(); + } + } catch (Exception $e) { + Notification::make()->title(__('Failed to update signatures'))->body($e->getMessage())->danger()->send(); + } + $this->loadClamavStatus(); + } + + public function startClamav(): void + { + try { + $result = $this->getAgent()->send('clamav.start'); + if ($result['success'] ?? false) { + Notification::make()->title(__('ClamAV started'))->success()->send(); + } else { + throw new Exception($result['error'] ?? __('Failed to start')); + } + } catch (Exception $e) { + Notification::make()->title(__('Failed to start ClamAV'))->body($e->getMessage())->danger()->send(); + } + $this->loadClamavStatus(); + } + + public function stopClamav(): void + { + try { + $result = $this->getAgent()->send('clamav.stop'); + if ($result['success'] ?? false) { + Notification::make()->title(__('ClamAV stopped'))->success()->send(); + } else { + throw new Exception($result['error'] ?? __('Failed to stop')); + } + } catch (Exception $e) { + Notification::make()->title(__('Failed to stop ClamAV'))->body($e->getMessage())->danger()->send(); + } + $this->loadClamavStatus(); + } + + public function toggleRealtime(): void + { + try { + if ($this->realtimeRunning) { + $result = $this->getAgent()->send('clamav.realtime_disable'); + $message = __('Real-time protection disabled'); + } else { + $result = $this->getAgent()->send('clamav.realtime_enable'); + $message = __('Real-time protection enabled'); + } + if ($result['success'] ?? false) { + Notification::make()->title($message)->success()->send(); + } else { + throw new Exception($result['error'] ?? __('Failed')); + } + } catch (Exception $e) { + Notification::make()->title(__('Failed to toggle real-time protection'))->body($e->getMessage())->danger()->send(); + } + $this->loadClamavStatus(); + } + + public function toggleLightMode(): void + { + try { + $action = $this->clamavLightMode ? 'clamav.set_full_mode' : 'clamav.set_light_mode'; + $result = $this->getAgent()->send($action); + + if ($result['success'] ?? false) { + $this->clamavLightMode = ! $this->clamavLightMode; + $message = $this->clamavLightMode + ? __('Switched to lightweight mode - web hosting signatures only') + : __('Switched to full mode - all ClamAV signatures'); + Notification::make() + ->title($message) + ->body(__('Signature count: :count', ['count' => number_format($result['signature_count'] ?? 0)])) + ->success() + ->send(); + } else { + throw new Exception($result['error'] ?? __('Failed to switch mode')); + } + } catch (Exception $e) { + Notification::make() + ->title(__('Failed to switch ClamAV mode')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + $this->loadClamavStatus(); + } + + public function deleteQuarantined(string $filename): void + { + try { + $result = $this->getAgent()->send('clamav.delete_quarantined', ['filename' => $filename]); + if ($result['success'] ?? false) { + Notification::make()->title(__('File deleted'))->success()->send(); + } else { + throw new Exception($result['error'] ?? __('Failed to delete')); + } + } catch (Exception $e) { + Notification::make()->title(__('Failed to delete file'))->body($e->getMessage())->danger()->send(); + } + $this->loadClamavStatus(); + } + + // Scanner methods + protected function checkScannerToolStatus(): void + { + exec('which lynis 2>/dev/null', $output, $code); + $this->lynisInstalled = $code === 0; + if ($this->lynisInstalled) { + exec('lynis --version 2>/dev/null | head -1', $versionOutput); + $this->lynisVersion = trim($versionOutput[0] ?? 'Unknown'); + } + + exec('which wpscan 2>/dev/null', $output2, $code2); + $this->wpscanInstalled = $code2 === 0; + if ($this->wpscanInstalled) { + exec("wpscan --version 2>/dev/null | grep -i 'version' | tail -1", $versionOutput2); + $this->wpscanVersion = trim($versionOutput2[0] ?? 'Unknown'); + } + + exec('which nikto 2>/dev/null', $output3, $code3); + $this->niktoInstalled = $code3 === 0; + if ($this->niktoInstalled) { + exec('nikto -Version 2>/dev/null | grep -i version | head -1', $versionOutput3); + $this->niktoVersion = trim($versionOutput3[0] ?? 'Unknown'); + } + } + + protected function loadLastScans(): void + { + $scanDir = storage_path('app/security-scans'); + + if (file_exists("$scanDir/lynis-latest.json")) { + $this->lastLynisScan = date('Y-m-d H:i:s', filemtime("$scanDir/lynis-latest.json")); + $this->lynisResults = json_decode(file_get_contents("$scanDir/lynis-latest.json"), true) ?? []; + } + + if (file_exists("$scanDir/wpscan-latest.json")) { + $this->lastWpscanScan = date('Y-m-d H:i:s', filemtime("$scanDir/wpscan-latest.json")); + $this->wpscanResults = json_decode(file_get_contents("$scanDir/wpscan-latest.json"), true) ?? []; + } + + if (file_exists("$scanDir/nikto-latest.json")) { + $this->lastNiktoScan = date('Y-m-d H:i:s', filemtime("$scanDir/nikto-latest.json")); + $this->niktoResults = json_decode(file_get_contents("$scanDir/nikto-latest.json"), true) ?? []; + } + } + + public function installLynis(): void + { + Notification::make()->title(__('Installing Lynis...'))->info()->send(); + exec('apt-get update && apt-get install -y lynis 2>&1', $output, $code); + + if ($code === 0) { + Notification::make()->title(__('Lynis installed successfully'))->success()->send(); + } else { + Notification::make()->title(__('Installation failed'))->body(implode("\n", array_slice($output, -5)))->danger()->send(); + } + $this->checkScannerToolStatus(); + } + + public function installWpscan(): void + { + Notification::make()->title(__('Installing WPScan...'))->body(__('This may take a few minutes.'))->info()->send(); + + exec('which ruby 2>/dev/null', $rubyCheck, $rubyCode); + if ($rubyCode !== 0) { + exec('apt-get update && apt-get install -y ruby ruby-dev build-essential libcurl4-openssl-dev libxml2 libxml2-dev libxslt1-dev 2>&1', $output, $code); + } + + exec('gem install wpscan 2>&1', $output, $code); + + if ($code === 0) { + exec('wpscan --update 2>&1'); + Notification::make()->title(__('WPScan installed successfully'))->success()->send(); + } else { + Notification::make()->title(__('Installation failed'))->body(implode("\n", array_slice($output, -5)))->danger()->send(); + } + $this->checkScannerToolStatus(); + } + + public function installNikto(): void + { + Notification::make()->title(__('Installing Nikto...'))->info()->send(); + exec('apt-get update && apt-get install -y nikto 2>&1', $output, $code); + + if ($code === 0) { + Notification::make()->title(__('Nikto installed successfully'))->success()->send(); + } else { + Notification::make()->title(__('Installation failed'))->body(implode("\n", array_slice($output, -5)))->danger()->send(); + } + $this->checkScannerToolStatus(); + } + + public function runLynisScan(): void + { + if (! $this->lynisInstalled) { + Notification::make()->title(__('Lynis not installed'))->danger()->send(); + + return; + } + + $this->isScanning = true; + $this->currentScan = 'lynis'; + $this->scanOutput = __('Running Lynis system audit...')."\n"; + + $scanDir = storage_path('app/security-scans'); + if (! is_dir($scanDir)) { + mkdir($scanDir, 0755, true); + } + + exec('lynis audit system --no-colors --quick 2>&1', $output, $code); + $this->scanOutput = implode("\n", $output); + + $results = $this->parseLynisOutput($output); + $results['scan_time'] = date('Y-m-d H:i:s'); + $results['raw_output'] = $this->scanOutput; + + file_put_contents("$scanDir/lynis-latest.json", json_encode($results, JSON_PRETTY_PRINT)); + + $this->lynisResults = $results; + $this->lastLynisScan = $results['scan_time']; + $this->isScanning = false; + $this->currentScan = ''; + + $warningCount = count($results['warnings'] ?? []); + $suggestionCount = count($results['suggestions'] ?? []); + + Notification::make() + ->title(__('Lynis scan completed')) + ->body(__('Found :warnings warnings and :suggestions suggestions', ['warnings' => $warningCount, 'suggestions' => $suggestionCount])) + ->success() + ->send(); + } + + protected function parseLynisOutput(array $output): array + { + $results = [ + 'hardening_index' => 0, + 'warnings' => [], + 'suggestions' => [], + 'tests_performed' => 0, + ]; + + $fullOutput = implode("\n", $output); + + if (preg_match('/Hardening index\s*:\s*(\d+)/i', $fullOutput, $matches)) { + $results['hardening_index'] = (int) $matches[1]; + } + + if (preg_match('/Tests performed\s*:\s*(\d+)/i', $fullOutput, $matches)) { + $results['tests_performed'] = (int) $matches[1]; + } + + preg_match_all('/\[WARNING\]\s*(.+)$/m', $fullOutput, $warningMatches); + $results['warnings'] = $warningMatches[1] ?? []; + + preg_match_all('/\[SUGGESTION\]\s*(.+)$/m', $fullOutput, $suggestionMatches); + $results['suggestions'] = $suggestionMatches[1] ?? []; + + return $results; + } + + public function getLocalWordPressSites(): array + { + $sites = []; + + try { + $users = \App\Models\User::where('is_admin', false)->get(); + + foreach ($users as $user) { + $result = $this->getAgent()->wpList($user->username); + $userSites = $result['sites'] ?? []; + + foreach ($userSites as $site) { + $siteId = $site['domain'].($site['path'] ?? ''); + $sites[$siteId] = $site['domain'].($site['path'] !== '/' ? ($site['path'] ?? '') : ''); + } + } + } catch (Exception $e) { + // Return empty array on error + } + + return $sites; + } + + public function runWpscanOnSite(): void + { + if (! $this->wpscanInstalled) { + Notification::make()->title(__('WPScan not installed'))->danger()->send(); + + return; + } + + if (! $this->selectedWpSiteId) { + Notification::make()->title(__('Please select a WordPress site'))->danger()->send(); + + return; + } + + $url = 'https://'.$this->selectedWpSiteId; + + $this->isScanning = true; + $this->currentScan = 'wpscan'; + $this->scanOutput = __('Scanning WordPress site: :url', ['url' => $url])."\n"; + + $scanDir = storage_path('app/security-scans'); + if (! is_dir($scanDir)) { + mkdir($scanDir, 0755, true); + } + + // Set HOME to writable directory for wpscan cache + $wpscanCmd = 'HOME=/var/www wpscan --url '.escapeshellarg($url).' --format json --no-banner 2>&1'; + exec($wpscanCmd, $output, $code); + + $jsonOutput = implode("\n", $output); + $this->scanOutput = $jsonOutput; + + $results = json_decode($jsonOutput, true); + if (! $results) { + $results = [ + 'error' => __('Failed to parse scan results'), + 'raw_output' => $jsonOutput, + ]; + } + + $results['scan_time'] = date('Y-m-d H:i:s'); + $results['target_url'] = $url; + + file_put_contents("$scanDir/wpscan-latest.json", json_encode($results, JSON_PRETTY_PRINT)); + + $this->wpscanResults = $results; + $this->lastWpscanScan = $results['scan_time']; + $this->isScanning = false; + $this->currentScan = ''; + + Notification::make() + ->title(__('WPScan completed')) + ->body(__('Scan finished for :url', ['url' => $url])) + ->success() + ->send(); + } + + public function runNiktoScan(): void + { + if (! $this->niktoInstalled) { + Notification::make()->title(__('Nikto not installed'))->danger()->send(); + + return; + } + + $target = 'localhost'; + + $this->isScanning = true; + $this->currentScan = 'nikto'; + $this->scanOutput = __('Scanning local web server...')."\n"; + + $scanDir = storage_path('app/security-scans'); + if (! is_dir($scanDir)) { + mkdir($scanDir, 0755, true); + } + + $jsonFile = "$scanDir/nikto-".date('Y-m-d-His').'.json'; + // Use full path for nikto since timeout command has restricted PATH + $niktoPath = file_exists('/usr/bin/nikto') ? '/usr/bin/nikto' : '/usr/local/bin/nikto'; + exec("timeout 300 {$niktoPath} -h localhost -Format json -output {$jsonFile} 2>&1", $output, $code); + + $this->scanOutput = implode("\n", $output); + + $results = []; + if (file_exists($jsonFile)) { + $results = json_decode(file_get_contents($jsonFile), true) ?? []; + } + + if (empty($results)) { + $results = $this->parseNiktoTextOutput($output); + } + + $results['scan_time'] = date('Y-m-d H:i:s'); + $results['target'] = $target; + $results['raw_output'] = $this->scanOutput; + + file_put_contents("$scanDir/nikto-latest.json", json_encode($results, JSON_PRETTY_PRINT)); + + $this->niktoResults = $results; + $this->lastNiktoScan = $results['scan_time']; + $this->isScanning = false; + $this->currentScan = ''; + + Notification::make() + ->title(__('Nikto scan completed')) + ->body(__('Local web server scan finished')) + ->success() + ->send(); + } + + protected function parseNiktoTextOutput(array $output): array + { + $results = [ + 'vulnerabilities' => [], + 'info' => [], + ]; + + foreach ($output as $line) { + if (preg_match('/^\+\s*OSVDB-\d+:\s*(.+)/', $line, $matches)) { + $results['vulnerabilities'][] = trim($matches[1]); + } elseif (preg_match('/^\+\s*(.+)/', $line, $matches)) { + $results['info'][] = trim($matches[1]); + } + } + + return $results; + } + + // ClamAV on-demand scan methods + public function getClamScanUsers(): array + { + $users = []; + try { + $systemUsers = \App\Models\User::where('is_admin', false)->get(); + foreach ($systemUsers as $user) { + $users[$user->username] = $user->username.' ('.$user->email.')'; + } + } catch (Exception $e) { + // Return empty array on error + } + + return $users; + } + + public function runClamScanUser(): void + { + if (! $this->clamavInstalled) { + Notification::make()->title(__('ClamAV not installed'))->danger()->send(); + + return; + } + + if (! $this->selectedClamUser) { + Notification::make()->title(__('Please select a user'))->danger()->send(); + + return; + } + + $this->isScanning = true; + $this->currentScan = 'clamav'; + $this->scanOutput = __('Scanning user directory: /home/:user', ['user' => $this->selectedClamUser])."\n"; + + $scanDir = storage_path('app/security-scans'); + if (! is_dir($scanDir)) { + mkdir($scanDir, 0755, true); + } + + $userDir = "/home/{$this->selectedClamUser}"; + $logFile = "$scanDir/clamscan-{$this->selectedClamUser}-".date('Y-m-d-His').'.log'; + + $cmd = "clamscan -r --infected --log={$logFile} ". + "--exclude-dir='^/home/{$this->selectedClamUser}/\\.cache' ". + "--exclude-dir='^/home/{$this->selectedClamUser}/\\.local' ". + escapeshellarg($userDir).' 2>&1'; + + exec($cmd, $output, $code); + $this->scanOutput = implode("\n", $output); + + $results = $this->parseClamScanOutput($output); + $results['scan_time'] = date('Y-m-d H:i:s'); + $results['scan_type'] = 'user'; + $results['target'] = $userDir; + $results['username'] = $this->selectedClamUser; + $results['raw_output'] = $this->scanOutput; + + file_put_contents("$scanDir/clamscan-latest.json", json_encode($results, JSON_PRETTY_PRINT)); + + $this->clamScanResults = $results; + $this->lastClamScan = $results['scan_time']; + $this->isScanning = false; + $this->currentScan = ''; + + $infected = $results['infected_files'] ?? 0; + $message = $infected > 0 + ? __('Found :count infected file(s)', ['count' => $infected]) + : __('No threats detected'); + + Notification::make() + ->title(__('ClamAV scan completed')) + ->body($message) + ->color($infected > 0 ? 'danger' : 'success') + ->send(); + } + + public function runClamScanServer(): void + { + if (! $this->clamavInstalled) { + Notification::make()->title(__('ClamAV not installed'))->danger()->send(); + + return; + } + + $this->isScanning = true; + $this->currentScan = 'clamav'; + $this->scanOutput = __('Scanning server-wide: /home')."\n". + __('This may take a while...')."\n"; + + $scanDir = storage_path('app/security-scans'); + if (! is_dir($scanDir)) { + mkdir($scanDir, 0755, true); + } + + $logFile = "$scanDir/clamscan-server-".date('Y-m-d-His').'.log'; + + $cmd = "clamscan -r --infected --log={$logFile} ". + "--exclude-dir='^\\.cache' ". + "--exclude-dir='^\\.local' ". + '/home 2>&1'; + + exec($cmd, $output, $code); + $this->scanOutput = implode("\n", $output); + + $results = $this->parseClamScanOutput($output); + $results['scan_time'] = date('Y-m-d H:i:s'); + $results['scan_type'] = 'server'; + $results['target'] = '/home'; + $results['raw_output'] = $this->scanOutput; + + file_put_contents("$scanDir/clamscan-latest.json", json_encode($results, JSON_PRETTY_PRINT)); + + $this->clamScanResults = $results; + $this->lastClamScan = $results['scan_time']; + $this->isScanning = false; + $this->currentScan = ''; + + $infected = $results['infected_files'] ?? 0; + $message = $infected > 0 + ? __('Found :count infected file(s)', ['count' => $infected]) + : __('No threats detected'); + + Notification::make() + ->title(__('Server-wide scan completed')) + ->body($message) + ->color($infected > 0 ? 'danger' : 'success') + ->send(); + } + + protected function parseClamScanOutput(array $output): array + { + $results = [ + 'scanned_files' => 0, + 'infected_files' => 0, + 'threats' => [], + ]; + + $fullOutput = implode("\n", $output); + + if (preg_match('/Scanned files:\s*(\d+)/i', $fullOutput, $matches)) { + $results['scanned_files'] = (int) $matches[1]; + } + if (preg_match('/Infected files:\s*(\d+)/i', $fullOutput, $matches)) { + $results['infected_files'] = (int) $matches[1]; + } + + foreach ($output as $line) { + if (preg_match('/^(.+?):\s*(.+?)\s*FOUND$/i', $line, $matches)) { + $results['threats'][] = [ + 'file' => trim($matches[1]), + 'threat' => trim($matches[2]), + ]; + } + } + + return $results; + } + + protected function loadClamScanResults(): void + { + $scanDir = storage_path('app/security-scans'); + if (file_exists("$scanDir/clamscan-latest.json")) { + $this->lastClamScan = date('Y-m-d H:i:s', filemtime("$scanDir/clamscan-latest.json")); + $this->clamScanResults = json_decode(file_get_contents("$scanDir/clamscan-latest.json"), true) ?? []; + } + } + + // Dynamic component builders for pure Filament UI + protected function buildClamavScanResultsSchema(): array + { + if (empty($this->clamScanResults)) { + return [Text::make(__('No scan results available'))]; + } + + $scannedFiles = $this->clamScanResults['scanned_files'] ?? 0; + $infectedFiles = $this->clamScanResults['infected_files'] ?? 0; + $scanType = $this->clamScanResults['scan_type'] ?? 'unknown'; + $target = $scanType === 'user' ? ($this->clamScanResults['username'] ?? '-') : __('Server'); + $threats = $this->clamScanResults['threats'] ?? []; + + $components = [ + Grid::make(['default' => 1, 'md' => 3]) + ->schema([ + Section::make((string) $scannedFiles) + ->description(__('Files Scanned')) + ->icon('heroicon-o-document-magnifying-glass') + ->iconColor('primary'), + Section::make((string) $infectedFiles) + ->description(__('Infected Files')) + ->icon('heroicon-o-exclamation-triangle') + ->iconColor($infectedFiles > 0 ? 'danger' : 'success'), + Section::make($target) + ->description(__('Scan Target')) + ->icon('heroicon-o-folder') + ->iconColor('gray'), + ]), + ]; + + if (! empty($threats)) { + $threatComponents = []; + foreach ($threats as $threat) { + $threatComponents[] = Text::make($threat['threat'].': '.basename($threat['file'])); + } + $components[] = Section::make(__('Threats Detected').' ('.count($threats).')') + ->icon('heroicon-o-exclamation-triangle') + ->iconColor('danger') + ->schema($threatComponents); + } else { + $components[] = Section::make(__('No threats detected')) + ->icon('heroicon-o-check-circle') + ->iconColor('success') + ->description(__('The scanned directory appears to be clean.')); + } + + return $components; + } + + protected function buildSshCurrentConfigSchema(): array + { + return [ + Section::make($this->sshPasswordAuth ? __('Enabled') : __('Disabled')) + ->description(__('Password Authentication')) + ->icon('heroicon-o-key') + ->iconColor($this->sshPasswordAuth ? 'warning' : 'success') + ->aside(), + Section::make($this->sshPubkeyAuth ? __('Enabled') : __('Disabled')) + ->description(__('Public Key Authentication')) + ->icon('heroicon-o-finger-print') + ->iconColor($this->sshPubkeyAuth ? 'success' : 'danger') + ->aside(), + Section::make((string) $this->sshPort) + ->description(__('SSH Port')) + ->icon('heroicon-o-server') + ->iconColor('gray') + ->aside(), + ]; + } + + protected function buildSshRecommendationsSchema(): array + { + $keysOnly = ! $this->sshPasswordAuth && $this->sshPubkeyAuth; + + return [ + Section::make(__('Disable password authentication')) + ->description(__('Use SSH keys only for better security')) + ->icon($keysOnly ? 'heroicon-o-check-circle' : 'heroicon-o-exclamation-triangle') + ->iconColor($keysOnly ? 'success' : 'warning') + ->aside(), + Section::make(__('Enable Fail2ban protection')) + ->description(__('Automatically block brute-force attempts')) + ->icon($this->fail2banRunning ? 'heroicon-o-check-circle' : 'heroicon-o-exclamation-triangle') + ->iconColor($this->fail2banRunning ? 'success' : 'warning') + ->aside(), + ]; + } + + protected function buildClamScanOutputSchema(): array + { + $output = $this->clamScanResults['raw_output'] ?? ''; + + if ($this->isScanning && $this->currentScan === 'clamav') { + $output = $this->scanOutput; + } + + if (! $output) { + return []; + } + + return [ + Text::make($output), + ]; + } + + protected function buildScanOutputSchema(): array + { + if (! $this->scanOutput) { + return []; + } + + return [ + Text::make($this->scanOutput), + ]; + } +} diff --git a/app/Filament/Admin/Pages/ServerSettings.php b/app/Filament/Admin/Pages/ServerSettings.php new file mode 100644 index 0000000..c122658 --- /dev/null +++ b/app/Filament/Admin/Pages/ServerSettings.php @@ -0,0 +1,1228 @@ +currentLogo && Storage::disk('public')->exists($this->currentLogo)) { + return asset('storage/'.$this->currentLogo); + } + + return null; + } + + protected function getAgent(): AgentClient + { + return new AgentClient; + } + + public function getTitle(): string|Htmlable + { + return __('Server Settings'); + } + + protected function normalizeTabName(?string $tab): string + { + return match ($tab) { + 'general', 'dns', 'storage', 'email', 'notifications', 'php-fpm' => $tab, + default => 'general', + }; + } + + public function setTab(string $tab): void + { + $this->activeTab = $this->normalizeTabName($tab); + } + + public function mount(): void + { + $this->activeTab = $this->normalizeTabName($this->activeTab); + $settings = DnsSetting::getAll(); + $hostname = gethostname() ?: 'localhost'; + $serverIp = trim(shell_exec("hostname -I | awk '{print $1}'") ?? '') ?: ''; + + $this->currentLogo = $settings['custom_logo'] ?? null; + $this->isSystemdResolved = trim(shell_exec('systemctl is-active systemd-resolved 2>/dev/null') ?? '') === 'active'; + + // Load hostname from agent + $agentHostname = $hostname; + try { + $result = $this->getAgent()->send('server.info', []); + if ($result['success'] ?? false) { + $agentHostname = $result['info']['hostname'] ?? $hostname; + } + } catch (Exception $e) { + // Use default + } + + // Load resolvers + $resolvers = ['', '', '']; + $searchDomain = ''; + if (file_exists('/etc/resolv.conf')) { + $content = file_get_contents('/etc/resolv.conf'); + $lines = explode("\n", $content); + $ns = []; + foreach ($lines as $line) { + $line = trim($line); + if (str_starts_with($line, 'nameserver ')) { + $ns[] = trim(substr($line, 11)); + } elseif (str_starts_with($line, 'search ')) { + $searchDomain = trim(substr($line, 7)); + } + } + $resolvers = [$ns[0] ?? '', $ns[1] ?? '', $ns[2] ?? '']; + } + + // Fill form data + $this->brandingData = [ + 'panel_name' => $settings['panel_name'] ?? 'Jabali', + ]; + + $this->hostnameData = [ + 'hostname' => $agentHostname, + ]; + + $this->dnsData = [ + 'ns1' => $settings['ns1'] ?? "ns1.{$hostname}", + 'ns1_ip' => $settings['ns1_ip'] ?? $serverIp, + 'ns2' => $settings['ns2'] ?? "ns2.{$hostname}", + 'ns2_ip' => $settings['ns2_ip'] ?? $serverIp, + 'default_ip' => $settings['default_ip'] ?? $serverIp, + 'default_ipv6' => $settings['default_ipv6'] ?? '', + 'default_ttl' => $settings['default_ttl'] ?? '3600', + 'admin_email' => $settings['admin_email'] ?? "admin.{$hostname}", + ]; + + $this->resolversData = [ + 'resolver1' => $resolvers[0], + 'resolver2' => $resolvers[1], + 'resolver3' => $resolvers[2], + 'search_domain' => $searchDomain, + ]; + + $this->quotaData = [ + 'quotas_enabled' => (bool) ($settings['quotas_enabled'] ?? false), + 'default_quota_mb' => (int) ($settings['default_quota_mb'] ?? 5120), + ]; + + $this->fileManagerData = [ + 'max_upload_size_mb' => (int) ($settings['max_upload_size_mb'] ?? 100), + ]; + + $this->emailData = [ + 'mail_hostname' => $settings['mail_hostname'] ?? "mail.{$hostname}", + 'mail_default_quota_mb' => (int) ($settings['mail_default_quota_mb'] ?? 1024), + 'max_mailboxes_per_domain' => (int) ($settings['max_mailboxes_per_domain'] ?? 10), + 'webmail_url' => $settings['webmail_url'] ?? '/webmail', + 'webmail_product_name' => $settings['webmail_product_name'] ?? 'Jabali Webmail', + ]; + + $this->notificationsData = [ + 'admin_email_recipients' => $settings['admin_email_recipients'] ?? '', + 'notify_ssl_errors' => (bool) ($settings['notify_ssl_errors'] ?? true), + 'notify_backup_failures' => (bool) ($settings['notify_backup_failures'] ?? true), + 'notify_backup_success' => (bool) ($settings['notify_backup_success'] ?? false), + 'notify_disk_quota' => (bool) ($settings['notify_disk_quota'] ?? true), + 'notify_login_failures' => (bool) ($settings['notify_login_failures'] ?? true), + 'notify_ssh_logins' => (bool) ($settings['notify_ssh_logins'] ?? false), + 'notify_system_updates' => (bool) ($settings['notify_system_updates'] ?? false), + 'notify_service_health' => (bool) ($settings['notify_service_health'] ?? true), + 'notify_high_load' => (bool) ($settings['notify_high_load'] ?? true), + 'load_threshold' => (float) ($settings['load_threshold'] ?? 5.0), + 'load_alert_minutes' => (int) ($settings['load_alert_minutes'] ?? 5), + ]; + + $this->phpFpmData = [ + 'pm_max_children' => (int) ($settings['fpm_pm_max_children'] ?? 5), + 'pm_max_requests' => (int) ($settings['fpm_pm_max_requests'] ?? 200), + 'rlimit_files' => (int) ($settings['fpm_rlimit_files'] ?? 1024), + 'process_priority' => (int) ($settings['fpm_process_priority'] ?? 0), + 'request_terminate_timeout' => (int) ($settings['fpm_request_terminate_timeout'] ?? 300), + 'memory_limit' => $settings['fpm_memory_limit'] ?? '512M', + ]; + + $this->loadVersionInfo(); + } + + public function settingsForm(Schema $schema): Schema + { + return $schema + ->schema([ + View::make('filament.admin.components.server-settings-tabs-nav'), + ...$this->getTabContent(), + ]); + } + + protected function getTabContent(): array + { + return match ($this->activeTab) { + 'general' => $this->generalTabContent(), + 'dns' => $this->dnsTabContent(), + 'storage' => $this->storageTabContent(), + 'email' => $this->emailTabContent(), + 'notifications' => $this->notificationsTabContent(), + 'php-fpm' => $this->phpFpmTabContent(), + default => $this->generalTabContent(), + }; + } + + protected function generalTabContent(): array + { + return [ + Section::make(__('Panel Version & Updates')) + ->description($this->currentVersion ?: __('Unknown')) + ->icon('heroicon-o-arrow-up-tray') + ->schema([ + Actions::make([ + FormAction::make('checkForUpdates') + ->label(__('Check for Updates')) + ->icon('heroicon-o-arrow-path') + ->color('gray') + ->action('checkForUpdates'), + FormAction::make('performUpgrade') + ->label(__('Upgrade Now')) + ->icon('heroicon-o-arrow-up-tray') + ->color('success') + ->requiresConfirmation() + ->action('performUpgrade') + ->visible(fn () => $this->updatesAvailable > 0), + ]), + ]), + Section::make(__('Panel Branding')) + ->icon('heroicon-o-paint-brush') + ->schema([ + Grid::make(['default' => 1, 'md' => 2])->schema([ + TextInput::make('brandingData.panel_name') + ->label(__('Control Panel Name')) + ->placeholder('Jabali') + ->helperText(__('Appears in browser title and navigation')) + ->required(), + ]), + Actions::make([ + FormAction::make('uploadLogo') + ->label(__('Upload Logo')) + ->icon('heroicon-o-arrow-up-tray') + ->color('gray') + ->form([ + FileUpload::make('logo') + ->label(__('Logo Image')) + ->image() + ->disk('public') + ->directory('branding') + ->visibility('public') + ->acceptedFileTypes(['image/png', 'image/svg+xml', 'image/jpeg', 'image/webp']) + ->maxSize(1024) + ->required() + ->helperText(__('SVG, PNG, JPEG or WebP, max 1MB')), + ]) + ->action(function (array $data): void { + $this->uploadLogo($data); + }), + FormAction::make('removeLogo') + ->label(__('Remove Logo')) + ->color('danger') + ->icon('heroicon-o-trash') + ->requiresConfirmation() + ->action(fn () => $this->removeLogo()) + ->visible(fn () => $this->currentLogo !== null), + FormAction::make('saveBranding') + ->label(__('Save Branding')) + ->action('saveBranding'), + ]), + ]), + Section::make(__('Server Hostname')) + ->icon('heroicon-o-server') + ->schema([ + TextInput::make('hostnameData.hostname') + ->label(__('Hostname')) + ->placeholder('server.example.com') + ->required(), + Actions::make([ + FormAction::make('saveHostname') + ->label(__('Save Hostname')) + ->action('saveHostname'), + ]), + ]), + ]; + } + + protected function dnsTabContent(): array + { + return [ + Section::make(__('Nameservers')) + ->icon('heroicon-o-server-stack') + ->schema([ + Grid::make(['default' => 1, 'md' => 2, 'lg' => 4])->schema([ + TextInput::make('dnsData.ns1')->label(__('NS1 Hostname'))->placeholder('ns1.example.com'), + TextInput::make('dnsData.ns1_ip')->label(__('NS1 IP Address'))->placeholder('192.168.1.1'), + TextInput::make('dnsData.ns2')->label(__('NS2 Hostname'))->placeholder('ns2.example.com'), + TextInput::make('dnsData.ns2_ip')->label(__('NS2 IP Address'))->placeholder('192.168.1.2'), + ]), + ]), + Section::make(__('Zone Defaults')) + ->schema([ + Grid::make(['default' => 1, 'md' => 3])->schema([ + TextInput::make('dnsData.default_ip') + ->label(__('Default Server IP')) + ->placeholder('192.168.1.1') + ->helperText(__('Default A record IP for new zones')), + TextInput::make('dnsData.default_ipv6') + ->label(__('Default IPv6')) + ->placeholder('2001:db8::1') + ->helperText(__('Default AAAA record IP for new zones')) + ->rule('nullable|ipv6'), + TextInput::make('dnsData.default_ttl') + ->label(__('Default TTL')) + ->placeholder('3600'), + ]), + TextInput::make('dnsData.admin_email') + ->label(__('Admin Email (SOA)')) + ->placeholder('admin.example.com') + ->helperText(__('Use dots instead of @ (e.g., admin.example.com)')), + Actions::make([ + FormAction::make('saveDns') + ->label(__('Save DNS Settings')) + ->action('saveDns'), + ]), + ]), + Section::make(__('DNS Resolvers')) + ->description($this->isSystemdResolved ? __('systemd-resolved active') : null) + ->icon('heroicon-o-signal') + ->schema([ + Grid::make(['default' => 1, 'md' => 2, 'lg' => 4])->schema([ + TextInput::make('resolversData.resolver1')->label(__('Resolver 1'))->placeholder('8.8.8.8'), + TextInput::make('resolversData.resolver2')->label(__('Resolver 2'))->placeholder('8.8.4.4'), + TextInput::make('resolversData.resolver3')->label(__('Resolver 3'))->placeholder('1.1.1.1'), + TextInput::make('resolversData.search_domain')->label(__('Search Domain'))->placeholder('example.com'), + ]), + Actions::make([ + FormAction::make('saveResolvers') + ->label(__('Save Resolvers')) + ->action('saveResolvers'), + ]), + ]), + Section::make(__('DNSSEC')) + ->description(__('DNS Security Extensions')) + ->icon('heroicon-o-shield-check') + ->schema([ + EmbeddedTable::make(DnssecTable::class), + ]), + ]; + } + + protected function storageTabContent(): array + { + return [ + Section::make(__('Disk Quotas')) + ->icon('heroicon-o-chart-pie') + ->schema([ + Grid::make(['default' => 1, 'md' => 2])->schema([ + Toggle::make('quotaData.quotas_enabled') + ->label(__('Enable Disk Quotas')) + ->helperText(__('When enabled, disk usage limits will be enforced for user accounts')), + TextInput::make('quotaData.default_quota_mb') + ->label(__('Default Quota (MB)')) + ->numeric() + ->placeholder('5120') + ->helperText(__('Default disk quota for new users (5120 MB = 5 GB)')), + ]), + Actions::make([ + FormAction::make('saveQuotaSettings') + ->label(__('Save Quota Settings')) + ->action('saveQuotaSettings'), + ]), + ]), + Section::make(__('File Manager')) + ->icon('heroicon-o-folder') + ->schema([ + TextInput::make('fileManagerData.max_upload_size_mb') + ->label(__('Max Upload Size (MB)')) + ->numeric() + ->minValue(1) + ->maxValue(500) + ->placeholder('100') + ->helperText(__('Maximum file size users can upload (1-500 MB)')), + Actions::make([ + FormAction::make('saveFileManagerSettings') + ->label(__('Save')) + ->action('saveFileManagerSettings'), + ]), + ]), + ]; + } + + protected function emailTabContent(): array + { + return [ + Section::make(__('Mail Server')) + ->icon('heroicon-o-envelope') + ->schema([ + Grid::make(['default' => 1, 'md' => 2])->schema([ + TextInput::make('emailData.mail_hostname') + ->label(__('Mail Server Hostname')) + ->placeholder('mail.example.com') + ->helperText(__('The hostname used for mail server identification')), + TextInput::make('emailData.mail_default_quota_mb') + ->label(__('Default Mailbox Quota (MB)')) + ->numeric() + ->minValue(100) + ->maxValue(10240), + TextInput::make('emailData.max_mailboxes_per_domain') + ->label(__('Max Mailboxes Per Domain')) + ->numeric() + ->minValue(1) + ->maxValue(1000), + ]), + ]), + Section::make(__('Webmail')) + ->icon('heroicon-o-globe-alt') + ->schema([ + Grid::make(['default' => 1, 'md' => 2])->schema([ + TextInput::make('emailData.webmail_url') + ->label(__('Webmail URL')) + ->placeholder('/webmail') + ->helperText(__('URL path for Roundcube webmail')), + TextInput::make('emailData.webmail_product_name') + ->label(__('Webmail Product Name')) + ->placeholder('Jabali Webmail') + ->helperText(__('Name displayed on the webmail login page')), + ]), + Actions::make([ + FormAction::make('openWebmail') + ->label(__('Open Webmail')) + ->icon('heroicon-o-arrow-top-right-on-square') + ->color('gray') + ->url('/webmail', shouldOpenInNewTab: true), + FormAction::make('saveEmailSettings') + ->label(__('Save Email Settings')) + ->action('saveEmailSettings'), + ]), + ]), + ]; + } + + protected function notificationsTabContent(): array + { + return [ + Section::make(__('Admin Recipients')) + ->icon('heroicon-o-user-group') + ->schema([ + TextInput::make('notificationsData.admin_email_recipients') + ->label(__('Email Addresses')) + ->placeholder('admin@example.com, alerts@example.com') + ->helperText(__('Comma-separated list of email addresses to receive notifications')), + ]), + Section::make(__('Notification Types')) + ->icon('heroicon-o-bell-alert') + ->schema([ + Grid::make(['default' => 1, 'md' => 2])->schema([ + Toggle::make('notificationsData.notify_ssl_errors') + ->label(__('SSL Certificate Alerts')) + ->helperText(__('Errors and expiring certificates')), + Toggle::make('notificationsData.notify_backup_failures') + ->label(__('Backup Failures')) + ->helperText(__('Failed scheduled backups')), + Toggle::make('notificationsData.notify_backup_success') + ->label(__('Backup Success')) + ->helperText(__('Successful backup completions')), + Toggle::make('notificationsData.notify_disk_quota') + ->label(__('Disk Quota Warnings')) + ->helperText(__('When users reach 90% quota')), + Toggle::make('notificationsData.notify_login_failures') + ->label(__('Login Failure Alerts')) + ->helperText(__('Brute force and Fail2ban alerts')), + Toggle::make('notificationsData.notify_ssh_logins') + ->label(__('SSH Login Alerts')) + ->helperText(__('Successful SSH login notifications')), + Toggle::make('notificationsData.notify_system_updates') + ->label(__('System Updates Available')) + ->helperText(__('When panel updates are available')), + Toggle::make('notificationsData.notify_service_health') + ->label(__('Service Health Alerts')) + ->helperText(__('Service failures and auto-restarts')), + ]), + ]), + Section::make(__('High Load Alerts')) + ->icon('heroicon-o-cpu-chip') + ->schema([ + Grid::make(['default' => 1, 'md' => 3])->schema([ + Toggle::make('notificationsData.notify_high_load') + ->label(__('Enable High Load Alerts')) + ->helperText(__('Alert when server load is high')), + TextInput::make('notificationsData.load_threshold') + ->label(__('Load Threshold')) + ->numeric() + ->minValue(1) + ->maxValue(100) + ->step(0.5) + ->placeholder('5') + ->helperText(__('Alert when load exceeds this value')), + TextInput::make('notificationsData.load_alert_minutes') + ->label(__('Alert After (minutes)')) + ->numeric() + ->minValue(1) + ->maxValue(60) + ->placeholder('5') + ->helperText(__('Minutes of high load before alerting')), + ]), + Actions::make([ + FormAction::make('sendTestEmail') + ->label(__('Send Test Email')) + ->color('gray') + ->action('sendTestEmail'), + FormAction::make('saveEmailNotificationSettings') + ->label(__('Save Notification Settings')) + ->action('saveEmailNotificationSettings'), + ]), + ]), + Section::make(__('Notification Log')) + ->description(__('Last 30 days')) + ->icon('heroicon-o-document-text') + ->schema([ + EmbeddedTable::make(NotificationLogTable::class), + ]), + ]; + } + + protected function phpFpmTabContent(): array + { + return [ + Section::make(__('Default Pool Limits')) + ->description(__('These settings apply to new user pools. Use "Apply to All" to update existing pools.')) + ->icon('heroicon-o-adjustments-horizontal') + ->schema([ + Grid::make(['default' => 1, 'md' => 2, 'lg' => 3])->schema([ + TextInput::make('phpFpmData.pm_max_children') + ->label(__('Max Processes')) + ->numeric() + ->minValue(1) + ->maxValue(50) + ->helperText(__('Max PHP workers per user (1-50)')), + TextInput::make('phpFpmData.pm_max_requests') + ->label(__('Max Requests')) + ->numeric() + ->minValue(50) + ->maxValue(10000) + ->helperText(__('Requests before worker recycle')), + TextInput::make('phpFpmData.memory_limit') + ->label(__('Memory Limit')) + ->placeholder('512M') + ->helperText(__('PHP memory_limit (e.g., 512M, 1G)')), + ]), + Grid::make(['default' => 1, 'md' => 2, 'lg' => 3])->schema([ + TextInput::make('phpFpmData.rlimit_files') + ->label(__('Open Files Limit')) + ->numeric() + ->minValue(256) + ->maxValue(65536) + ->helperText(__('Max open file descriptors')), + TextInput::make('phpFpmData.process_priority') + ->label(__('Process Priority')) + ->numeric() + ->minValue(-20) + ->maxValue(19) + ->helperText(__('Nice value (-20 to 19, lower = higher priority)')), + TextInput::make('phpFpmData.request_terminate_timeout') + ->label(__('Request Timeout (s)')) + ->numeric() + ->minValue(30) + ->maxValue(3600) + ->helperText(__('Kill slow requests after this time')), + ]), + Actions::make([ + FormAction::make('saveFpmSettings') + ->label(__('Save Settings')) + ->action('saveFpmSettings'), + FormAction::make('applyFpmToAll') + ->label(__('Apply to All Users')) + ->color('warning') + ->icon('heroicon-o-arrow-path') + ->requiresConfirmation() + ->modalHeading(__('Apply FPM Settings to All Users')) + ->modalDescription(__('This will update all existing PHP-FPM pool configurations with the current settings. PHP-FPM will be reloaded.')) + ->action('applyFpmToAll'), + ]), + ]), + ]; + } + + protected function getForms(): array + { + return [ + 'settingsForm', + ]; + } + + public function saveBranding(): void + { + $data = $this->brandingData; + + if (empty(trim($data['panel_name'] ?? ''))) { + Notification::make()->title(__('Panel name cannot be empty'))->danger()->send(); + + return; + } + + DnsSetting::set('panel_name', trim($data['panel_name'])); + DnsSetting::clearCache(); + + Notification::make()->title(__('Branding updated'))->body(__('Refresh to see changes.'))->success()->send(); + } + + public function uploadLogo(array $data): void + { + try { + $logo = $data['logo'] ?? null; + if (empty($logo)) { + Notification::make()->title(__('No file selected'))->warning()->send(); + + return; + } + + // Filament FileUpload returns an array of stored file paths + $path = is_array($logo) ? ($logo[0] ?? null) : $logo; + + if ($path) { + // Delete old logo if exists + if ($this->currentLogo && Storage::disk('public')->exists($this->currentLogo)) { + Storage::disk('public')->delete($this->currentLogo); + } + + DnsSetting::set('custom_logo', $path); + DnsSetting::clearCache(); + $this->currentLogo = $path; + + Notification::make()->title(__('Logo uploaded'))->body(__('Refresh to see changes.'))->success()->send(); + } + } catch (Exception $e) { + Notification::make()->title(__('Failed to upload logo'))->body($e->getMessage())->danger()->send(); + } + } + + public function removeLogo(): void + { + try { + if ($this->currentLogo && Storage::disk('public')->exists($this->currentLogo)) { + Storage::disk('public')->delete($this->currentLogo); + } + DnsSetting::set('custom_logo', null); + DnsSetting::clearCache(); + $this->currentLogo = null; + Notification::make()->title(__('Logo removed'))->success()->send(); + } catch (Exception $e) { + Notification::make()->title(__('Failed to remove logo'))->body($e->getMessage())->danger()->send(); + } + } + + public function saveHostname(): void + { + $hostname = $this->hostnameData['hostname'] ?? ''; + + if (empty(trim($hostname))) { + Notification::make()->title(__('Hostname cannot be empty'))->danger()->send(); + + return; + } + + $result = $this->getAgent()->send('server.set_hostname', ['hostname' => $hostname]); + + if (! ($result['success'] ?? false)) { + Notification::make()->title(__('Failed to update hostname'))->body($result['error'] ?? __('Unknown error'))->danger()->send(); + + return; + } + + // Restart or reload affected services + $services = ['postfix', 'dovecot', 'nginx', 'php8.3-fpm', 'named']; + $updatedServices = []; + $failedServices = []; + + foreach ($services as $service) { + $action = $this->shouldReloadService($service) ? 'reload' : 'restart'; + $result = $this->getAgent()->send("service.{$action}", ['service' => $service]); + if ($result['success'] ?? false) { + $updatedServices[] = $service; + } else { + $failedServices[] = $service; + } + } + + if (empty($failedServices)) { + Notification::make() + ->title(__('Hostname updated')) + ->body(__('Affected services have been restarted or reloaded.')) + ->success() + ->send(); + } else { + Notification::make() + ->title(__('Hostname updated')) + ->body(__('Some services failed to restart or reload: :services. If you experience issues, a server reboot may help.', ['services' => implode(', ', $failedServices)])) + ->warning() + ->send(); + } + } + + protected function shouldReloadService(string $service): bool + { + if ($service === 'nginx') { + return true; + } + + return preg_match('/^php(\d+\.\d+)?-fpm$/', $service) === 1; + } + + public function saveDns(): void + { + $data = $this->dnsData; + + DnsSetting::set('ns1', $data['ns1']); + DnsSetting::set('ns1_ip', $data['ns1_ip']); + DnsSetting::set('ns2', $data['ns2']); + DnsSetting::set('ns2_ip', $data['ns2_ip']); + DnsSetting::set('default_ip', $data['default_ip']); + DnsSetting::set('default_ipv6', $data['default_ipv6'] ?: null); + DnsSetting::set('default_ttl', $data['default_ttl']); + DnsSetting::set('admin_email', $data['admin_email']); + DnsSetting::clearCache(); + + $result = $this->getAgent()->send('server.create_zone', [ + 'hostname' => $this->hostnameData['hostname'], + 'ns1' => $data['ns1'], + 'ns1_ip' => $data['ns1_ip'], + 'ns2' => $data['ns2'], + 'ns2_ip' => $data['ns2_ip'], + 'admin_email' => $data['admin_email'], + 'server_ip' => $data['default_ip'], + 'server_ipv6' => $data['default_ipv6'], + 'ttl' => $data['default_ttl'], + ]); + + if ($result['success'] ?? false) { + Notification::make()->title(__('DNS settings saved'))->success()->send(); + } else { + Notification::make()->title(__('Settings saved but zone creation failed'))->body($result['error'] ?? __('Unknown error'))->warning()->send(); + } + } + + public function saveResolvers(): void + { + $data = $this->resolversData; + + try { + $nameservers = array_filter([ + $data['resolver1'], + $data['resolver2'], + $data['resolver3'], + ], fn ($ns) => ! empty(trim($ns ?? ''))); + + if (empty($nameservers)) { + Notification::make()->title(__('Failed to update DNS resolvers'))->body(__('At least one nameserver is required'))->danger()->send(); + + return; + } + + $result = $this->getAgent()->send('server.set_resolvers', [ + 'nameservers' => array_values($nameservers), + 'search_domains' => ! empty($data['search_domain']) ? [$data['search_domain']] : [], + ]); + + if ($result['success'] ?? false) { + Notification::make()->title(__('DNS resolvers updated'))->success()->send(); + } else { + Notification::make()->title(__('Failed to update DNS resolvers'))->body($result['error'] ?? __('Unknown error'))->danger()->send(); + } + } catch (Exception $e) { + Notification::make()->title(__('Failed to update DNS resolvers'))->body($e->getMessage())->danger()->send(); + } + } + + public function saveQuotaSettings(): void + { + $data = $this->quotaData; + $wasEnabled = (bool) DnsSetting::get('quotas_enabled', false); + + DnsSetting::set('quotas_enabled', $data['quotas_enabled'] ? '1' : '0'); + DnsSetting::set('default_quota_mb', (string) $data['default_quota_mb']); + DnsSetting::clearCache(); + + if ($data['quotas_enabled'] && ! $wasEnabled) { + try { + $result = $this->getAgent()->send('quota.enable', ['path' => '/home']); + if ($result['success'] ?? false) { + Notification::make()->title(__('Disk quotas enabled'))->body(__('Quota system has been initialized on /home'))->success()->send(); + } else { + Notification::make()->title(__('Settings saved'))->body(__('Warning: Could not enable quota system on filesystem.'))->warning()->send(); + } + } catch (Exception $e) { + Notification::make()->title(__('Settings saved'))->body(__('Warning: Could not enable quota system.'))->warning()->send(); + } + } else { + Notification::make()->title(__('Quota settings saved'))->success()->send(); + } + } + + public function saveFileManagerSettings(): void + { + $data = $this->fileManagerData; + $size = max(1, min(500, (int) $data['max_upload_size_mb'])); + + DnsSetting::set('max_upload_size_mb', (string) $size); + DnsSetting::clearCache(); + + try { + $result = $this->getAgent()->send('server.set_upload_limits', ['size_mb' => $size]); + if ($result['success'] ?? false) { + Notification::make()->title(__('File manager settings saved'))->body(__('Server upload limits updated to :size MB', ['size' => $size]))->success()->send(); + } else { + Notification::make()->title(__('Settings saved'))->body(__('Database updated but server config update had issues'))->warning()->send(); + } + } catch (Exception $e) { + Notification::make()->title(__('Settings saved'))->body(__('Database updated but could not update server configs'))->warning()->send(); + } + } + + public function saveEmailSettings(): void + { + $data = $this->emailData; + + DnsSetting::set('mail_hostname', $data['mail_hostname']); + DnsSetting::set('mail_default_quota_mb', (string) $data['mail_default_quota_mb']); + DnsSetting::set('max_mailboxes_per_domain', (string) $data['max_mailboxes_per_domain']); + DnsSetting::set('webmail_url', $data['webmail_url']); + DnsSetting::set('webmail_product_name', $data['webmail_product_name']); + DnsSetting::clearCache(); + + // Update Roundcube config + $configFile = '/etc/roundcube/config.inc.php'; + if (file_exists($configFile)) { + try { + $content = file_get_contents($configFile); + $content = preg_replace( + "/\\\$config\['product_name'\]\s*=\s*'[^']*';/", + "\$config['product_name'] = '".addslashes($data['webmail_product_name'])."';", + $content + ); + file_put_contents($configFile, $content); + } catch (Exception $e) { + // Silently fail + } + } + + Notification::make()->title(__('Email settings saved'))->success()->send(); + } + + public function saveEmailNotificationSettings(): void + { + $data = $this->notificationsData; + + if (! empty($data['admin_email_recipients'])) { + $emails = array_map('trim', explode(',', $data['admin_email_recipients'])); + foreach ($emails as $email) { + if (! filter_var($email, FILTER_VALIDATE_EMAIL)) { + Notification::make()->title(__('Invalid recipient email'))->body(__(':email is not a valid email address', ['email' => $email]))->danger()->send(); + + return; + } + } + } + + DnsSetting::set('admin_email_recipients', $data['admin_email_recipients']); + DnsSetting::set('notify_ssl_errors', $data['notify_ssl_errors'] ? '1' : '0'); + DnsSetting::set('notify_backup_failures', $data['notify_backup_failures'] ? '1' : '0'); + DnsSetting::set('notify_backup_success', $data['notify_backup_success'] ? '1' : '0'); + DnsSetting::set('notify_disk_quota', $data['notify_disk_quota'] ? '1' : '0'); + DnsSetting::set('notify_login_failures', $data['notify_login_failures'] ? '1' : '0'); + DnsSetting::set('notify_ssh_logins', $data['notify_ssh_logins'] ? '1' : '0'); + DnsSetting::set('notify_system_updates', $data['notify_system_updates'] ? '1' : '0'); + DnsSetting::set('notify_service_health', $data['notify_service_health'] ? '1' : '0'); + DnsSetting::set('notify_high_load', $data['notify_high_load'] ? '1' : '0'); + DnsSetting::set('load_threshold', (string) max(1, min(100, (float) ($data['load_threshold'] ?? 5)))); + DnsSetting::set('load_alert_minutes', (string) max(1, min(60, (int) ($data['load_alert_minutes'] ?? 5)))); + DnsSetting::clearCache(); + + Notification::make()->title(__('Notification settings saved'))->success()->send(); + } + + public function sendTestEmail(): void + { + $recipients = $this->notificationsData['admin_email_recipients'] ?? ''; + + if (empty($recipients)) { + Notification::make()->title(__('No recipients configured'))->body(__('Please add at least one admin email address'))->warning()->send(); + + return; + } + + try { + $recipientList = array_map('trim', explode(',', $recipients)); + $hostname = gethostname() ?: 'localhost'; + $sender = "webmaster@{$hostname}"; + $subject = __('Test Email'); + $message = __('This is a test email from your Jabali Panel at :hostname.', ['hostname' => $hostname]). + "\n\n".__('If you received this email, your admin notifications are working correctly.'); + + Mail::raw( + $message, + function ($mail) use ($recipientList, $sender, $subject) { + $mail->from($sender, 'Jabali Panel'); + $mail->to($recipientList); + $mail->subject('[Jabali] '.$subject); + } + ); + + // Log the test email + \App\Models\NotificationLog::log( + 'test', + $subject, + $message, + $recipientList, + 'sent' + ); + + Notification::make()->title(__('Test email sent'))->body(__('Check your inbox for the test email'))->success()->send(); + } catch (Exception $e) { + // Log the failed test email + \App\Models\NotificationLog::log( + 'test', + __('Test Email'), + __('This is a test email from your Jabali Panel.'), + array_map('trim', explode(',', $recipients)), + 'failed', + null, + $e->getMessage() + ); + + Notification::make()->title(__('Failed to send test email'))->body($e->getMessage())->danger()->send(); + } + } + + public function saveFpmSettings(): void + { + $data = $this->phpFpmData; + + DnsSetting::set('fpm_pm_max_children', (string) $data['pm_max_children']); + DnsSetting::set('fpm_pm_max_requests', (string) $data['pm_max_requests']); + DnsSetting::set('fpm_rlimit_files', (string) $data['rlimit_files']); + DnsSetting::set('fpm_process_priority', (string) $data['process_priority']); + DnsSetting::set('fpm_request_terminate_timeout', (string) $data['request_terminate_timeout']); + DnsSetting::set('fpm_memory_limit', $data['memory_limit']); + DnsSetting::clearCache(); + + Notification::make() + ->title(__('PHP-FPM settings saved')) + ->body(__('New user pools will use these settings. Use "Apply to All" to update existing pools.')) + ->success() + ->send(); + } + + public function applyFpmToAll(): void + { + $data = $this->phpFpmData; + + try { + $result = $this->getAgent()->send('php.update_all_pool_limits', [ + 'pm_max_children' => (int) $data['pm_max_children'], + 'pm_max_requests' => (int) $data['pm_max_requests'], + 'rlimit_files' => (int) $data['rlimit_files'], + 'process_priority' => (int) $data['process_priority'], + 'request_terminate_timeout' => (int) $data['request_terminate_timeout'], + 'memory_limit' => $data['memory_limit'], + ]); + + if ($result['success'] ?? false) { + $updated = $result['updated'] ?? []; + $errors = $result['errors'] ?? []; + + if (empty($errors)) { + Notification::make() + ->title(__('FPM pools updated')) + ->body(__(':count user pools updated. PHP-FPM will reload.', ['count' => count($updated)])) + ->success() + ->send(); + } else { + Notification::make() + ->title(__('Partial update')) + ->body(__(':success pools updated, :errors failed', [ + 'success' => count($updated), + 'errors' => count($errors), + ])) + ->warning() + ->send(); + } + } else { + Notification::make() + ->title(__('Failed to update pools')) + ->body($result['error'] ?? __('Unknown error')) + ->danger() + ->send(); + } + } catch (Exception $e) { + Notification::make() + ->title(__('Failed to update pools')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + protected function loadVersionInfo(): void + { + $versionFile = base_path('VERSION'); + if (File::exists($versionFile)) { + $content = File::get($versionFile); + if (preg_match('/VERSION=(.+)/', $content, $matches)) { + $this->currentVersion = trim($matches[1]); + if (preg_match('/BUILD=(\d+)/', $content, $buildMatches)) { + $this->currentVersion .= ' ('.__('build').' '.trim($buildMatches[1]).')'; + } + } + } else { + $this->currentVersion = __('Unknown'); + } + } + + public function checkForUpdates(): void + { + $this->isChecking = true; + $this->updatesAvailable = 0; + + try { + $basePath = base_path(); + + if (! is_dir("{$basePath}/.git")) { + throw new Exception(__('Not a git repository.')); + } + + exec("cd {$basePath} && timeout 30 git fetch origin main 2>&1", $fetchOutput, $fetchCode); + + if ($fetchCode !== 0) { + throw new Exception(__('Failed to fetch from repository.')); + } + + $behindCount = trim(shell_exec("cd {$basePath} && git rev-list HEAD..origin/main --count 2>&1") ?? '0'); + $this->updatesAvailable = (int) $behindCount; + + if ($this->updatesAvailable > 0) { + $this->latestVersion = trim(shell_exec("cd {$basePath} && git log origin/main -1 --format='%s' 2>&1") ?? ''); + Notification::make()->title(__('Updates Available'))->body(__(':count update(s) available', ['count' => $this->updatesAvailable]))->warning()->send(); + } else { + Notification::make()->title(__('Up to Date'))->body(__('Running the latest version'))->success()->send(); + } + } catch (Exception $e) { + Notification::make()->title(__('Update Check Failed'))->body($e->getMessage())->danger()->send(); + } + + $this->isChecking = false; + } + + public function performUpgrade(): void + { + $this->isUpgrading = true; + $this->upgradeLog = __('Starting upgrade...')."\n"; + + try { + $exitCode = Artisan::call('jabali:upgrade', ['--force' => true]); + $this->upgradeLog .= Artisan::output(); + if ($exitCode !== 0) { + throw new Exception(__('Upgrade failed. Check the log for details.')); + } + $this->loadVersionInfo(); + $this->updatesAvailable = 0; + Notification::make()->title(__('Upgrade Complete'))->body(__('Refresh to see changes.'))->success()->send(); + } catch (Exception $e) { + $this->upgradeLog .= "\n".__('Error').': '.$e->getMessage(); + Notification::make()->title(__('Upgrade Failed'))->body($e->getMessage())->danger()->send(); + } + + $this->isUpgrading = false; + } + + protected function getHeaderActions(): array + { + return [ + $this->getTourAction(), + Action::make('export_config') + ->label(__('Export')) + ->icon('heroicon-o-arrow-down-tray') + ->color('gray') + ->action(fn () => $this->exportConfig()), + Action::make('import_config') + ->label(__('Import')) + ->icon('heroicon-o-arrow-up-tray') + ->color('gray') + ->modalHeading(__('Import Configuration')) + ->modalDescription(__('Upload a previously exported configuration file. This will overwrite your current settings.')) + ->modalIcon('heroicon-o-arrow-up-tray') + ->modalIconColor('warning') + ->modalSubmitActionLabel(__('Import')) + ->form([ + FileUpload::make('config_file') + ->label(__('Configuration File')) + ->acceptedFileTypes(['application/json']) + ->required() + ->maxSize(1024) + ->helperText(__('Select a .json file exported from Jabali Panel')), + ]) + ->action(fn (array $data) => $this->importConfig($data)), + ]; + } + + public function exportConfig(): \Symfony\Component\HttpFoundation\StreamedResponse + { + $settings = DnsSetting::getAll(); + unset($settings['custom_logo']); + + $exportData = [ + 'version' => '1.0', + 'exported_at' => now()->toIso8601String(), + 'hostname' => gethostname(), + 'settings' => $settings, + ]; + + $filename = 'jabali-config-'.date('Y-m-d-His').'.json'; + $content = json_encode($exportData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + Notification::make()->title(__('Configuration exported'))->success()->send(); + + return Response::streamDownload(function () use ($content) { + echo $content; + }, $filename, ['Content-Type' => 'application/json']); + } + + public function importConfig(array $data): void + { + try { + if (empty($data['config_file'])) { + throw new Exception(__('No file uploaded')); + } + + $filePath = Storage::disk('local')->path($data['config_file']); + + if (! file_exists($filePath)) { + throw new Exception(__('Uploaded file not found')); + } + + $content = file_get_contents($filePath); + $importData = json_decode($content, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new Exception(__('Invalid JSON file: :error', ['error' => json_last_error_msg()])); + } + + if (! isset($importData['settings']) || ! is_array($importData['settings'])) { + throw new Exception(__('Invalid configuration file format')); + } + + $imported = 0; + foreach ($importData['settings'] as $key => $value) { + if (in_array($key, ['custom_logo'])) { + continue; + } + DnsSetting::set($key, $value); + $imported++; + } + + DnsSetting::clearCache(); + Storage::disk('local')->delete($data['config_file']); + $this->mount(); + + Notification::make()->title(__('Configuration imported'))->body(__(':count settings imported successfully', ['count' => $imported]))->success()->send(); + } catch (Exception $e) { + Notification::make()->title(__('Import failed'))->body($e->getMessage())->danger()->send(); + } + } +} diff --git a/app/Filament/Admin/Pages/ServerStatus.php b/app/Filament/Admin/Pages/ServerStatus.php new file mode 100644 index 0000000..18a2ea8 --- /dev/null +++ b/app/Filament/Admin/Pages/ServerStatus.php @@ -0,0 +1,393 @@ +getTourAction(), + ActionGroup::make([ + Action::make('limit25') + ->label(__('Show 25 processes')) + ->icon(fn () => $this->processLimit === 25 ? 'heroicon-o-check' : null) + ->action(fn () => $this->setProcessLimit(25)), + Action::make('limit50') + ->label(__('Show 50 processes')) + ->icon(fn () => $this->processLimit === 50 ? 'heroicon-o-check' : null) + ->action(fn () => $this->setProcessLimit(50)), + Action::make('limit100') + ->label(__('Show 100 processes')) + ->icon(fn () => $this->processLimit === 100 ? 'heroicon-o-check' : null) + ->action(fn () => $this->setProcessLimit(100)), + Action::make('limitAll') + ->label(__('Show all processes')) + ->icon(fn () => $this->processLimit === 0 ? 'heroicon-o-check' : null) + ->action(fn () => $this->setProcessLimit(0)), + ]) + ->label(fn () => __('Process Limit: :limit', ['limit' => $this->processLimit === 0 ? __('All') : $this->processLimit])) + ->icon('heroicon-o-queue-list') + ->color('gray') + ->button(), + ActionGroup::make([ + Action::make('5s') + ->label(__('Every 5 seconds')) + ->icon(fn () => $this->refreshInterval === '5s' ? 'heroicon-o-check' : null) + ->action(fn () => $this->setRefreshInterval('5s')), + Action::make('10s') + ->label(__('Every 10 seconds')) + ->icon(fn () => $this->refreshInterval === '10s' ? 'heroicon-o-check' : null) + ->action(fn () => $this->setRefreshInterval('10s')), + Action::make('30s') + ->label(__('Every 30 seconds')) + ->icon(fn () => $this->refreshInterval === '30s' ? 'heroicon-o-check' : null) + ->action(fn () => $this->setRefreshInterval('30s')), + Action::make('60s') + ->label(__('Every 1 minute')) + ->icon(fn () => $this->refreshInterval === '60s' ? 'heroicon-o-check' : null) + ->action(fn () => $this->setRefreshInterval('60s')), + Action::make('off') + ->label(__('Off')) + ->icon(fn () => $this->refreshInterval === 'off' ? 'heroicon-o-check' : null) + ->action(fn () => $this->setRefreshInterval('off')), + ]) + ->label(fn () => $this->refreshInterval === 'off' + ? __('Auto-refresh: Off') + : __('Auto: :interval', ['interval' => $this->refreshInterval])) + ->icon('heroicon-o-clock') + ->color('gray') + ->button(), + Action::make('refresh') + ->label(fn () => $this->lastUpdated ? __('Refresh (:time)', ['time' => $this->lastUpdated]) : __('Refresh')) + ->icon('heroicon-o-arrow-path') + ->color('primary') + ->action(fn () => $this->loadMetrics()), + ]; + } + + public function setProcessLimit(int $limit): void + { + $this->processLimit = $limit; + $this->loadMetrics(); + } + + public function setRefreshInterval(string $interval): void + { + $this->refreshInterval = $interval; + $this->dispatch('refresh-interval-changed', interval: $interval); + } + + public function getAgent(): AgentClient + { + if ($this->agent === null) { + $this->agent = new AgentClient; + } + + return $this->agent; + } + + public function mount(): void + { + $this->loadMetrics(); + } + + public function loadMetrics(): void + { + try { + $this->overview = $this->getAgent()->metricsOverview(); + $this->disk = $this->getAgent()->metricsDisk()['data'] ?? []; + $this->network = $this->getAgent()->metricsNetwork()['data'] ?? []; + + // Get processes with configurable limit (0 = all) + $limit = $this->processLimit === 0 ? 500 : $this->processLimit; + $processData = $this->getAgent()->metricsProcesses($limit)['data'] ?? []; + $this->processTotal = $processData['total'] ?? 0; + $this->processRunning = $processData['running'] ?? 0; + + if (! empty($processData['top'])) { + ServerProcess::captureProcesses($processData['top'], $this->processTotal); + $this->flushCachedTableRecords(); + } + + $this->lastUpdated = now()->format('H:i:s'); + } catch (Exception $e) { + $this->overview = ['error' => $e->getMessage()]; + } + } + + public function table(Table $table): Table + { + return $table + ->query(ServerProcess::latestBatch()->orderBy('cpu', 'desc')) + ->columns([ + TextColumn::make('pid') + ->label(__('PID')) + ->fontFamily(FontFamily::Mono) + ->sortable() + ->searchable() + ->copyable() + ->copyMessage(__('PID copied')) + ->toggleable(), + TextColumn::make('user') + ->label(__('User')) + ->badge() + ->color(fn ($state) => match ($state) { + 'root' => 'danger', + 'www-data', 'nginx', 'apache' => 'info', + 'mysql', 'postgres' => 'warning', + default => 'gray', + }) + ->sortable() + ->searchable() + ->toggleable(), + TextColumn::make('command') + ->label(__('Command')) + ->limit(40) + ->tooltip(fn (ServerProcess $record) => $record->command) + ->searchable() + ->wrap() + ->toggleable(), + TextColumn::make('cpu') + ->label(__('CPU %')) + ->suffix('%') + ->badge() + ->color(fn ($state) => $state > 50 ? 'danger' : ($state > 20 ? 'warning' : 'gray')) + ->sortable() + ->toggleable(), + TextColumn::make('memory') + ->label(__('Mem %')) + ->suffix('%') + ->badge() + ->color(fn ($state) => $state > 50 ? 'danger' : ($state > 20 ? 'warning' : 'gray')) + ->sortable() + ->toggleable(), + ]) + ->filters([ + SelectFilter::make('user') + ->label(__('User')) + ->options(fn () => ServerProcess::latestBatch() + ->distinct() + ->pluck('user', 'user') + ->toArray() + ) + ->searchable() + ->preload(), + ]) + ->recordActions([ + Action::make('kill') + ->label(__('Kill')) + ->icon('heroicon-o-x-circle') + ->color('danger') + ->requiresConfirmation() + ->modalHeading(__('Kill Process')) + ->modalDescription(fn (ServerProcess $record) => __('Are you sure you want to kill process :pid (:command)?', [ + 'pid' => $record->pid, + 'command' => substr($record->command, 0, 50), + ])) + ->modalIcon('heroicon-o-exclamation-triangle') + ->modalIconColor('danger') + ->form([ + Radio::make('signal') + ->label(__('Signal')) + ->options([ + '15' => __('SIGTERM (15) - Graceful termination'), + '9' => __('SIGKILL (9) - Force kill'), + '1' => __('SIGHUP (1) - Hangup/Reload'), + ]) + ->default('15') + ->required() + ->helperText(__('SIGTERM allows the process to clean up. SIGKILL forces immediate termination.')), + ]) + ->action(fn (ServerProcess $record, array $data) => $this->killProcess($record, (int) $data['signal'])), + ]) + ->selectable() + ->bulkActions([ + BulkAction::make('killSelected') + ->label(__('Kill Selected')) + ->icon('heroicon-o-x-circle') + ->color('danger') + ->requiresConfirmation() + ->modalHeading(__('Kill Selected Processes')) + ->modalDescription(__('Are you sure you want to kill the selected processes? This action cannot be undone.')) + ->modalIcon('heroicon-o-exclamation-triangle') + ->modalIconColor('danger') + ->form([ + Radio::make('signal') + ->label(__('Signal')) + ->options([ + '15' => __('SIGTERM (15) - Graceful termination'), + '9' => __('SIGKILL (9) - Force kill'), + ]) + ->default('15') + ->required(), + ]) + ->action(fn (Collection $records, array $data) => $this->killProcesses($records, (int) $data['signal'])) + ->deselectRecordsAfterCompletion(), + ]) + ->heading(__('Process List')) + ->description(__(':total total processes, :running running', ['total' => $this->processTotal, 'running' => $this->processRunning])) + ->paginated([10, 25, 50, 100]) + ->defaultPaginationPageOption(25) + ->poll($this->refreshInterval === 'off' ? null : $this->refreshInterval) + ->striped() + ->defaultSort('cpu', 'desc') + ->persistFiltersInSession() + ->persistSearchInSession(); + } + + public function killProcess(ServerProcess $process, int $signal = 15): void + { + try { + $result = $this->getAgent()->send('system.kill_process', [ + 'pid' => $process->pid, + 'signal' => $signal, + ]); + + if ($result['success'] ?? false) { + Notification::make() + ->title(__('Process killed')) + ->body(__('Process :pid has been terminated with signal :signal.', [ + 'pid' => $process->pid, + 'signal' => $signal, + ])) + ->success() + ->send(); + + // Refresh the process list + $this->loadMetrics(); + } else { + throw new Exception($result['error'] ?? __('Unknown error')); + } + } catch (Exception $e) { + Notification::make() + ->title(__('Failed to kill process')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function killProcesses(Collection $records, int $signal = 15): void + { + $killed = 0; + $failed = 0; + + foreach ($records as $process) { + try { + $result = $this->getAgent()->send('system.kill_process', [ + 'pid' => $process->pid, + 'signal' => $signal, + ]); + + if ($result['success'] ?? false) { + $killed++; + } else { + $failed++; + } + } catch (Exception $e) { + $failed++; + } + } + + if ($killed > 0) { + Notification::make() + ->title(__('Processes killed')) + ->body(__(':count process(es) terminated successfully.', ['count' => $killed])) + ->success() + ->send(); + } + + if ($failed > 0) { + Notification::make() + ->title(__('Some processes failed')) + ->body(__(':count process(es) could not be killed.', ['count' => $failed])) + ->warning() + ->send(); + } + + // Refresh the process list + $this->loadMetrics(); + } + + public function refresh(): void + { + $this->loadMetrics(); + } + + public function getListeners(): array + { + return [ + 'refresh' => 'loadMetrics', + ]; + } +} diff --git a/app/Filament/Admin/Pages/Services.php b/app/Filament/Admin/Pages/Services.php new file mode 100644 index 0000000..a3f078d --- /dev/null +++ b/app/Filament/Admin/Pages/Services.php @@ -0,0 +1,332 @@ + ['name' => 'Nginx', 'description' => 'Web Server', 'icon' => 'globe'], + 'mariadb' => ['name' => 'MariaDB', 'description' => 'Database Server', 'icon' => 'database'], + 'redis-server' => ['name' => 'Redis', 'description' => 'Cache Server', 'icon' => 'bolt'], + 'postfix' => ['name' => 'Postfix', 'description' => 'Mail Transfer Agent', 'icon' => 'envelope'], + 'dovecot' => ['name' => 'Dovecot', 'description' => 'IMAP/POP3 Server', 'icon' => 'inbox'], + 'rspamd' => ['name' => 'Rspamd', 'description' => 'Spam Filter', 'icon' => 'shield'], + 'clamav-daemon' => ['name' => 'ClamAV', 'description' => 'Antivirus Scanner', 'icon' => 'bug'], + 'named' => ['name' => 'BIND9', 'description' => 'DNS Server', 'icon' => 'server'], + 'opendkim' => ['name' => 'OpenDKIM', 'description' => 'DKIM Signing', 'icon' => 'key'], + 'fail2ban' => ['name' => 'Fail2Ban', 'description' => 'Intrusion Prevention', 'icon' => 'lock'], + 'ssh' => ['name' => 'SSH', 'description' => 'Secure Shell', 'icon' => 'terminal'], + 'cron' => ['name' => 'Cron', 'description' => 'Task Scheduler', 'icon' => 'clock'], + ]; + + protected ?array $managedServices = null; + + protected function getManagedServices(): array + { + if ($this->managedServices !== null) { + return $this->managedServices; + } + + $this->managedServices = []; + foreach ($this->baseServices as $key => $config) { + $this->managedServices[$key] = $config; + + if ($key === 'nginx') { + foreach ($this->detectPhpFpmVersions() as $service => $phpConfig) { + $this->managedServices[$service] = $phpConfig; + } + } + } + + return $this->managedServices; + } + + protected function detectPhpFpmVersions(): array + { + $phpServices = []; + $output = []; + exec('ls /lib/systemd/system/php*-fpm.service 2>/dev/null', $output); + + foreach ($output as $servicePath) { + if (preg_match('/php([\d.]+)-fpm\.service$/', $servicePath, $matches)) { + $version = $matches[1]; + $serviceName = "php{$version}-fpm"; + $phpServices[$serviceName] = [ + 'name' => "PHP {$version} FPM", + 'description' => 'PHP FastCGI Process Manager', + 'icon' => 'code', + ]; + } + } + + uksort($phpServices, function ($a, $b) { + preg_match('/php([\d.]+)-fpm/', $a, $matchA); + preg_match('/php([\d.]+)-fpm/', $b, $matchB); + + return version_compare($matchB[1] ?? '0', $matchA[1] ?? '0'); + }); + + return $phpServices; + } + + public function getTitle(): string|Htmlable + { + return __('Service Manager'); + } + + public function getAgent(): AgentClient + { + return $this->agent ??= new AgentClient; + } + + public function mount(): void + { + $this->loadServices(); + } + + public function loadServices(): void + { + $managedServices = $this->getManagedServices(); + + try { + $result = $this->getAgent()->send('service.list', [ + 'services' => array_keys($managedServices), + ]); + + if ($result['success'] ?? false) { + $this->services = []; + foreach ($result['services'] ?? [] as $name => $status) { + $config = $managedServices[$name] ?? [ + 'name' => ucfirst($name), + 'description' => '', + 'icon' => 'cog', + ]; + $this->services[$name] = array_merge($config, [ + 'service' => $name, + 'is_active' => $status['is_active'] ?? false, + 'is_enabled' => $status['is_enabled'] ?? false, + 'status' => $status['status'] ?? 'unknown', + ]); + } + } + } catch (Exception $e) { + Notification::make()->title(__('Error loading services'))->body($e->getMessage())->danger()->send(); + } + } + + public function table(Table $table): Table + { + return $table + ->records(fn () => array_values($this->services)) + ->columns([ + TextColumn::make('name') + ->label(__('Service')) + ->icon(fn (array $record): string => match ($record['icon'] ?? 'cog') { + 'globe' => 'heroicon-o-globe-alt', + 'code' => 'heroicon-o-code-bracket', + 'database' => 'heroicon-o-circle-stack', + 'bolt' => 'heroicon-o-bolt', + 'envelope' => 'heroicon-o-envelope', + 'inbox' => 'heroicon-o-inbox', + 'shield' => 'heroicon-o-shield-check', + 'server' => 'heroicon-o-server', + 'key' => 'heroicon-o-key', + 'lock' => 'heroicon-o-lock-closed', + 'terminal' => 'heroicon-o-command-line', + 'clock' => 'heroicon-o-clock', + 'bug' => 'heroicon-o-bug-ant', + default => 'heroicon-o-cog-6-tooth', + }) + ->iconColor(fn (array $record): string => $record['is_active'] ? 'success' : 'danger') + ->description(fn (array $record): string => $record['description'] ?? '') + ->weight('medium') + ->searchable(), + TextColumn::make('is_active') + ->label(__('Status')) + ->badge() + ->formatStateUsing(fn (array $record): string => $record['is_active'] ? __('Running') : __('Stopped')) + ->color(fn (array $record): string => $record['is_active'] ? 'success' : 'danger'), + TextColumn::make('is_enabled') + ->label(__('Boot')) + ->badge() + ->formatStateUsing(fn (array $record): string => $record['is_enabled'] ? __('Enabled') : __('Disabled')) + ->color(fn (array $record): string => $record['is_enabled'] ? 'success' : 'warning'), + ]) + ->recordActions([ + Action::make('start') + ->label(__('Start')) + ->icon('heroicon-o-play') + ->color('success') + ->size('sm') + ->visible(fn (array $record): bool => ! $record['is_active']) + ->action(fn (array $record) => $this->executeServiceAction($record['service'], 'start')), + Action::make('stop') + ->label(__('Stop')) + ->icon('heroicon-o-stop') + ->color('danger') + ->size('sm') + ->visible(fn (array $record): bool => $record['is_active']) + ->requiresConfirmation() + ->modalHeading(__('Stop Service')) + ->modalDescription(fn (array $record): string => __('Are you sure you want to stop :service? This may affect running websites and services.', ['service' => $record['name']])) + ->modalSubmitActionLabel(__('Stop Service')) + ->action(fn (array $record) => $this->executeServiceAction($record['service'], 'stop')), + Action::make('restart') + ->label(fn (array $record): string => $this->shouldReloadService($record['service']) ? __('Reload') : __('Restart')) + ->icon('heroicon-o-arrow-path') + ->color('info') + ->size('sm') + ->visible(fn (array $record): bool => $record['is_active']) + ->action(fn (array $record) => $this->executeServiceAction( + $record['service'], + $this->shouldReloadService($record['service']) ? 'reload' : 'restart' + )), + Action::make('enable') + ->label(__('Enable')) + ->icon('heroicon-o-check') + ->color('gray') + ->size('sm') + ->visible(fn (array $record): bool => ! $record['is_enabled']) + ->action(fn (array $record) => $this->executeServiceAction($record['service'], 'enable')), + Action::make('disable') + ->label(__('Disable')) + ->icon('heroicon-o-x-mark') + ->color('warning') + ->size('sm') + ->visible(fn (array $record): bool => $record['is_enabled']) + ->requiresConfirmation() + ->modalHeading(__('Disable Service')) + ->modalDescription(fn (array $record): string => __("Are you sure you want to disable :service? It won't start automatically on boot.", ['service' => $record['name']])) + ->modalSubmitActionLabel(__('Disable Service')) + ->action(fn (array $record) => $this->executeServiceAction($record['service'], 'disable')), + ]) + ->headerActions([ + Action::make('refresh') + ->label(__('Refresh')) + ->icon('heroicon-o-arrow-path') + ->color('gray') + ->action(function () { + $this->loadServices(); + $this->resetTable(); + Notification::make()->title(__('Services refreshed'))->success()->duration(1500)->send(); + }), + ]) + ->emptyStateHeading(__('No services found')) + ->emptyStateDescription(__('Unable to load system services')) + ->emptyStateIcon('heroicon-o-cog-6-tooth') + ->striped(); + } + + public function getTableRecordKey(Model|array $record): string + { + return is_array($record) ? $record['service'] : $record->getKey(); + } + + protected function executeServiceAction(string $service, string $action): void + { + try { + $result = $this->getAgent()->send("service.{$action}", [ + 'service' => $service, + ]); + + if ($result['success'] ?? false) { + $notificationTitle = match ($action) { + 'start' => __(':service started', ['service' => ucfirst($service)]), + 'stop' => __(':service stopped', ['service' => ucfirst($service)]), + 'restart' => __(':service restarted', ['service' => ucfirst($service)]), + 'reload' => __(':service reloaded', ['service' => ucfirst($service)]), + 'enable' => __(':service enabled', ['service' => ucfirst($service)]), + 'disable' => __(':service disabled', ['service' => ucfirst($service)]), + default => ucfirst($service).' '.$action + }; + + $actionPast = match ($action) { + 'start' => 'started', + 'stop' => 'stopped', + 'restart' => 'restarted', + 'reload' => 'reloaded', + 'enable' => 'enabled', + 'disable' => 'disabled', + default => $action + }; + + Notification::make() + ->title($notificationTitle) + ->success() + ->send(); + + AuditLog::logServiceAction($actionPast, $service); + + $this->loadServices(); + $this->resetTable(); + } else { + throw new Exception($result['error'] ?? $result['message'] ?? __('Unknown error')); + } + } catch (Exception $e) { + Notification::make() + ->title(__('Action failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + protected function getHeaderActions(): array + { + return [ + $this->getTourAction(), + ]; + } + + protected function shouldReloadService(string $service): bool + { + if ($service === 'nginx') { + return true; + } + + return preg_match('/^php(\d+\.\d+)?-fpm$/', $service) === 1; + } +} diff --git a/app/Filament/Admin/Pages/SslManager.php b/app/Filament/Admin/Pages/SslManager.php new file mode 100644 index 0000000..11bcd02 --- /dev/null +++ b/app/Filament/Admin/Pages/SslManager.php @@ -0,0 +1,592 @@ +agent === null) { + $this->agent = new AgentClient; + } + + return $this->agent; + } + + public function mount(): void + { + $this->lastUpdated = now()->format('H:i:s'); + } + + public function table(Table $table): Table + { + return $table + ->query(Domain::with(['user', 'sslCertificate'])) + ->columns([ + TextColumn::make('domain') + ->label(__('Domain')) + ->searchable() + ->sortable() + ->description(fn (Domain $record) => $record->user?->username ?? __('Unknown')), + TextColumn::make('sslCertificate.type') + ->label(__('Type')) + ->badge() + ->color('gray') + ->formatStateUsing(fn ($state) => $state ? ucfirst(str_replace('_', ' ', $state)) : __('No SSL')), + TextColumn::make('sslCertificate.status') + ->label(__('Status')) + ->badge() + ->color(fn ($state) => match ($state) { + 'active' => 'success', + 'expired' => 'danger', + 'expiring' => 'warning', + 'failed' => 'danger', + default => 'gray', + }) + ->formatStateUsing(fn ($state) => $state ? ucfirst($state) : __('No Certificate')), + TextColumn::make('sslCertificate.expires_at') + ->label(__('Expires')) + ->date('M d, Y') + ->description(fn (Domain $record) => $record->sslCertificate?->days_until_expiry !== null + ? __(':days days', ['days' => $record->sslCertificate->days_until_expiry]) + : null) + ->color(fn (Domain $record) => match (true) { + $record->sslCertificate?->days_until_expiry <= 7 => 'danger', + $record->sslCertificate?->days_until_expiry <= 30 => 'warning', + default => 'gray', + }), + TextColumn::make('sslCertificate.last_check_at') + ->label(__('Last Check')) + ->since() + ->sortable(), + TextColumn::make('sslCertificate.last_error') + ->label(__('Error')) + ->limit(30) + ->tooltip(fn ($state) => $state) + ->color('danger') + ->placeholder('-'), + ]) + ->filters([ + SelectFilter::make('ssl_status') + ->label(__('Status')) + ->options([ + 'active' => __('Active'), + 'no_ssl' => __('No SSL'), + 'expiring' => __('Expiring Soon'), + 'expired' => __('Expired'), + 'failed' => __('Failed'), + ]) + ->query(function (Builder $query, array $data) { + if (! $data['value']) { + return $query; + } + + return match ($data['value']) { + 'active' => $query->whereHas('sslCertificate', fn ($q) => $q->where('status', 'active')), + 'no_ssl' => $query->whereDoesntHave('sslCertificate'), + 'expiring' => $query->whereHas('sslCertificate', fn ($q) => $q->where('status', 'active') + ->where('expires_at', '<=', now()->addDays(30)) + ->where('expires_at', '>', now())), + 'expired' => $query->whereHas('sslCertificate', fn ($q) => $q->where('status', 'expired') + ->orWhere('expires_at', '<', now())), + 'failed' => $query->whereHas('sslCertificate', fn ($q) => $q->where('status', 'failed')), + default => $query, + }; + }), + SelectFilter::make('user_id') + ->label(__('User')) + ->relationship('user', 'username'), + ]) + ->recordActions([ + Action::make('issue') + ->label(__('Issue')) + ->icon('heroicon-o-lock-closed') + ->color('success') + ->visible(fn (Domain $record) => ! $record->sslCertificate || $record->sslCertificate->status === 'failed') + ->action(fn (Domain $record) => $this->issueSslForDomain($record->id)), + Action::make('renew') + ->label(__('Renew')) + ->icon('heroicon-o-arrow-path') + ->color('primary') + ->visible(fn (Domain $record) => $record->sslCertificate?->type === 'lets_encrypt' && $record->sslCertificate?->status === 'active') + ->action(fn (Domain $record) => $this->renewSslForDomain($record->id)), + Action::make('check') + ->label(__('Check')) + ->icon('heroicon-o-magnifying-glass') + ->color('gray') + ->action(fn (Domain $record) => $this->checkSslForDomain($record->id)), + ]) + ->heading(__('Domain Certificates')) + ->poll('30s'); + } + + public function issueSslForDomain(int $domainId): void + { + try { + $domain = Domain::with('user')->findOrFail($domainId); + + $result = $this->getAgent()->sslIssue( + $domain->domain, + $domain->user->username, + $domain->user->email, + true + ); + + if ($result['success'] ?? false) { + SslCertificate::updateOrCreate( + ['domain_id' => $domain->id], + [ + 'type' => 'lets_encrypt', + 'status' => 'active', + 'issuer' => "Let's Encrypt", + 'certificate' => $result['certificate'] ?? null, + 'issued_at' => now(), + 'expires_at' => isset($result['valid_to']) ? \Carbon\Carbon::parse($result['valid_to']) : now()->addMonths(3), + 'last_check_at' => now(), + 'last_error' => null, + 'renewal_attempts' => 0, + 'auto_renew' => true, + ] + ); + + $domain->update(['ssl_enabled' => true]); + + Notification::make() + ->title(__('SSL Certificate Issued')) + ->body(__('Certificate issued for :domain', ['domain' => $domain->domain])) + ->success() + ->send(); + } else { + SslCertificate::updateOrCreate( + ['domain_id' => $domain->id], + [ + 'type' => 'lets_encrypt', + 'status' => 'failed', + 'last_check_at' => now(), + 'last_error' => $result['error'] ?? __('Unknown error'), + ] + ); + + Notification::make() + ->title(__('SSL Certificate Failed')) + ->body($result['error'] ?? __('Unknown error')) + ->danger() + ->send(); + } + } catch (Exception $e) { + Notification::make() + ->title(__('Error')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + + $this->lastUpdated = now()->format('H:i:s'); + } + + public function renewSslForDomain(int $domainId): void + { + try { + $domain = Domain::with('user')->findOrFail($domainId); + + $result = $this->getAgent()->sslRenew($domain->domain, $domain->user->username); + + if ($result['success'] ?? false) { + $ssl = $domain->sslCertificate; + if ($ssl) { + $ssl->update([ + 'status' => 'active', + 'issued_at' => now(), + 'expires_at' => isset($result['valid_to']) ? \Carbon\Carbon::parse($result['valid_to']) : now()->addMonths(3), + 'last_check_at' => now(), + 'last_error' => null, + 'renewal_attempts' => 0, + ]); + } + + Notification::make() + ->title(__('Certificate Renewed')) + ->body(__('SSL certificate renewed for :domain', ['domain' => $domain->domain])) + ->success() + ->send(); + } else { + Notification::make() + ->title(__('Renewal Failed')) + ->body($result['error'] ?? __('Unknown error')) + ->danger() + ->send(); + } + } catch (Exception $e) { + Notification::make() + ->title(__('Error')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + + $this->lastUpdated = now()->format('H:i:s'); + } + + public function checkSslForDomain(int $domainId): void + { + try { + $domain = Domain::with('user')->findOrFail($domainId); + + $result = $this->getAgent()->sslCheck($domain->domain, $domain->user->username); + + if ($result['success'] ?? false) { + $sslData = $result['ssl'] ?? []; + + if ($sslData['has_ssl'] ?? false) { + SslCertificate::updateOrCreate( + ['domain_id' => $domain->id], + [ + 'type' => $sslData['type'] ?? 'custom', + 'status' => ($sslData['is_expired'] ?? false) ? 'expired' : 'active', + 'issuer' => $sslData['issuer'], + 'certificate' => $sslData['certificate'] ?? null, + 'issued_at' => isset($sslData['valid_from']) ? \Carbon\Carbon::parse($sslData['valid_from']) : null, + 'expires_at' => isset($sslData['valid_to']) ? \Carbon\Carbon::parse($sslData['valid_to']) : null, + 'last_check_at' => now(), + ] + ); + $domain->update(['ssl_enabled' => true]); + } + + Notification::make() + ->title(__('Certificate Checked')) + ->body($sslData['has_ssl'] ? __('Found: :issuer', ['issuer' => $sslData['issuer']]) : __('No certificate found')) + ->success() + ->send(); + } else { + Notification::make() + ->title(__('Check Failed')) + ->body($result['error'] ?? __('Unknown error')) + ->danger() + ->send(); + } + } catch (Exception $e) { + Notification::make() + ->title(__('Error')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + + $this->lastUpdated = now()->format('H:i:s'); + } + + public function runAutoSsl(?string $domain = null): void + { + $this->isRunning = true; + $this->autoSslLog = ''; + + try { + // Ensure log directory exists with proper permissions + $logDir = storage_path('logs/ssl'); + if (! is_dir($logDir)) { + @mkdir($logDir, 0775, true); + } + + $params = []; + if ($domain) { + $params['--domain'] = $domain; + } + + Artisan::call('jabali:ssl-check', $params); + $this->autoSslLog = Artisan::output(); + + Notification::make() + ->title(__('SSL Check Complete')) + ->body($domain + ? __('SSL check completed for :domain', ['domain' => $domain]) + : __('SSL certificate check completed for all domains')) + ->success() + ->send(); + } catch (Exception $e) { + $this->autoSslLog = __('Error: :message', ['message' => $e->getMessage()]); + + Notification::make() + ->title(__('SSL Check Failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + + $this->isRunning = false; + $this->lastUpdated = now()->format('H:i:s'); + } + + public function runSslCheckForUser(int $userId): void + { + $this->isRunning = true; + $this->autoSslLog = ''; + + try { + $user = User::findOrFail($userId); + $domains = Domain::where('user_id', $userId)->pluck('domain')->toArray(); + + if (empty($domains)) { + $this->autoSslLog = __('No domains found for user :user', ['user' => $user->username]); + Notification::make() + ->title(__('No Domains')) + ->body(__('User :user has no domains', ['user' => $user->username])) + ->warning() + ->send(); + $this->isRunning = false; + + return; + } + + $this->autoSslLog = __('Checking SSL for :count domains of user :user', ['count' => count($domains), 'user' => $user->username])."\n\n"; + + foreach ($domains as $domain) { + Artisan::call('jabali:ssl-check', ['--domain' => $domain]); + $this->autoSslLog .= Artisan::output()."\n"; + } + + Notification::make() + ->title(__('SSL Check Complete')) + ->body(__('SSL check completed for :count domains of user :user', ['count' => count($domains), 'user' => $user->username])) + ->success() + ->send(); + } catch (Exception $e) { + $this->autoSslLog = __('Error: :message', ['message' => $e->getMessage()]); + + Notification::make() + ->title(__('SSL Check Failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + + $this->isRunning = false; + $this->lastUpdated = now()->format('H:i:s'); + } + + public function issueAllPending(): void + { + $domainsWithoutSsl = Domain::whereDoesntHave('sslCertificate') + ->orWhereHas('sslCertificate', function ($q) { + $q->where('status', 'failed'); + }) + ->with('user') + ->get(); + + $issued = 0; + $failed = 0; + + foreach ($domainsWithoutSsl as $domain) { + try { + $result = $this->getAgent()->sslIssue( + $domain->domain, + $domain->user->username, + $domain->user->email, + true + ); + + if ($result['success'] ?? false) { + SslCertificate::updateOrCreate( + ['domain_id' => $domain->id], + [ + 'type' => 'lets_encrypt', + 'status' => 'active', + 'issuer' => "Let's Encrypt", + 'certificate' => $result['certificate'] ?? null, + 'issued_at' => now(), + 'expires_at' => isset($result['valid_to']) ? \Carbon\Carbon::parse($result['valid_to']) : now()->addMonths(3), + 'last_check_at' => now(), + 'last_error' => null, + 'renewal_attempts' => 0, + 'auto_renew' => true, + ] + ); + $domain->update(['ssl_enabled' => true]); + $issued++; + } else { + SslCertificate::updateOrCreate( + ['domain_id' => $domain->id], + [ + 'type' => 'lets_encrypt', + 'status' => 'failed', + 'last_check_at' => now(), + 'last_error' => $result['error'] ?? __('Unknown error'), + ] + ); + $failed++; + } + } catch (Exception $e) { + $failed++; + } + } + + Notification::make() + ->title(__('Bulk SSL Issuance Complete')) + ->body(__('Issued: :issued, Failed: :failed', ['issued' => $issued, 'failed' => $failed])) + ->success() + ->send(); + + $this->lastUpdated = now()->format('H:i:s'); + } + + public function getLetsEncryptLog(): string + { + $logFiles = [ + '/var/log/letsencrypt/letsencrypt.log', + '/var/log/certbot/letsencrypt.log', + '/var/log/certbot.log', + ]; + + $logContent = ''; + $foundFile = null; + + foreach ($logFiles as $logFile) { + if (file_exists($logFile)) { + $foundFile = $logFile; + $lines = file($logFile); + $lastLines = array_slice($lines, -500); + $logContent .= "=== {$logFile} ===\n".implode('', $lastLines); + break; + } + } + + if (! $foundFile) { + $certbotLogs = glob('/var/log/letsencrypt/*.log'); + if (! empty($certbotLogs)) { + $foundFile = end($certbotLogs); + $lines = file($foundFile); + $lastLines = array_slice($lines, -500); + $logContent = "=== {$foundFile} ===\n".implode('', $lastLines); + } + } + + if (! $foundFile) { + return __("No Let's Encrypt/Certbot log files found.")."\n\n".__('Searched locations:')."\n".implode("\n", $logFiles)."\n/var/log/letsencrypt/*.log"; + } + + return $logContent; + } + + protected function getHeaderActions(): array + { + return [ + $this->getTourAction(), + Action::make('runAutoSsl') + ->label(__('Run SSL Check')) + ->icon('heroicon-o-play') + ->color('success') + ->modalHeading(__('Run SSL Check')) + ->modalDescription(__('Check SSL certificates and automatically issue/renew them.')) + ->modalWidth('md') + ->form([ + Select::make('scope') + ->label(__('Scope')) + ->options([ + 'all' => __('All Domains'), + 'user' => __('Specific User'), + 'domain' => __('Specific Domain'), + ]) + ->default('all') + ->live() + ->required(), + Select::make('user_id') + ->label(__('User')) + ->options(fn () => User::pluck('username', 'id')->toArray()) + ->searchable() + ->visible(fn ($get) => $get('scope') === 'user') + ->required(fn ($get) => $get('scope') === 'user'), + Select::make('domain') + ->label(__('Domain')) + ->options(fn () => Domain::pluck('domain', 'domain')->toArray()) + ->searchable() + ->visible(fn ($get) => $get('scope') === 'domain') + ->required(fn ($get) => $get('scope') === 'domain'), + ]) + ->action(function (array $data): void { + match ($data['scope']) { + 'user' => $this->runSslCheckForUser((int) $data['user_id']), + 'domain' => $this->runAutoSsl($data['domain']), + default => $this->runAutoSsl(), + }; + }), + Action::make('issueAllPending') + ->label(__('Issue All Pending')) + ->icon('heroicon-o-shield-check') + ->color('primary') + ->requiresConfirmation() + ->modalHeading(__('Issue SSL for All Pending Domains')) + ->modalDescription(__('This will attempt to issue SSL certificates for all domains without active certificates. This may take a while.')) + ->action(fn () => $this->issueAllPending()), + Action::make('viewLog') + ->label(__('View Log')) + ->icon('heroicon-o-document-text') + ->color('gray') + ->modalHeading(__("Let's Encrypt Log")) + ->modalWidth('4xl') + ->modalContent(fn () => view('filament.admin.pages.ssl-log-modal', ['log' => $this->getLetsEncryptLog()])) + ->modalSubmitAction(false) + ->modalCancelActionLabel(__('Close')), + ]; + } +} diff --git a/app/Filament/Admin/Pages/WhmMigration.php b/app/Filament/Admin/Pages/WhmMigration.php new file mode 100644 index 0000000..c016eab --- /dev/null +++ b/app/Filament/Admin/Pages/WhmMigration.php @@ -0,0 +1,1267 @@ + ['jabali_username' => '', 'email' => '', 'password' => ''] + + // Migration status (Step 4) + public bool $isMigrating = false; + + public array $migrationStatus = []; // user => ['status' => 'pending|processing|completed|error', 'log' => [], 'progress' => 0] + + public int $currentAccountIndex = 0; + + public int $totalAccounts = 0; + + public array $statusLog = []; + + protected ?AgentClient $agent = null; + + protected ?WhmApiService $whm = null; + + public function getTitle(): string|Htmlable + { + return __('WHM Migration'); + } + + public function getSubheading(): ?string + { + return __('Migrate multiple cPanel accounts from a WHM server to Jabali'); + } + + protected function getHeaderActions(): array + { + return [ + Action::make('startOver') + ->label(__('Start Over')) + ->icon('heroicon-o-arrow-path') + ->color('gray') + ->requiresConfirmation() + ->modalHeading(__('Start Over')) + ->modalDescription(__('This will reset all migration data. Are you sure?')) + ->action('resetMigration'), + ]; + } + + public function mount(): void + { + $this->restoreFromSession(); + $this->loadMigrationStatusFromStore(); + + // Initialize accountConfig if we have selectedAccounts but no config + // This handles direct URL navigation to step 3 + if (! empty($this->selectedAccounts) && empty($this->accountConfig)) { + $this->initializeAccountConfig(); + $this->saveToSession(); + } + } + + public function updatedHostname(): void + { + $this->resetConnection(); + } + + public function updatedWhmUsername(): void + { + $this->resetConnection(); + } + + public function updatedApiToken(): void + { + $this->resetConnection(); + } + + public function updatedPort(): void + { + $this->resetConnection(); + } + + public function updatedUseSSL(): void + { + $this->resetConnection(); + } + + protected function resetConnection(): void + { + $this->whm = null; + $this->isConnected = false; + $this->serverInfo = []; + $this->accounts = []; + $this->selectedAccounts = []; + } + + public function getAgent(): AgentClient + { + return $this->agent ??= new AgentClient; + } + + public function getMigrationCacheKey(): string + { + $userId = auth()->id() ?? 0; + + return 'whm_migration_status_'.$userId; + } + + protected function getMigrationStatusStore(): WhmMigrationStatusStore + { + return new WhmMigrationStatusStore($this->getMigrationCacheKey()); + } + + protected function loadMigrationStatusFromStore(): void + { + $state = $this->getMigrationStatusStore()->get(); + if ($state === []) { + return; + } + + $this->migrationStatus = $state['migrationStatus'] ?? $this->migrationStatus; + $this->isMigrating = (bool) ($state['isMigrating'] ?? $this->isMigrating); + $this->selectedAccounts = $state['selectedAccounts'] ?? $this->selectedAccounts; + } + + protected function getWhm(): ?WhmApiService + { + if (! $this->hostname || ! $this->whmUsername || ! $this->apiToken) { + $this->restoreFromSession(); + } + + if (! $this->hostname || ! $this->whmUsername || ! $this->apiToken) { + return null; + } + + return $this->whm ??= new WhmApiService( + trim($this->hostname), + trim($this->whmUsername), + trim($this->apiToken), + $this->port, + $this->useSSL + ); + } + + protected function saveToSession(): void + { + session()->put('whm_migration.hostname', $this->hostname); + session()->put('whm_migration.username', $this->whmUsername); + session()->put('whm_migration.token', $this->apiToken); + session()->put('whm_migration.port', $this->port); + session()->put('whm_migration.useSSL', $this->useSSL); + session()->put('whm_migration.isConnected', $this->isConnected); + session()->put('whm_migration.serverInfo', $this->serverInfo); + session()->put('whm_migration.accounts', $this->accounts); + session()->put('whm_migration.selectedAccounts', $this->selectedAccounts); + session()->put('whm_migration.accountConfig', $this->accountConfig); + session()->put('whm_migration.step1Complete', $this->step1Complete); + session()->put('whm_migration.step2Complete', $this->step2Complete); + session()->put('whm_migration.migrationStatus', $this->migrationStatus); + session()->put('whm_migration.isMigrating', $this->isMigrating); + session()->save(); + } + + protected function restoreFromSession(): void + { + if (session()->has('whm_migration.hostname')) { + $this->hostname = session('whm_migration.hostname'); + $this->whmUsername = session('whm_migration.username', 'root'); + $this->apiToken = session('whm_migration.token'); + $this->port = session('whm_migration.port', 2087); + $this->useSSL = session('whm_migration.useSSL', true); + $this->isConnected = session('whm_migration.isConnected', false); + $this->serverInfo = session('whm_migration.serverInfo', []); + $this->accounts = session('whm_migration.accounts', []); + $this->selectedAccounts = session('whm_migration.selectedAccounts', []); + $this->accountConfig = session('whm_migration.accountConfig', []); + $this->step1Complete = session('whm_migration.step1Complete', false); + $this->step2Complete = session('whm_migration.step2Complete', false); + $this->migrationStatus = session('whm_migration.migrationStatus', []); + } + } + + protected function clearSession(): void + { + session()->forget([ + 'whm_migration.hostname', + 'whm_migration.username', + 'whm_migration.token', + 'whm_migration.port', + 'whm_migration.useSSL', + 'whm_migration.isConnected', + 'whm_migration.serverInfo', + 'whm_migration.accounts', + 'whm_migration.selectedAccounts', + 'whm_migration.accountConfig', + 'whm_migration.step1Complete', + 'whm_migration.step2Complete', + 'whm_migration.migrationStatus', + ]); + } + + protected function getBackupDestPath(): string + { + return '/var/backups/jabali/whm-migrations'; + } + + protected function getJabaliPublicIp(): string + { + $ip = trim(shell_exec('curl -s ifconfig.me 2>/dev/null') ?? ''); + + if (empty($ip)) { + $ip = gethostbyname(gethostname()); + } + + return $ip; + } + + protected function getSshKeyName(): string + { + return 'jabali-system-key'; + } + + protected function getForms(): array + { + return ['migrationForm']; + } + + public function migrationForm(Schema $schema): Schema + { + return $schema->schema([ + Wizard::make([ + $this->getConnectStep(), + $this->getSelectAccountsStep(), + $this->getConfigureStep(), + $this->getMigrateStep(), + ]) + ->nextAction( + fn (Action $action) => $action + ->disabled(fn () => $this->isNextStepDisabled()) + ->hidden(fn () => $this->isNextButtonHidden()) + ) + ->persistStepInQueryString('whm-step'), + ]); + } + + protected function isNextButtonHidden(): bool + { + return $this->isMigrating; + } + + protected function isNextStepDisabled(): bool + { + // Step 1: Must be connected + if (! $this->step1Complete) { + return ! $this->isConnected; + } + + // Step 2: Must have selected accounts + if (! $this->step2Complete) { + return empty($this->selectedAccounts); + } + + return false; + } + + protected function getConnectStep(): Step + { + return Step::make(__('Connect')) + ->id('connect') + ->icon('heroicon-o-link') + ->description(__('Connect to WHM server')) + ->schema([ + Section::make(__('WHM Server Credentials')) + ->description(__('Enter the WHM (WebHost Manager) server connection details')) + ->icon('heroicon-o-server-stack') + ->schema([ + Grid::make(['default' => 1, 'sm' => 2])->schema([ + TextInput::make('hostname') + ->label(__('WHM Hostname')) + ->placeholder('whm.example.com') + ->required() + ->helperText(__('Your WHM server hostname or IP address')), + TextInput::make('port') + ->label(__('Port')) + ->numeric() + ->default(2087) + ->required() + ->helperText(__('Usually 2087 for SSL or 2086 without')), + ]), + Grid::make(['default' => 1, 'sm' => 2])->schema([ + TextInput::make('whmUsername') + ->label(__('WHM Username')) + ->default('root') + ->required() + ->helperText(__('Usually "root" for WHM')), + TextInput::make('apiToken') + ->label(__('API Token')) + ->password() + ->required() + ->revealable() + ->helperText(__('Generate from WHM → Manage API Tokens')), + ]), + Checkbox::make('useSSL') + ->label(__('Use SSL (HTTPS)')) + ->default(true) + ->helperText(__('Recommended. Disable only if your WHM does not support SSL.')), + ]), + + FormActions::make([ + Action::make('testConnection') + ->label(fn () => $this->isConnected ? __('Connected') : __('Test Connection')) + ->icon(fn () => $this->isConnected ? 'heroicon-o-check-circle' : 'heroicon-o-signal') + ->color(fn () => $this->isConnected ? 'success' : 'primary') + ->action('testConnection'), + ])->alignEnd(), + + Section::make(__('Connection Successful')) + ->icon('heroicon-o-check-circle') + ->iconColor('success') + ->visible(fn () => $this->isConnected) + ->schema([ + Grid::make(['default' => 2, 'sm' => 4])->schema([ + Text::make(fn () => __('Host: :host', ['host' => $this->hostname ?? '-'])), + Text::make(fn () => __('Version: :version', ['version' => $this->serverInfo['version'] ?? '-'])), + Text::make(fn () => __('Accounts: :count', ['count' => $this->serverInfo['account_count'] ?? 0])), + Text::make(fn () => __('User: :user', ['user' => $this->whmUsername ?? '-'])), + ]), + Text::make(__('You can proceed to select accounts for migration.'))->color('success'), + ]), + ]) + ->afterValidation(function () { + if (! $this->isConnected) { + Notification::make() + ->title(__('Connection required')) + ->body(__('Please test the connection first')) + ->danger() + ->send(); + throw new Exception(__('Please test the connection first')); + } + + $this->step1Complete = true; + $this->saveToSession(); + }); + } + + protected function getSelectAccountsStep(): Step + { + return Step::make(__('Select Accounts')) + ->id('accounts') + ->icon('heroicon-o-users') + ->description(__('Choose which accounts to migrate')) + ->schema([ + Section::make(__('cPanel Accounts')) + ->description(fn () => ! empty($this->selectedAccounts) + ? __(':selected of :count accounts selected', ['selected' => count($this->selectedAccounts), 'count' => count($this->accounts)]) + : __(':count accounts found on server', ['count' => count($this->accounts)])) + ->icon('heroicon-o-user-group') + ->headerActions([ + Action::make('refreshAccounts') + ->label(__('Refresh')) + ->icon('heroicon-o-arrow-path') + ->color('gray') + ->action('refreshAccounts'), + Action::make('selectAll') + ->label(__('Select All')) + ->icon('heroicon-o-check') + ->color('primary') + ->action('selectAllAccounts') + ->visible(fn () => count($this->selectedAccounts) < count($this->accounts)), + Action::make('deselectAll') + ->label(__('Deselect All')) + ->icon('heroicon-o-x-mark') + ->color('gray') + ->action('deselectAllAccounts') + ->visible(fn () => count($this->selectedAccounts) > 0), + ]) + ->schema([ + View::make('filament.admin.pages.whm-accounts-table'), + ]), + ]) + ->afterValidation(function () { + if (empty($this->selectedAccounts)) { + Notification::make() + ->title(__('No accounts selected')) + ->body(__('Please select at least one account to migrate')) + ->danger() + ->send(); + throw new Exception(__('Please select at least one account')); + } + + // Initialize account configuration for selected accounts + $this->initializeAccountConfig(); + + $this->step2Complete = true; + $this->saveToSession(); + + // Notify the config table widget to refresh + $this->dispatch('whm-config-updated'); + }); + } + + protected function getConfigureStep(): Step + { + return Step::make(__('Configure')) + ->id('configure') + ->icon('heroicon-o-cog') + ->description(__('Configure migration options')) + ->schema([ + Section::make(__('Global Options')) + ->description(__('These options apply to all selected accounts')) + ->icon('heroicon-o-adjustments-horizontal') + ->schema([ + Grid::make(['default' => 1, 'sm' => 2])->schema([ + Checkbox::make('createLinuxUsers') + ->label(__('Create Linux system users')) + ->helperText(__('Creates Linux user accounts on this server')) + ->default(true), + Checkbox::make('sendWelcomeEmail') + ->label(__('Send welcome email to users')) + ->helperText(__('Notify users after their account is migrated')) + ->default(false), + ]), + ]), + + Section::make(__('What to Restore')) + ->description(__('Select which parts of each account to restore')) + ->icon('heroicon-o-check-circle') + ->schema([ + Grid::make(['default' => 1, 'sm' => 2])->schema([ + Checkbox::make('restoreFiles') + ->label(__('Website Files')) + ->helperText(__('Restore all website files')) + ->default(true), + Checkbox::make('restoreDatabases') + ->label(__('MySQL Databases')) + ->helperText(__('Restore databases with all data')) + ->default(true), + Checkbox::make('restoreEmails') + ->label(__('Email Mailboxes')) + ->helperText(__('Restore email accounts and messages')) + ->default(true), + Checkbox::make('restoreSsl') + ->label(__('SSL Certificates')) + ->helperText(__('Restore SSL certificates for domains')) + ->default(true), + ]), + ]), + + Section::make(__('Account Mappings')) + ->description(fn () => __(':count accounts to migrate', ['count' => count($this->selectedAccounts)])) + ->icon('heroicon-o-arrow-right') + ->schema([ + View::make('filament.admin.pages.whm-account-config-table'), + ]), + ]); + } + + protected function getMigrateStep(): Step + { + return Step::make(__('Migrate')) + ->id('migrate') + ->icon('heroicon-o-play') + ->description(__('Migration progress')) + ->schema([ + FormActions::make([ + Action::make('startMigration') + ->label(__('Start Migration')) + ->icon('heroicon-o-play') + ->color('success') + ->visible(fn () => ! $this->isMigrating && empty($this->migrationStatus)) + ->requiresConfirmation() + ->modalHeading(__('Start Migration')) + ->modalDescription(__('This will migrate :count account(s). Existing data may be overwritten. Continue?', ['count' => count($this->selectedAccounts)])) + ->action('startMigration'), + + Action::make('newMigration') + ->label(__('New Migration')) + ->icon('heroicon-o-plus') + ->color('primary') + ->visible(fn () => ! $this->isMigrating && ! empty($this->migrationStatus)) + ->action('resetMigration'), + ])->alignEnd(), + + Section::make(__('Overall Progress')) + ->icon($this->isMigrating ? 'heroicon-o-arrow-path' : ($this->getMigrationCompletedCount() === count($this->selectedAccounts) && ! empty($this->selectedAccounts) ? 'heroicon-o-check-circle' : 'heroicon-o-clock')) + ->iconColor($this->isMigrating ? 'warning' : ($this->getMigrationCompletedCount() === count($this->selectedAccounts) && ! empty($this->selectedAccounts) ? 'success' : 'gray')) + ->schema($this->getOverallProgressSchema()) + ->extraAttributes($this->isMigrating ? ['wire:poll.5s' => 'pollMigrationStatus'] : []), + + Section::make(__('Account Status')) + ->icon('heroicon-o-queue-list') + ->schema([ + View::make('filament.admin.pages.whm-migration-status-table'), + ]) + ->visible(fn () => ! empty($this->migrationStatus)), + ]); + } + + protected function getOverallProgressSchema(): array + { + if (empty($this->selectedAccounts)) { + return [ + Text::make(__('No accounts selected for migration.'))->color('gray'), + ]; + } + + if (empty($this->migrationStatus)) { + return [ + Text::make(__('Click "Start Migration" to begin.'))->color('gray'), + Text::make(__(':count account(s) will be migrated.', ['count' => count($this->selectedAccounts)]))->color('primary'), + ]; + } + + $total = count($this->selectedAccounts); + $completed = $this->getMigrationCompletedCount(); + $errors = $this->getMigrationErrorCount(); + $percent = $total > 0 ? round(($completed / $total) * 100) : 0; + + return [ + Grid::make(['default' => 2, 'sm' => 4])->schema([ + Section::make((string) $total) + ->description(__('Total')) + ->icon('heroicon-o-users') + ->iconColor('primary') + ->compact(), + Section::make((string) $completed) + ->description(__('Completed')) + ->icon('heroicon-o-check-circle') + ->iconColor('success') + ->compact(), + Section::make((string) $errors) + ->description(__('Errors')) + ->icon('heroicon-o-x-circle') + ->iconColor($errors > 0 ? 'danger' : 'gray') + ->compact(), + Section::make("{$percent}%") + ->description(__('Progress')) + ->icon('heroicon-o-chart-pie') + ->iconColor('info') + ->compact(), + ]), + ]; + } + + public function table(Table $table): Table + { + return $table + ->records(fn () => $this->getAccountConfigRecords()) + ->columns([ + Tables\Columns\IconColumn::make('exists') + ->label(__('Status')) + ->boolean() + ->trueIcon('heroicon-o-exclamation-triangle') + ->falseIcon('heroicon-o-user-plus') + ->trueColor('warning') + ->falseColor('success') + ->tooltip(fn (array $record): string => $record['exists'] + ? __('User exists - will restore to existing account') + : __('New user will be created')), + Tables\Columns\TextColumn::make('user') + ->label(__('Username')) + ->weight('bold') + ->searchable(), + Tables\Columns\TextColumn::make('domain') + ->label(__('Domain')) + ->searchable(), + Tables\Columns\TextColumn::make('email') + ->label(__('Email')) + ->icon('heroicon-o-envelope'), + Tables\Columns\TextColumn::make('diskused') + ->label(__('Size')), + ]) + ->striped() + ->paginated([10, 25, 50]) + ->defaultPaginationPageOption(10) + ->emptyStateHeading(__('No accounts selected')) + ->emptyStateDescription(__('Go back to Step 2 and select accounts to migrate')) + ->emptyStateIcon('heroicon-o-user-group'); + } + + protected function getAccountConfigRecords(): array + { + $records = []; + + foreach ($this->selectedAccounts as $cpanelUser) { + $account = collect($this->accounts)->firstWhere('user', $cpanelUser); + $config = $this->accountConfig[$cpanelUser] ?? []; + + $domain = $account['domain'] ?? ''; + $email = $config['email'] ?? $account['email'] ?? "{$cpanelUser}@{$domain}"; + + $existingUser = User::where('username', $cpanelUser)->first(); + + $records[] = [ + 'user' => $cpanelUser, + 'domain' => $domain, + 'email' => $email, + 'exists' => $existingUser !== null, + 'diskused' => $account['diskused'] ?? '', + ]; + } + + return $records; + } + + public function testConnection(): void + { + if (empty($this->hostname) || empty($this->whmUsername) || empty($this->apiToken)) { + Notification::make() + ->title(__('Missing credentials')) + ->body(__('Please fill in all required fields')) + ->danger() + ->send(); + + return; + } + + try { + $whm = $this->getWhm(); + $result = $whm->testConnection(); + + if ($result['success']) { + // Get account list + $accountsResult = $whm->listAccounts(); + if ($accountsResult['success']) { + $this->accounts = $accountsResult['accounts']; + } + + $this->isConnected = true; + $this->serverInfo = [ + 'version' => $result['version'] ?? 'Unknown', + 'account_count' => count($this->accounts), + ]; + + $this->saveToSession(); + $this->dispatch('whm-accounts-updated'); + + Notification::make() + ->title(__('Connection successful')) + ->body(__('Connected to WHM server. Found :count cPanel accounts.', [ + 'count' => count($this->accounts), + ])) + ->success() + ->send(); + } else { + throw new Exception($result['message'] ?? __('Connection failed')); + } + } catch (Exception $e) { + $this->isConnected = false; + Log::error('WHM connection failed', ['error' => $e->getMessage()]); + + Notification::make() + ->title(__('Connection failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function refreshAccounts(): void + { + $whm = $this->getWhm(); + if (! $whm) { + return; + } + + try { + $result = $whm->listAccounts(); + if ($result['success']) { + $this->accounts = $result['accounts']; + $this->serverInfo['account_count'] = count($this->accounts); + $this->saveToSession(); + $this->dispatch('whm-accounts-updated'); + + Notification::make() + ->title(__('Accounts refreshed')) + ->body(__('Found :count cPanel accounts.', ['count' => count($this->accounts)])) + ->success() + ->send(); + } + } catch (Exception $e) { + Notification::make() + ->title(__('Refresh failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function toggleAccountSelection(string $user): void + { + if (in_array($user, $this->selectedAccounts)) { + $this->selectedAccounts = array_values(array_diff($this->selectedAccounts, [$user])); + } else { + $this->selectedAccounts[] = $user; + } + + $this->saveToSession(); + } + + #[On('whm-selection-updated')] + public function handleSelectionUpdated(array $selectedAccounts): void + { + $this->selectedAccounts = $selectedAccounts; + $this->saveToSession(); + } + + public function selectAllAccounts(): void + { + $this->selectedAccounts = array_map(fn ($a) => $a['user'], $this->accounts); + $this->saveToSession(); + $this->dispatch('whm-accounts-updated'); + } + + public function deselectAllAccounts(): void + { + $this->selectedAccounts = []; + $this->saveToSession(); + $this->dispatch('whm-accounts-updated'); + } + + protected function initializeAccountConfig(): void + { + foreach ($this->selectedAccounts as $cpanelUser) { + if (isset($this->accountConfig[$cpanelUser])) { + continue; + } + + $account = collect($this->accounts)->firstWhere('user', $cpanelUser); + $domain = $account['domain'] ?? ''; + $email = $account['email'] ?? "{$cpanelUser}@{$domain}"; + + $this->accountConfig[$cpanelUser] = [ + 'jabali_username' => $cpanelUser, + 'email' => $email, + 'password' => bin2hex(random_bytes(8)), // Generate random password + ]; + } + } + + public function startMigration(): void + { + if (empty($this->selectedAccounts)) { + return; + } + + $this->isMigrating = true; + $this->currentAccountIndex = 0; + $this->totalAccounts = count($this->selectedAccounts); + + $store = $this->getMigrationStatusStore(); + $state = $store->initialize($this->selectedAccounts); + $this->migrationStatus = $state['migrationStatus'] ?? []; + + $this->saveToSession(); + + RunWhmMigrationBatch::dispatch( + cacheKey: $this->getMigrationCacheKey(), + hostname: $this->hostname ?? '', + whmUsername: $this->whmUsername ?? 'root', + apiToken: $this->apiToken ?? '', + port: $this->port, + useSSL: $this->useSSL, + accounts: $this->accounts, + selectedAccounts: $this->selectedAccounts, + restoreFiles: $this->restoreFiles, + restoreDatabases: $this->restoreDatabases, + restoreEmails: $this->restoreEmails, + restoreSsl: $this->restoreSsl, + createLinuxUsers: $this->createLinuxUsers, + ); + } + + #[\Livewire\Attributes\On('process-next-account')] + public function handleProcessNextAccount(): void + { + $this->processNextAccount(); + } + + public function processNextAccount(): void + { + if ($this->currentAccountIndex >= $this->totalAccounts) { + $this->isMigrating = false; + $this->saveToSession(); + + $completed = $this->getMigrationCompletedCount(); + $errors = $this->getMigrationErrorCount(); + + Notification::make() + ->title(__('Migration complete')) + ->body(__(':completed of :total accounts migrated successfully. :errors errors.', [ + 'completed' => $completed, + 'total' => $this->totalAccounts, + 'errors' => $errors, + ])) + ->success() + ->send(); + + $this->resetMigration(); + + return; + } + + $cpanelUser = $this->selectedAccounts[$this->currentAccountIndex]; + $this->migrateAccount($cpanelUser); + } + + protected function migrateAccount(string $cpanelUser): void + { + $this->updateAccountStatus($cpanelUser, 'processing', __('Starting migration...')); + + try { + $whm = $this->getWhm(); + if (! $whm) { + throw new Exception(__('WHM connection lost')); + } + + $account = collect($this->accounts)->firstWhere('user', $cpanelUser); + $domain = $account['domain'] ?? ''; + $email = $account['email'] ?? "{$cpanelUser}@{$domain}"; + + // Step 1: Create or get Jabali user + $user = $this->createOrGetUser($cpanelUser, $email); + if (! $user) { + throw new Exception(__('Failed to create user')); + } + + $this->addAccountLog($cpanelUser, __('User ready: :username', ['username' => $user->username]), 'success'); + + // Step 2: Set up SSH key for SCP transfer (same as cPanel migration) + $this->updateAccountStatus($cpanelUser, 'backup_creating', __('Setting up backup transfer...')); + + $keyName = $this->getSshKeyName(); + $destPath = $this->getBackupDestPath(); + + // Ensure destination directory exists + if (! is_dir($destPath)) { + mkdir($destPath, 0755, true); + } + + // Ensure Jabali SSH key exists (via agent which runs as root) + $this->getAgent()->send('jabali_ssh.ensure_exists', []); + + // Get Jabali's public key and add to authorized_keys + $publicKeyResult = $this->getAgent()->send('jabali_ssh.get_public_key', []); + if (! ($publicKeyResult['success'] ?? false) || ! ($publicKeyResult['exists'] ?? false)) { + throw new Exception(__('Failed to get Jabali public key')); + } + $publicKey = $publicKeyResult['public_key'] ?? null; + + // Add to authorized_keys so cPanel can SCP to us + $this->getAgent()->send('jabali_ssh.add_to_authorized_keys', [ + 'public_key' => $publicKey, + 'comment' => 'whm-migration-'.$cpanelUser, + ]); + + // Read Jabali's SSH private key via agent (runs as root) + $privateKeyResult = $this->getAgent()->send('jabali_ssh.get_private_key', []); + if (! ($privateKeyResult['success'] ?? false) || ! ($privateKeyResult['exists'] ?? false)) { + throw new Exception(__('Failed to read Jabali private key')); + } + + $privateKey = $privateKeyResult['private_key'] ?? null; + if (empty($privateKey)) { + throw new Exception(__('Private key is empty')); + } + + $this->addAccountLog($cpanelUser, __('Importing SSH key to cPanel...'), 'pending'); + + // Import SSH key to the cPanel user via WHM + $importResult = $whm->importSshPrivateKey($cpanelUser, $keyName, $privateKey); + if (! ($importResult['success'] ?? false)) { + throw new Exception($importResult['message'] ?? __('Failed to import SSH key')); + } + + // Use actual key name if it was different (key already existed under different name) + $actualKeyName = $importResult['actual_key_name'] ?? $keyName; + $this->addAccountLog($cpanelUser, __('SSH key imported'), 'success'); + + // Authorize the key + $authResult = $whm->authorizeSshKey($cpanelUser, $actualKeyName); + if (! ($authResult['success'] ?? false)) { + $this->addAccountLog($cpanelUser, __('SSH key authorization skipped'), 'info'); + } else { + $this->addAccountLog($cpanelUser, __('SSH key authorized'), 'success'); + } + + // Step 3: Initiate backup with SCP transfer to Jabali + $this->addAccountLog($cpanelUser, __('Initiating backup transfer...'), 'pending'); + + $jabaliIp = $this->getJabaliPublicIp(); + + $backupResult = $whm->createBackupToScpWithKey( + $cpanelUser, + $jabaliIp, + 'root', + $destPath, + $actualKeyName, + 22 + ); + + if (! ($backupResult['success'] ?? false)) { + throw new Exception($backupResult['message'] ?? __('Failed to start backup')); + } + + $this->addAccountLog($cpanelUser, __('Backup initiated, transferring via SCP...'), 'success'); + + // Step 4: Wait for backup to arrive + $this->updateAccountStatus($cpanelUser, 'backup_downloading', __('Waiting for backup file...')); + + $backupPath = $this->waitForBackupFile($cpanelUser, $destPath); + if (! $backupPath) { + throw new Exception(__('Backup file did not arrive')); + } + + $this->addAccountLog($cpanelUser, __('Backup received: :size', ['size' => $this->formatBytes(filesize($backupPath))]), 'success'); + + // Step 5: Get migration summary for this user + $summary = $whm->getUserMigrationSummary($cpanelUser); + $discoveredData = $whm->convertApiDataToAgentFormat($summary); + + // Step 6: Restore backup + $this->updateAccountStatus($cpanelUser, 'restoring', __('Restoring data...')); + + $result = $this->getAgent()->send('cpanel.restore_backup', [ + 'backup_path' => $backupPath, + 'username' => $user->username, + 'restore_files' => $this->restoreFiles, + 'restore_databases' => $this->restoreDatabases, + 'restore_emails' => $this->restoreEmails, + 'restore_ssl' => $this->restoreSsl, + 'discovered_data' => $discoveredData, + ]); + + if ($result['success'] ?? false) { + foreach ($result['log'] ?? [] as $entry) { + $this->addAccountLog($cpanelUser, $entry['message'], $entry['status'] ?? 'info'); + } + + $this->updateAccountStatus($cpanelUser, 'completed', __('Migration completed')); + + // Clean up backup file + @unlink($backupPath); + } else { + throw new Exception($result['error'] ?? __('Restore failed')); + } + } catch (Exception $e) { + Log::error('WHM migration failed for user', ['user' => $cpanelUser, 'error' => $e->getMessage()]); + $this->updateAccountStatus($cpanelUser, 'error', $e->getMessage()); + } + + // Move to next account + $this->currentAccountIndex++; + $this->saveToSession(); + + // Process next account - dispatch to allow UI update + $this->dispatch('process-next-account'); + } + + protected function createOrGetUser(string $cpanelUser, string $email): ?User + { + // Check if user already exists + $existingUser = User::where('username', $cpanelUser)->first(); + if ($existingUser) { + return $existingUser; + } + + // Check if email already exists + if (User::where('email', $email)->exists()) { + $email = "{$cpanelUser}.".time().'@'.explode('@', $email)[1]; + } + + $password = bin2hex(random_bytes(12)); + + try { + if ($this->createLinuxUsers) { + // Check if Linux user already exists + exec('id '.escapeshellarg($cpanelUser).' 2>/dev/null', $output, $exitCode); + + if ($exitCode !== 0) { + $result = $this->getAgent()->send('user.create', [ + 'username' => $cpanelUser, + 'password' => $password, + ]); + + if (! ($result['success'] ?? false)) { + throw new Exception($result['error'] ?? __('Failed to create system user')); + } + } + } + + // Create panel user record + $user = User::create([ + 'name' => ucfirst($cpanelUser), + 'username' => $cpanelUser, + 'email' => $email, + 'password' => Hash::make($password), + 'home_directory' => '/home/'.$cpanelUser, + 'disk_quota_mb' => null, + 'is_active' => true, + 'is_admin' => false, + ]); + + return $user; + } catch (Exception $e) { + Log::error('Failed to create user', ['username' => $cpanelUser, 'error' => $e->getMessage()]); + + return null; + } + } + + /** + * Wait for backup file to arrive via SCP transfer + */ + protected function waitForBackupFile(string $cpanelUser, string $destPath): ?string + { + $maxAttempts = 120; // 10 minutes (5s interval) + $attempt = 0; + $lastSeenSize = 0; + $sizeStableCount = 0; + + while ($attempt < $maxAttempts) { + $attempt++; + sleep(5); + + // Look for backup files matching this user + $pattern = "{$destPath}/backup-*_{$cpanelUser}.tar.gz"; + $files = glob($pattern); + + // Also check for cpmove format + if (empty($files)) { + $pattern = "{$destPath}/cpmove-{$cpanelUser}.tar.gz"; + $files = glob($pattern); + } + + if (empty($files)) { + if ($attempt % 6 === 0) { // Log every 30 seconds + $this->addAccountLog($cpanelUser, __('Waiting for backup file... (:count s)', ['count' => $attempt * 5]), 'pending'); + } + + continue; + } + + // Sort by modification time, get newest + usort($files, fn ($a, $b) => filemtime($b) - filemtime($a)); + $backupFile = $files[0]; + $currentSize = filesize($backupFile); + + // Check if size is stable (transfer finished) + if ($currentSize > 0 && $currentSize === $lastSeenSize) { + $sizeStableCount++; + } else { + $sizeStableCount = 0; + } + $lastSeenSize = $currentSize; + + // Require size to be stable for 3 checks (15 seconds) and at least 10KB + if ($sizeStableCount >= 3 && $currentSize >= 10 * 1024) { + // Fix permissions - file arrives as root via SCP + $this->getAgent()->send('file.chown', [ + 'path' => $backupFile, + 'owner' => 'www-data', + 'group' => 'www-data', + ]); + + // Verify it's a valid gzip + $handle = fopen($backupFile, 'rb'); + $magic = $handle ? fread($handle, 2) : ''; + if ($handle) { + fclose($handle); + } + + if ($magic === "\x1f\x8b") { + return $backupFile; + } else { + $this->addAccountLog($cpanelUser, __('Invalid backup file format, waiting...'), 'warning'); + $sizeStableCount = 0; + } + } + + if ($attempt % 6 === 0) { // Log every 30 seconds + $this->addAccountLog($cpanelUser, __('Receiving backup... :size', [ + 'size' => $this->formatBytes($currentSize), + ]), 'pending'); + } + } + + return null; + } + + protected function updateAccountStatus(string $user, string $status, string $message): void + { + $this->migrationStatus[$user]['status'] = $status; + $this->addAccountLog($user, $message, $status === 'error' ? 'error' : 'info'); + $this->saveToSession(); + $this->dispatch('whm-migration-status-updated'); + } + + protected function addAccountLog(string $user, string $message, string $status = 'info'): void + { + $this->migrationStatus[$user]['log'][] = [ + 'message' => $message, + 'status' => $status, + 'time' => now()->format('H:i:s'), + ]; + $this->saveToSession(); + } + + public function pollMigrationStatus(): void + { + $this->loadMigrationStatusFromStore(); + $this->dispatch('whm-migration-status-updated'); + } + + protected function getMigrationCompletedCount(): int + { + return count(array_filter($this->migrationStatus, fn ($s) => $s['status'] === 'completed')); + } + + protected function getMigrationErrorCount(): int + { + return count(array_filter($this->migrationStatus, fn ($s) => $s['status'] === 'error')); + } + + public function resetMigration(): void + { + $this->wizardStep = null; + $this->hostname = null; + $this->whmUsername = 'root'; + $this->apiToken = null; + $this->port = 2087; + $this->useSSL = true; + $this->isConnected = false; + $this->serverInfo = []; + $this->accounts = []; + $this->selectedAccounts = []; + $this->accountConfig = []; + $this->step1Complete = false; + $this->step2Complete = false; + $this->isMigrating = false; + $this->migrationStatus = []; + $this->currentAccountIndex = 0; + $this->totalAccounts = 0; + $this->statusLog = []; + $this->whm = null; + + $this->getMigrationStatusStore()->clear(); + $this->clearSession(); + $this->redirect(static::getUrl()); + } + + protected 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]; + } +} diff --git a/app/Filament/Admin/Resources/Users/Pages/CreateUser.php b/app/Filament/Admin/Resources/Users/Pages/CreateUser.php new file mode 100644 index 0000000..1293981 --- /dev/null +++ b/app/Filament/Admin/Resources/Users/Pages/CreateUser.php @@ -0,0 +1,90 @@ +data['create_linux_user'] ?? true; + + if ($createLinuxUser) { + try { + $linuxService = new LinuxUserService(); + + // Get the plain password before it was hashed + $password = $this->data['sftp_password'] ?? null; + + $linuxService->createUser($this->record, $password); + + Notification::make() + ->title(__('Linux user created')) + ->body(__("System user ':username' has been created.", ['username' => $this->record->username])) + ->success() + ->send(); + + // Apply disk quota if enabled + $this->applyDiskQuota(); + } catch (Exception $e) { + Notification::make() + ->title(__('Linux user creation failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + } + + protected function applyDiskQuota(): void + { + $quotaMb = $this->record->disk_quota_mb; + if (!$quotaMb || $quotaMb <= 0) { + return; + } + + // Always try to apply quota when set + try { + $agent = new AgentClient(); + $result = $agent->quotaSet($this->record->username, (int) $quotaMb); + + if ($result['success'] ?? false) { + Notification::make() + ->title(__('Disk quota applied')) + ->body(__("Quota of :quota GB set for ':username'.", ['quota' => number_format($quotaMb / 1024, 1), 'username' => $this->record->username])) + ->success() + ->send(); + } else { + throw new Exception($result['error'] ?? __('Unknown error')); + } + } catch (Exception $e) { + // Show warning but don't fail - quota value is saved in database + Notification::make() + ->title(__('Disk quota failed')) + ->body(__('Value saved but filesystem quota not applied: :error', ['error' => $e->getMessage()])) + ->warning() + ->send(); + } + } +} diff --git a/app/Filament/Admin/Resources/Users/Pages/EditUser.php b/app/Filament/Admin/Resources/Users/Pages/EditUser.php new file mode 100644 index 0000000..5c5c010 --- /dev/null +++ b/app/Filament/Admin/Resources/Users/Pages/EditUser.php @@ -0,0 +1,116 @@ +originalQuota = $data['disk_quota_mb'] ?? null; + + return $data; + } + + protected function afterSave(): void + { + $newQuota = $this->record->disk_quota_mb; + if ($newQuota === $this->originalQuota) { + return; + } + + // Always try to apply quota when changed + try { + $agent = new AgentClient; + $result = $agent->quotaSet($this->record->username, (int) ($newQuota ?? 0)); + + if ($result['success'] ?? false) { + $message = $newQuota && $newQuota > 0 + ? __('Quota updated to :size GB', ['size' => number_format($newQuota / 1024, 1)]) + : __('Quota removed (unlimited)'); + + Notification::make() + ->title(__('Disk quota updated')) + ->body($message) + ->success() + ->send(); + } else { + throw new Exception($result['error'] ?? __('Unknown error')); + } + } catch (Exception $e) { + // Show warning but don't fail - quota value is saved in database + Notification::make() + ->title(__('Disk quota update failed')) + ->body(__('Value saved but filesystem quota not applied: :error', ['error' => $e->getMessage()])) + ->warning() + ->send(); + } + } + + protected function getHeaderActions(): array + { + return [ + Actions\Action::make('loginAsUser') + ->label(__('Login as User')) + ->icon('heroicon-o-arrow-right-on-rectangle') + ->color('info') + ->visible(fn () => ! $this->record->is_admin && $this->record->is_active) + ->url(fn () => route('impersonate.start', ['user' => $this->record->id]), shouldOpenInNewTab: true), + Actions\DeleteAction::make() + ->visible(fn () => (int) $this->record->id !== 1) + ->form([ + Toggle::make('remove_home') + ->label(__('Delete home directory')) + ->helperText(__('Warning: This will permanently delete /home/:username and all its contents!', ['username' => $this->record->username])) + ->default(false), + ]) + ->action(function (array $data) { + $removeHome = $data['remove_home'] ?? false; + $username = $this->record->username; + + try { + $linuxService = new LinuxUserService; + + if ($linuxService->userExists($username)) { + $linuxService->deleteUser($username, $removeHome); + + $body = $removeHome + ? __("System user ':username' has been deleted along with home directory.", ['username' => $username]) + : __("System user ':username' has been deleted.", ['username' => $username]); + + Notification::make() + ->title(__('Linux user deleted')) + ->body($body) + ->success() + ->send(); + } + } catch (Exception $e) { + Notification::make() + ->title(__('Linux user deletion failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + + // Delete from database + $this->record->delete(); + + $this->redirect($this->getResource()::getUrl('index')); + }), + ]; + } +} diff --git a/app/Filament/Admin/Resources/Users/Pages/ListUsers.php b/app/Filament/Admin/Resources/Users/Pages/ListUsers.php new file mode 100644 index 0000000..789a764 --- /dev/null +++ b/app/Filament/Admin/Resources/Users/Pages/ListUsers.php @@ -0,0 +1,23 @@ +getTourAction(), + CreateAction::make(), + ]; + } +} diff --git a/app/Filament/Admin/Resources/Users/Schemas/UserForm.php b/app/Filament/Admin/Resources/Users/Schemas/UserForm.php new file mode 100644 index 0000000..2097411 --- /dev/null +++ b/app/Filament/Admin/Resources/Users/Schemas/UserForm.php @@ -0,0 +1,217 @@ +columns(1) + ->components([ + Section::make(__('User Information')) + ->schema([ + TextInput::make('name') + ->label(__('Name')) + ->required() + ->maxLength(255), + + TextInput::make('username') + ->label(__('Username')) + ->required() + ->maxLength(32) + ->alphaNum() + ->unique(ignoreRecord: true) + ->rules(['regex:/^[a-z][a-z0-9_]{0,31}$/']) + ->helperText(__('Lowercase letters, numbers, and underscores only. Must start with a letter.')) + ->disabled(fn (string $operation): bool => $operation === 'edit'), + + TextInput::make('email') + ->label(__('Email address')) + ->email() + ->required() + ->unique(ignoreRecord: true) + ->maxLength(255), + + TextInput::make('password') + ->password() + ->revealable() + ->dehydrateStateUsing(fn ($state) => filled($state) ? Hash::make($state) : null) + ->dehydrated(fn ($state) => filled($state)) + ->required(fn (string $operation): bool => $operation === 'create') + ->minLength(8) + ->rules([ + 'regex:/[a-z]/', // lowercase + 'regex:/[A-Z]/', // uppercase + 'regex:/[0-9]/', // number + ]) + ->suffixActions([ + Action::make('generatePassword') + ->icon('heroicon-o-arrow-path') + ->tooltip(__('Generate secure password')) + ->action(function ($set) { + $password = self::generateSecurePassword(); + $set('password', $password); + }), + Action::make('copyPassword') + ->icon('heroicon-o-clipboard-document') + ->tooltip(__('Copy to clipboard')) + ->action(function ($state, $livewire) { + if ($state) { + $escaped = addslashes($state); + $livewire->js("navigator.clipboard.writeText('{$escaped}')"); + \Filament\Notifications\Notification::make() + ->title(__('Copied to clipboard')) + ->success() + ->duration(2000) + ->send(); + } + }), + ]) + ->helperText(__('Minimum 8 characters with uppercase, lowercase, and numbers')) + ->label(fn (string $operation): string => $operation === 'create' ? __('Password') : __('New Password')), + ]) + ->columns(2), + + Section::make(__('Account Settings')) + ->schema([ + Toggle::make('is_admin') + ->label(__('Administrator')) + ->helperText(__('Grant full administrative access')) + ->inline(false), + + Toggle::make('is_active') + ->label(__('Active')) + ->default(true) + ->helperText(__('Inactive users cannot log in')) + ->inline(false), + + Toggle::make('create_linux_user') + ->label(__('Create Linux User')) + ->default(true) + ->helperText(__('Create a system user account')) + ->visibleOn('create') + ->dehydrated(false) + ->inline(false), + + DateTimePicker::make('email_verified_at') + ->label(__('Email Verified At')), + ]) + ->columns(4), + + Section::make(__('Disk Quota')) + ->schema([ + Placeholder::make('current_usage') + ->label(__('Current Usage')) + ->content(function ($record) { + if (!$record) { + return __('N/A'); + } + + $used = $record->disk_usage_formatted; + $quotaMb = $record->disk_quota_mb; + + if (!$quotaMb || $quotaMb <= 0) { + return "{$used} (" . __('Unlimited') . ")"; + } + + $quota = $quotaMb >= 1024 + ? number_format($quotaMb / 1024, 1) . ' GB' + : $quotaMb . ' MB'; + $percent = $record->disk_usage_percent; + + return "{$used} / {$quota} ({$percent}%)"; + }) + ->visibleOn('edit'), + + Toggle::make('unlimited_quota') + ->label(__('Unlimited Quota')) + ->helperText(__('No disk space limit')) + ->default(fn () => (int) DnsSetting::get('default_quota_mb', 5120) === 0) + ->live() + ->afterStateHydrated(function (Toggle $component, $state, $record) { + if ($record) { + $component->state(!$record->disk_quota_mb || $record->disk_quota_mb <= 0); + } + }) + ->afterStateUpdated(function ($state, callable $set) { + if ($state) { + $set('disk_quota_mb', 0); + } else { + $set('disk_quota_mb', (int) DnsSetting::get('default_quota_mb', 5120)); + } + }) + ->inline(false), + + TextInput::make('disk_quota_mb') + ->label(__('Disk Quota')) + ->numeric() + ->minValue(1) + ->default(fn () => (int) DnsSetting::get('default_quota_mb', 5120)) + ->helperText(fn ($state) => $state && $state > 0 ? number_format($state / 1024, 1) . ' GB' : null) + ->suffix('MB') + ->visible(fn ($get) => !$get('unlimited_quota')) + ->required(fn ($get) => !$get('unlimited_quota')), + ]) + ->description(fn () => !(bool) DnsSetting::get('quotas_enabled', false) ? __('Note: Quotas are currently disabled in Server Settings.') : null) + ->columns(3), + + Section::make(__('System Information')) + ->schema([ + Placeholder::make('home_directory_display') + ->label(__('Home Directory')) + ->content(fn ($record) => $record?->home_directory ?? '/home/' . __('username')), + + Placeholder::make('created_at_display') + ->label(__('Created')) + ->content(fn ($record) => $record?->created_at?->format('M d, Y H:i') ?? __('N/A')), + + Placeholder::make('updated_at_display') + ->label(__('Last Updated')) + ->content(fn ($record) => $record?->updated_at?->format('M d, Y H:i') ?? __('N/A')), + ]) + ->columns(3) + ->visibleOn('edit') + ->collapsible(), + ]); + } +} diff --git a/app/Filament/Admin/Resources/Users/Tables/UsersTable.php b/app/Filament/Admin/Resources/Users/Tables/UsersTable.php new file mode 100644 index 0000000..d9da880 --- /dev/null +++ b/app/Filament/Admin/Resources/Users/Tables/UsersTable.php @@ -0,0 +1,212 @@ +columns([ + TextColumn::make('id') + ->label(__('ID')) + ->sortable(), + + TextColumn::make('name') + ->label(__('Name')) + ->searchable() + ->sortable(), + + TextColumn::make('username') + ->label(__('Username')) + ->searchable() + ->sortable() + ->copyable(), + + TextColumn::make('email') + ->label(__('Email')) + ->searchable() + ->sortable(), + + TextColumn::make('home_directory') + ->label(__('Home')) + ->toggleable(isToggledHiddenByDefault: true), + + IconColumn::make('is_admin') + ->boolean() + ->label(__('Admin')), + + IconColumn::make('is_active') + ->boolean() + ->label(__('Active')), + + TextColumn::make('disk_usage') + ->label(__('Disk Usage')) + ->getStateUsing(function ($record) { + $used = $record->disk_usage_formatted; + $quotaMb = $record->disk_quota_mb; + + if (! $quotaMb || $quotaMb <= 0) { + return $used; + } + + $quota = $quotaMb >= 1024 + ? number_format($quotaMb / 1024, 1).' GB' + : $quotaMb.' MB'; + + return "{$used} / {$quota}"; + }) + ->description(function ($record) { + if (! $record->disk_quota_mb || $record->disk_quota_mb <= 0) { + return __('Unlimited'); + } + + return $record->disk_usage_percent.'% '.__('used'); + }) + ->color(function ($record) { + if (! $record->disk_quota_mb || $record->disk_quota_mb <= 0) { + return 'gray'; + } + $percent = $record->disk_usage_percent; + if ($percent >= 90) { + return 'danger'; + } + if ($percent >= 75) { + return 'warning'; + } + + return null; + }), + + TextColumn::make('created_at') + ->label(__('Created')) + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + TernaryFilter::make('is_admin') + ->label(__('Administrator')), + + TernaryFilter::make('is_active') + ->label(__('Active')), + ]) + ->recordActions([ + Action::make('loginAsUser') + ->label(__('Login as User')) + ->icon('heroicon-o-arrow-right-on-rectangle') + ->color('info') + ->visible(fn ($record) => ! $record->is_admin && $record->is_active) + ->url(fn ($record) => route('impersonate.start', ['user' => $record->id]), shouldOpenInNewTab: true), + EditAction::make(), + DeleteAction::make() + ->visible(fn ($record) => (int) $record->id !== 1) + ->form([ + Toggle::make('remove_home') + ->label(__('Delete home directory')) + ->helperText(fn ($record) => __('Warning: This will permanently delete /home/:username and all its contents!', ['username' => $record->username])) + ->default(false), + ]) + ->action(function ($record, array $data) { + $removeHome = $data['remove_home'] ?? false; + $username = $record->username; + + try { + $linuxService = new LinuxUserService; + + if ($linuxService->userExists($username)) { + $linuxService->deleteUser($username, $removeHome); + + Notification::make() + ->title(__('Linux user deleted')) + ->body(__("System user ':username' has been deleted.", ['username' => $username])) + ->success() + ->send(); + } + } catch (Exception $e) { + Notification::make() + ->title(__('Linux user deletion failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + + $record->delete(); + }), + ]) + ->bulkActions([ + DeleteBulkAction::make() + ->form([ + Toggle::make('remove_home') + ->label(__('Delete home directories')) + ->helperText(__('Warning: This will permanently delete all home directories for selected users!')) + ->default(false), + ]) + ->action(function ($records, array $data) { + $removeHome = $data['remove_home'] ?? false; + $linuxService = new LinuxUserService; + $skippedPrimaryAdmin = 0; + $deletedCount = 0; + + foreach ($records as $record) { + if ((int) $record->id === 1) { + $skippedPrimaryAdmin++; + + continue; + } + + try { + if ($linuxService->userExists($record->username)) { + $linuxService->deleteUser($record->username, $removeHome); + } + } catch (Exception $e) { + Notification::make() + ->title(__('Failed to delete Linux user :username', ['username' => $record->username])) + ->body($e->getMessage()) + ->danger() + ->send(); + } + + $record->delete(); + $deletedCount++; + } + + if ($skippedPrimaryAdmin > 0) { + Notification::make() + ->title(__('Primary admin account cannot be deleted')) + ->body(__(':count account(s) were skipped.', ['count' => $skippedPrimaryAdmin])) + ->warning() + ->send(); + } + + if ($deletedCount > 0) { + Notification::make() + ->title(__('Users deleted')) + ->success() + ->send(); + } else { + Notification::make() + ->title(__('No users deleted')) + ->warning() + ->send(); + } + }), + ]) + ->checkIfRecordIsSelectableUsing(fn ($record): bool => (int) $record->id !== 1); + } +} diff --git a/app/Filament/Admin/Resources/Users/UserResource.php b/app/Filament/Admin/Resources/Users/UserResource.php new file mode 100644 index 0000000..08d3569 --- /dev/null +++ b/app/Filament/Admin/Resources/Users/UserResource.php @@ -0,0 +1,65 @@ + ListUsers::route('/'), + 'create' => CreateUser::route('/create'), + 'edit' => EditUser::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Admin/Widgets/AdminStatsOverview.php b/app/Filament/Admin/Widgets/AdminStatsOverview.php new file mode 100644 index 0000000..c7aa434 --- /dev/null +++ b/app/Filament/Admin/Widgets/AdminStatsOverview.php @@ -0,0 +1,51 @@ +count(); + $domainCount = Domain::count(); + $sslActiveCount = SslCertificate::where('status', 'active')->count(); + $sslExpiringCount = SslCertificate::where('status', 'active') + ->where('expires_at', '<=', now()->addDays(30)) + ->where('expires_at', '>', now()) + ->count(); + + return [ + Stat::make(__('Users'), $userCount) + ->description(__('Total accounts')) + ->descriptionIcon('heroicon-m-users') + ->color('danger') + ->url(route('filament.admin.resources.users.index')), + + Stat::make(__('Domains'), $domainCount) + ->description(__('Hosted domains')) + ->descriptionIcon('heroicon-m-globe-alt') + ->color('success') + ->url(url('/jabali-admin/dns-zones')), + + Stat::make(__('SSL Certificates'), $sslActiveCount) + ->description($sslExpiringCount > 0 ? __(':count expiring soon', ['count' => $sslExpiringCount]) : __('All certificates valid')) + ->descriptionIcon($sslExpiringCount > 0 ? 'heroicon-m-exclamation-triangle' : 'heroicon-m-check-circle') + ->color($sslExpiringCount > 0 ? 'warning' : 'info') + ->url(url('/jabali-admin/ssl-manager')), + + Stat::make(__('Server Status'), __('Healthy')) + ->description(__('View metrics')) + ->descriptionIcon('heroicon-m-server') + ->color('gray') + ->url(url('/jabali-admin/server-status')), + ]; + } +} diff --git a/app/Filament/Admin/Widgets/Dashboard/RecentActivityTable.php b/app/Filament/Admin/Widgets/Dashboard/RecentActivityTable.php new file mode 100644 index 0000000..14a8b5d --- /dev/null +++ b/app/Filament/Admin/Widgets/Dashboard/RecentActivityTable.php @@ -0,0 +1,68 @@ +query(AuditLog::query()->with('user')->latest()->limit(10)) + ->columns([ + TextColumn::make('created_at') + ->label(__('Time')) + ->dateTime('M d, H:i') + ->color('gray'), + TextColumn::make('user.name') + ->label(__('User')) + ->icon('heroicon-o-user') + ->default(__('System')), + TextColumn::make('action') + ->label(__('Action')) + ->badge() + ->color(fn (string $state): string => match ($state) { + 'create', 'created' => 'success', + 'update', 'updated' => 'warning', + 'delete', 'deleted' => 'danger', + 'login' => 'info', + default => 'gray', + }), + TextColumn::make('description') + ->label(__('Description')) + ->limit(50) + ->wrap(), + ]) + ->paginated(false) + ->striped() + ->emptyStateHeading(__('No activity')) + ->emptyStateDescription(__('No recent activity recorded.')) + ->emptyStateIcon('heroicon-o-document-text'); + } + + public function render() + { + return $this->getTable()->render(); + } +} diff --git a/app/Filament/Admin/Widgets/DashboardStatsWidget.php b/app/Filament/Admin/Widgets/DashboardStatsWidget.php new file mode 100644 index 0000000..354558f --- /dev/null +++ b/app/Filament/Admin/Widgets/DashboardStatsWidget.php @@ -0,0 +1,67 @@ +count(); + $domainCount = Domain::count(); + $mailboxCount = Mailbox::count(); + $databaseCount = MysqlCredential::count(); + $sslActiveCount = SslCertificate::where('status', 'active')->count(); + $sslExpiringCount = SslCertificate::where('status', 'active') + ->where('expires_at', '<=', now()->addDays(30)) + ->where('expires_at', '>', now()) + ->count(); + + return [ + [ + 'value' => $userCount, + 'label' => __('Users'), + 'icon' => 'heroicon-o-users', + 'color' => 'primary', + ], + [ + 'value' => $domainCount, + 'label' => __('Domains'), + 'icon' => 'heroicon-o-globe-alt', + 'color' => 'success', + ], + [ + 'value' => $mailboxCount, + 'label' => __('Mailboxes'), + 'icon' => 'heroicon-o-envelope', + 'color' => 'info', + ], + [ + 'value' => $databaseCount, + 'label' => __('Databases'), + 'icon' => 'heroicon-o-circle-stack', + 'color' => 'warning', + ], + [ + 'value' => $sslActiveCount, + 'label' => __('SSL Certificates'), + 'icon' => $sslExpiringCount > 0 ? 'heroicon-o-exclamation-triangle' : 'heroicon-o-lock-closed', + 'color' => $sslExpiringCount > 0 ? 'warning' : 'success', + ], + ]; + } +} diff --git a/app/Filament/Admin/Widgets/DiskUsageWidget.php b/app/Filament/Admin/Widgets/DiskUsageWidget.php new file mode 100644 index 0000000..6b6d8ad --- /dev/null +++ b/app/Filament/Admin/Widgets/DiskUsageWidget.php @@ -0,0 +1,25 @@ +metricsDisk()['data'] ?? []; + } catch (\Exception $e) { + return []; + } + } +} diff --git a/app/Filament/Admin/Widgets/DnsPendingAddsTable.php b/app/Filament/Admin/Widgets/DnsPendingAddsTable.php new file mode 100644 index 0000000..6b72fb3 --- /dev/null +++ b/app/Filament/Admin/Widgets/DnsPendingAddsTable.php @@ -0,0 +1,92 @@ +> + */ + protected function getRecords(): array + { + return collect($this->records)->values()->all(); + } + + public function table(Table $table): Table + { + return $table + ->records(fn () => $this->getRecords()) + ->columns([ + TextColumn::make('type') + ->label(__('Type')) + ->badge() + ->color('success'), + TextColumn::make('name') + ->label(__('Name')) + ->fontFamily(FontFamily::Mono), + TextColumn::make('content') + ->label(__('Content')) + ->fontFamily(FontFamily::Mono) + ->limit(50) + ->tooltip(fn (array $record): string => (string) ($record['content'] ?? '')), + TextColumn::make('ttl') + ->label(__('TTL')), + TextColumn::make('priority') + ->label(__('Priority')) + ->placeholder('-'), + ]) + ->actions([ + Action::make('removePending') + ->label(__('Remove')) + ->icon('heroicon-o-x-mark') + ->color('danger') + ->action(function (array $record): void { + $key = $record['key'] ?? null; + if (! $key) { + return; + } + + $this->dispatch('dns-pending-add-remove', key: $key); + }), + ]) + ->striped() + ->paginated(false) + ->emptyStateHeading(__('No pending records')) + ->emptyStateDescription(__('Queued records will appear here.')) + ->emptyStateIcon('heroicon-o-plus-circle') + ->poll(null); + } + + public function render() + { + return $this->getTable()->render(); + } +} diff --git a/app/Filament/Admin/Widgets/DomainIpAssignmentsTable.php b/app/Filament/Admin/Widgets/DomainIpAssignmentsTable.php new file mode 100644 index 0000000..c2f010d --- /dev/null +++ b/app/Filament/Admin/Widgets/DomainIpAssignmentsTable.php @@ -0,0 +1,319 @@ +loadDefaults(); + } + + #[On('ip-defaults-updated')] + public function refreshDefaults(): void + { + $this->loadDefaults(); + $this->resetTable(); + } + + protected function loadDefaults(): void + { + $settings = DnsSetting::getAll(); + $this->defaultIp = $settings['default_ip'] ?? null; + $this->defaultIpv6 = $settings['default_ipv6'] ?? null; + } + + protected function getAgent(): AgentClient + { + return $this->agent ??= new AgentClient; + } + + public function table(Table $table): Table + { + return $table + ->query(Domain::query()->with('user')->orderBy('domain')) + ->columns([ + TextColumn::make('domain') + ->label(__('Domain')) + ->searchable() + ->sortable() + ->description(fn (Domain $record) => $record->user?->username ?? __('Unknown')), + TextColumn::make('ip_address') + ->label(__('IPv4')) + ->badge() + ->color(fn (Domain $record): string => $record->ip_address ? 'primary' : 'gray') + ->getStateUsing(fn (Domain $record): string => $this->formatIpDisplay($record->ip_address, $this->defaultIp)) + ->description(fn (Domain $record): string => $this->formatIpDescription($record->ip_address, $this->defaultIp)), + TextColumn::make('ipv6_address') + ->label(__('IPv6')) + ->badge() + ->color(fn (Domain $record): string => $record->ipv6_address ? 'primary' : 'gray') + ->getStateUsing(fn (Domain $record): string => $this->formatIpDisplay($record->ipv6_address, $this->defaultIpv6)) + ->description(fn (Domain $record): string => $this->formatIpDescription($record->ipv6_address, $this->defaultIpv6)), + ]) + ->recordActions([ + Action::make('assign') + ->label(__('Assign IPs')) + ->icon('heroicon-o-adjustments-horizontal') + ->color('primary') + ->modalHeading(fn (Domain $record): string => __('Assign IPs for :domain', ['domain' => $record->domain])) + ->modalDescription(__('Select the IPv4 and IPv6 addresses to use for this domain.')) + ->modalSubmitActionLabel(__('Save Assignments')) + ->form([ + Select::make('ip_address') + ->label(__('IPv4 Address')) + ->options(fn () => $this->getIpv4Options()) + ->placeholder(__('Use default IPv4')) + ->searchable() + ->nullable(), + Select::make('ipv6_address') + ->label(__('IPv6 Address')) + ->options(fn () => $this->getIpv6Options()) + ->placeholder(__('Use default IPv6')) + ->searchable() + ->nullable(), + ]) + ->fillForm(fn (Domain $record): array => [ + 'ip_address' => $record->ip_address, + 'ipv6_address' => $record->ipv6_address, + ]) + ->action(fn (Domain $record, array $data) => $this->assignIps($record, $data)), + ]) + ->striped() + ->emptyStateHeading(__('No domains found')) + ->emptyStateDescription(__('Create a domain to assign IPs.')) + ->emptyStateIcon('heroicon-o-globe-alt'); + } + + protected function formatIpDisplay(?string $assigned, ?string $default): string + { + if (! empty($assigned)) { + return $assigned; + } + + return $default ?: '-'; + } + + protected function formatIpDescription(?string $assigned, ?string $default): string + { + if (! empty($assigned)) { + return __('Assigned'); + } + + return $default ? __('Default') : __('Not set'); + } + + /** + * @return array + */ + protected function getIpv4Options(): array + { + return $this->getIpOptionsByVersion(4); + } + + /** + * @return array + */ + protected function getIpv6Options(): array + { + return $this->getIpOptionsByVersion(6); + } + + /** + * @return array + */ + protected function getIpOptionsByVersion(int $version): array + { + try { + $result = $this->getAgent()->ipList(); + } catch (Exception) { + return []; + } + + $options = []; + foreach (($result['addresses'] ?? []) as $address) { + $addressVersion = (int) ($address['version'] ?? 4); + if ($addressVersion !== $version) { + continue; + } + + $ip = $address['ip'] ?? null; + if (! $ip) { + continue; + } + + $options[$ip] = $this->formatIpOptionLabel($address); + } + + return $options; + } + + protected function formatIpOptionLabel(array $address): string + { + $ip = $address['ip'] ?? ''; + $cidr = $address['cidr'] ?? null; + $interface = $address['interface'] ?? null; + $scope = $address['scope'] ?? null; + + $label = $ip; + if ($cidr) { + $label .= '/'.$cidr; + } + if ($interface) { + $label .= ' • '.$interface; + } + if ($scope) { + $label .= ' • '.$scope; + } + + return $label; + } + + protected function assignIps(Domain $record, array $data): void + { + $settings = DnsSetting::getAll(); + $defaultIp = $settings['default_ip'] ?? null; + $defaultIpv6 = $settings['default_ipv6'] ?? null; + + $previousIpv4 = $record->ip_address ?: $defaultIp; + $previousIpv6 = $record->ipv6_address ?: $defaultIpv6; + + $record->update([ + 'ip_address' => $data['ip_address'] ?: null, + 'ipv6_address' => $data['ipv6_address'] ?: null, + ]); + + $newIpv4 = $record->ip_address ?: $defaultIp; + $newIpv6 = $record->ipv6_address ?: $defaultIpv6; + $ttl = (int) ($settings['default_ttl'] ?? 3600); + + $this->updateDefaultDnsRecords($record, $previousIpv4, $newIpv4, $previousIpv6, $newIpv6, $ttl); + $this->syncDnsZone($record); + + Notification::make() + ->title(__('IP assignments updated')) + ->success() + ->send(); + $this->dispatch('notificationsSent'); + + $this->resetTable(); + } + + protected function updateDefaultDnsRecords(Domain $domain, ?string $previousIpv4, ?string $newIpv4, ?string $previousIpv6, ?string $newIpv6, int $ttl): void + { + foreach (['@', 'www', 'mail'] as $name) { + $this->updateDnsRecord($domain, $name, 'A', $previousIpv4, $newIpv4, $ttl); + $this->updateDnsRecord($domain, $name, 'AAAA', $previousIpv6, $newIpv6, $ttl); + } + } + + protected function updateDnsRecord(Domain $domain, string $name, string $type, ?string $previousContent, ?string $newContent, int $ttl): void + { + $query = DnsRecord::query() + ->where('domain_id', $domain->id) + ->where('name', $name) + ->where('type', $type); + + if (empty($newContent)) { + if (! empty($previousContent)) { + $query->where('content', $previousContent)->delete(); + } + + return; + } + + if (! empty($previousContent)) { + $updated = (clone $query) + ->where('content', $previousContent) + ->update(['content' => $newContent, 'ttl' => $ttl]); + + if ($updated > 0) { + return; + } + } + + $exists = (clone $query) + ->where('content', $newContent) + ->exists(); + + if (! $exists) { + DnsRecord::create([ + 'domain_id' => $domain->id, + 'name' => $name, + 'type' => $type, + 'content' => $newContent, + 'ttl' => $ttl, + 'priority' => null, + ]); + } + } + + protected function syncDnsZone(Domain $domain): void + { + $settings = DnsSetting::getAll(); + $hostname = gethostname() ?: 'localhost'; + $serverIp = trim(shell_exec("hostname -I | awk '{print $1}'") ?? '') ?: '127.0.0.1'; + + try { + $records = $domain->dnsRecords()->get()->toArray(); + $this->getAgent()->send('dns.sync_zone', [ + 'domain' => $domain->domain, + 'records' => $records, + 'ns1' => $settings['ns1'] ?? "ns1.{$hostname}", + 'ns2' => $settings['ns2'] ?? "ns2.{$hostname}", + 'admin_email' => $settings['admin_email'] ?? "admin.{$hostname}", + 'default_ip' => $settings['default_ip'] ?? $serverIp, + 'default_ttl' => (int) ($settings['default_ttl'] ?? 3600), + ]); + } catch (Exception $e) { + Notification::make() + ->title(__('DNS sync failed')) + ->body($e->getMessage()) + ->warning() + ->send(); + $this->dispatch('notificationsSent'); + } + } + + public function render() + { + return $this->getTable()->render(); + } +} diff --git a/app/Filament/Admin/Widgets/MemoryWidget.php b/app/Filament/Admin/Widgets/MemoryWidget.php new file mode 100644 index 0000000..8f5c90a --- /dev/null +++ b/app/Filament/Admin/Widgets/MemoryWidget.php @@ -0,0 +1,51 @@ +metricsOverview(); + $memory = $overview['memory'] ?? []; + } catch (\Exception $e) { + return []; + } + + $total = ($memory['total'] ?? 0) / 1024; + $used = ($memory['used'] ?? 0) / 1024; + $cached = ($memory['cached'] ?? 0) / 1024; + $available = ($memory['available'] ?? 0) / 1024; + + return [ + Stat::make(__('Total'), number_format($total, 1) . ' GB') + ->description(__('Total memory')) + ->color('gray'), + + Stat::make(__('Used'), number_format($used, 1) . ' GB') + ->description(__('In use')) + ->color('success'), + + Stat::make(__('Cached'), number_format($cached, 1) . ' GB') + ->description(__('Cached data')) + ->color('primary'), + + Stat::make(__('Available'), number_format($available, 1) . ' GB') + ->description(__('Free to use')) + ->color('warning'), + ]; + } +} diff --git a/app/Filament/Admin/Widgets/NetworkTableWidget.php b/app/Filament/Admin/Widgets/NetworkTableWidget.php new file mode 100644 index 0000000..578c5a1 --- /dev/null +++ b/app/Filament/Admin/Widgets/NetworkTableWidget.php @@ -0,0 +1,88 @@ +loadNetwork(); + } + + protected function loadNetwork(): void + { + try { + $agent = new AgentClient(); + $network = $agent->metricsNetwork()['data'] ?? []; + + $interfaces = []; + foreach (($network['interfaces'] ?? []) as $name => $data) { + $interfaces[] = [ + 'name' => $name, + 'ip' => $data['ip'] ?? '-', + 'rx' => $data['rx_human'] ?? '0', + 'tx' => $data['tx_human'] ?? '0', + ]; + } + $this->interfaces = $interfaces; + } catch (\Exception $e) { + $this->interfaces = []; + } + } + + public function makeFilamentTranslatableContentDriver(): ?\Filament\Support\Contracts\TranslatableContentDriver + { + return null; + } + + public function table(Table $table): Table + { + return $table + ->records(fn () => $this->interfaces) + ->columns([ + TextColumn::make('name') + ->label(__('Interface')) + ->weight('medium'), + TextColumn::make('ip') + ->label(__('IP Address')) + ->fontFamily('mono') + ->color('gray'), + TextColumn::make('rx') + ->label(__('Download')) + ->icon('heroicon-o-arrow-down') + ->badge() + ->color('success'), + TextColumn::make('tx') + ->label(__('Upload')) + ->icon('heroicon-o-arrow-up') + ->badge() + ->color('info'), + ]) + ->paginated(false) + ->striped(); + } + + public function render() + { + return $this->getTable()->render(); + } +} diff --git a/app/Filament/Admin/Widgets/ProcessesWidget.php b/app/Filament/Admin/Widgets/ProcessesWidget.php new file mode 100644 index 0000000..5ccd9f0 --- /dev/null +++ b/app/Filament/Admin/Widgets/ProcessesWidget.php @@ -0,0 +1,25 @@ +metricsProcesses(10)['data'] ?? []; + } catch (\Exception $e) { + return []; + } + } +} diff --git a/app/Filament/Admin/Widgets/QuickActions.php b/app/Filament/Admin/Widgets/QuickActions.php new file mode 100644 index 0000000..7759f81 --- /dev/null +++ b/app/Filament/Admin/Widgets/QuickActions.php @@ -0,0 +1,67 @@ + __('Users'), + 'icon' => 'heroicon-o-users', + 'url' => route('filament.admin.resources.users.index'), + ], + [ + 'label' => __('Services'), + 'icon' => 'heroicon-o-server', + 'url' => route('filament.admin.pages.services'), + ], + [ + 'label' => __('Server'), + 'icon' => 'heroicon-o-cpu-chip', + 'url' => route('filament.admin.pages.server-status'), + ], + [ + 'label' => __('Settings'), + 'icon' => 'heroicon-o-cog-6-tooth', + 'url' => route('filament.admin.pages.server-settings'), + ], + [ + 'label' => __('Security'), + 'icon' => 'heroicon-o-shield-check', + 'url' => route('filament.admin.pages.security'), + ], + [ + 'label' => __('SSL'), + 'icon' => 'heroicon-o-lock-closed', + 'url' => route('filament.admin.pages.ssl-manager'), + ], + [ + 'label' => __('PHP'), + 'icon' => 'heroicon-o-code-bracket', + 'url' => route('filament.admin.pages.php-manager'), + ], + [ + 'label' => __('DNS'), + 'icon' => 'heroicon-o-server-stack', + 'url' => route('filament.admin.pages.dns-zones'), + ], + [ + 'label' => __('Backups'), + 'icon' => 'heroicon-o-archive-box', + 'url' => route('filament.admin.pages.backups'), + ], + ]; + } +} diff --git a/app/Filament/Admin/Widgets/Security/AuditLogsTable.php b/app/Filament/Admin/Widgets/Security/AuditLogsTable.php new file mode 100644 index 0000000..a6e465b --- /dev/null +++ b/app/Filament/Admin/Widgets/Security/AuditLogsTable.php @@ -0,0 +1,83 @@ +query(AuditLog::query()->with('user')->latest()) + ->columns([ + TextColumn::make('action') + ->label(__('Action')) + ->badge() + ->color(fn (string $state): string => match ($state) { + 'login', 'logout' => 'info', + 'create', 'created' => 'success', + 'delete', 'deleted' => 'danger', + 'update', 'updated' => 'warning', + default => 'gray', + }) + ->searchable(), + TextColumn::make('category') + ->label(__('Category')) + ->badge() + ->color('gray') + ->searchable(), + TextColumn::make('description') + ->label(__('Description')) + ->limit(50) + ->tooltip(fn ($record) => $record->description) + ->searchable(), + TextColumn::make('user.name') + ->label(__('User')) + ->default(__('System')) + ->searchable(), + TextColumn::make('ip_address') + ->label(__('IP')) + ->fontFamily('mono') + ->size('xs') + ->color('gray') + ->searchable(), + TextColumn::make('created_at') + ->label(__('Time')) + ->since() + ->sortable(), + ]) + ->defaultSort('created_at', 'desc') + ->striped() + ->paginated([10, 25, 50, 100]) + ->defaultPaginationPageOption(10) + ->emptyStateHeading(__('No audit logs')) + ->emptyStateDescription(__('No actions have been logged yet.')) + ->emptyStateIcon('heroicon-o-clipboard-document-list'); + } + + public function render() + { + return $this->getTable()->render(); + } +} diff --git a/app/Filament/Admin/Widgets/Security/BannedIpsTable.php b/app/Filament/Admin/Widgets/Security/BannedIpsTable.php new file mode 100644 index 0000000..cbd9c43 --- /dev/null +++ b/app/Filament/Admin/Widgets/Security/BannedIpsTable.php @@ -0,0 +1,134 @@ +send('fail2ban.status', []); + if ($result['success'] ?? false) { + $this->jails = $result['jails'] ?? []; + $this->resetTable(); + } + } catch (\Exception $e) { + // Keep existing data on error + } + } + + public function makeFilamentTranslatableContentDriver(): ?\Filament\Support\Contracts\TranslatableContentDriver + { + return null; + } + + protected function getBannedIpRecords(): array + { + $records = []; + foreach ($this->jails as $jail) { + $jailName = $jail['name'] ?? ''; + $bannedIps = $jail['banned_ips'] ?? []; + foreach ($bannedIps as $ip) { + $records[] = [ + 'id' => "{$jailName}_{$ip}", + 'jail' => $jailName, + 'ip' => $ip, + ]; + } + } + return $records; + } + + public function table(Table $table): Table + { + return $table + ->records(fn () => $this->getBannedIpRecords()) + ->columns([ + TextColumn::make('jail') + ->label(__('Service')) + ->badge() + ->color('danger') + ->formatStateUsing(fn (string $state): string => ucfirst($state)), + TextColumn::make('ip') + ->label(__('IP Address')) + ->icon('heroicon-o-globe-alt') + ->fontFamily('mono') + ->copyable() + ->searchable(), + ]) + ->actions([ + Action::make('unban') + ->label(__('Unban')) + ->icon('heroicon-o-lock-open') + ->color('success') + ->requiresConfirmation() + ->modalHeading(__('Unban IP Address')) + ->modalDescription(fn (array $record): string => __('Are you sure you want to unban :ip from :jail?', [ + 'ip' => $record['ip'] ?? '', + 'jail' => ucfirst($record['jail'] ?? ''), + ])) + ->action(function (array $record): void { + try { + $agent = new AgentClient(); + $result = $agent->send('fail2ban.unban_ip', [ + 'jail' => $record['jail'], + 'ip' => $record['ip'], + ]); + + if ($result['success'] ?? false) { + Notification::make() + ->title(__('IP Unbanned')) + ->body(__(':ip has been unbanned from :jail', [ + 'ip' => $record['ip'], + 'jail' => ucfirst($record['jail']), + ])) + ->success() + ->send(); + + // Reload banned IPs data directly + $this->reloadBannedIps(); + } else { + throw new \Exception($result['error'] ?? __('Failed to unban IP')); + } + } catch (\Exception $e) { + Notification::make() + ->title(__('Error')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + }), + ]) + ->striped() + ->emptyStateHeading(__('No banned IPs')) + ->emptyStateDescription(__('No IP addresses are currently banned.')) + ->emptyStateIcon('heroicon-o-check-circle'); + } + + public function render() + { + return $this->getTable()->render(); + } +} diff --git a/app/Filament/Admin/Widgets/Security/JailsTable.php b/app/Filament/Admin/Widgets/Security/JailsTable.php new file mode 100644 index 0000000..51dcf64 --- /dev/null +++ b/app/Filament/Admin/Widgets/Security/JailsTable.php @@ -0,0 +1,121 @@ +send('fail2ban.list_jails', []); + if ($result['success'] ?? false) { + $this->jails = $result['jails'] ?? []; + $this->resetTable(); + } + } catch (\Exception $e) { + // Keep existing data on error + } + } + + public function makeFilamentTranslatableContentDriver(): ?\Filament\Support\Contracts\TranslatableContentDriver + { + return null; + } + + public function table(Table $table): Table + { + return $table + ->records(fn () => $this->jails) + ->columns([ + TextColumn::make('name') + ->label(__('Service')) + ->formatStateUsing(fn (string $state): string => ucfirst($state)) + ->searchable(), + TextColumn::make('description') + ->label(__('Description')) + ->limit(50) + ->color('gray'), + IconColumn::make('active') + ->label(__('Active')) + ->boolean() + ->trueIcon('heroicon-o-check-circle') + ->falseIcon('heroicon-o-x-circle') + ->trueColor('success') + ->falseColor('gray'), + IconColumn::make('enabled') + ->label(__('Enabled')) + ->boolean() + ->trueIcon('heroicon-o-shield-check') + ->falseIcon('heroicon-o-shield-exclamation') + ->trueColor('success') + ->falseColor('gray'), + ]) + ->actions([ + Action::make('toggle') + ->label(fn (array $record): string => ($record['enabled'] ?? false) ? __('Disable') : __('Enable')) + ->icon(fn (array $record): string => ($record['enabled'] ?? false) ? 'heroicon-o-x-circle' : 'heroicon-o-check-circle') + ->color(fn (array $record): string => ($record['enabled'] ?? false) ? 'danger' : 'success') + ->disabled(fn (array $record): bool => ($record['name'] ?? '') === 'sshd') + ->action(function (array $record): void { + $name = $record['name'] ?? ''; + $enabled = $record['enabled'] ?? false; + + try { + $agent = new AgentClient(); + $action = $enabled ? 'fail2ban.disable_jail' : 'fail2ban.enable_jail'; + $result = $agent->send($action, ['jail' => $name]); + + if ($result['success'] ?? false) { + Notification::make() + ->title($enabled ? __('Jail disabled') : __('Jail enabled')) + ->success() + ->send(); + + // Reload jails data directly + $this->reloadJails(); + } else { + throw new \Exception($result['error'] ?? __('Operation failed')); + } + } catch (\Exception $e) { + Notification::make() + ->title(__('Error')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + }), + ]) + ->striped() + ->emptyStateHeading(__('No protection modules')) + ->emptyStateDescription(__('No Fail2ban jails are configured.')) + ->emptyStateIcon('heroicon-o-shield-exclamation'); + } + + public function render() + { + return $this->getTable()->render(); + } +} diff --git a/app/Filament/Admin/Widgets/Security/LynisResultsTable.php b/app/Filament/Admin/Widgets/Security/LynisResultsTable.php new file mode 100644 index 0000000..e214228 --- /dev/null +++ b/app/Filament/Admin/Widgets/Security/LynisResultsTable.php @@ -0,0 +1,66 @@ +results[$this->type] ?? []; + return array_map(fn ($item, $index) => [ + 'id' => $index, + 'message' => $item, + ], $items, array_keys($items)); + } + + public function table(Table $table): Table + { + $icon = $this->type === 'warnings' ? 'heroicon-o-exclamation-triangle' : 'heroicon-o-light-bulb'; + $color = $this->type === 'warnings' ? 'warning' : 'info'; + + return $table + ->records(fn () => $this->getRecords()) + ->columns([ + TextColumn::make('message') + ->label($this->type === 'warnings' ? __('Warning') : __('Suggestion')) + ->icon($icon) + ->iconColor($color) + ->wrap() + ->searchable(), + ]) + ->striped() + ->paginated([10, 25, 50]) + ->emptyStateHeading($this->type === 'warnings' ? __('No warnings') : __('No suggestions')) + ->emptyStateDescription(__('The system audit found no issues in this category.')) + ->emptyStateIcon('heroicon-o-check-circle'); + } + + public function render() + { + return $this->getTable()->render(); + } +} diff --git a/app/Filament/Admin/Widgets/Security/NiktoResultsTable.php b/app/Filament/Admin/Widgets/Security/NiktoResultsTable.php new file mode 100644 index 0000000..d58ffef --- /dev/null +++ b/app/Filament/Admin/Widgets/Security/NiktoResultsTable.php @@ -0,0 +1,66 @@ +results[$this->type] ?? []; + return array_map(fn ($item, $index) => [ + 'id' => $index, + 'message' => $item, + ], $items, array_keys($items)); + } + + public function table(Table $table): Table + { + $icon = $this->type === 'vulnerabilities' ? 'heroicon-o-exclamation-triangle' : 'heroicon-o-information-circle'; + $color = $this->type === 'vulnerabilities' ? 'danger' : 'info'; + + return $table + ->records(fn () => $this->getRecords()) + ->columns([ + TextColumn::make('message') + ->label($this->type === 'vulnerabilities' ? __('Vulnerability') : __('Information')) + ->icon($icon) + ->iconColor($color) + ->wrap() + ->searchable(), + ]) + ->striped() + ->paginated([10, 25, 50]) + ->emptyStateHeading($this->type === 'vulnerabilities' ? __('No vulnerabilities') : __('No information')) + ->emptyStateDescription(__('No issues found in this category.')) + ->emptyStateIcon('heroicon-o-check-circle'); + } + + public function render() + { + return $this->getTable()->render(); + } +} diff --git a/app/Filament/Admin/Widgets/Security/QuarantinedFilesTable.php b/app/Filament/Admin/Widgets/Security/QuarantinedFilesTable.php new file mode 100644 index 0000000..4d3b171 --- /dev/null +++ b/app/Filament/Admin/Widgets/Security/QuarantinedFilesTable.php @@ -0,0 +1,94 @@ +records(fn () => $this->files) + ->columns([ + TextColumn::make('name') + ->label(__('File Name')) + ->icon('heroicon-o-document') + ->searchable() + ->limit(40), + TextColumn::make('size') + ->label(__('Size')) + ->formatStateUsing(fn ($state): string => $state ? number_format($state / 1024, 1) . ' KB' : '-'), + TextColumn::make('date') + ->label(__('Quarantined')) + ->date('M d, Y H:i'), + ]) + ->actions([ + Action::make('delete') + ->label(__('Delete')) + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->modalHeading(__('Delete Quarantined File')) + ->modalDescription(__('Are you sure you want to permanently delete this file? This action cannot be undone.')) + ->action(function (array $record): void { + try { + $agent = new AgentClient(); + $result = $agent->send('clamav.delete_quarantined', [ + 'filename' => $record['name'], + ]); + + if ($result['success'] ?? false) { + Notification::make() + ->title(__('File Deleted')) + ->success() + ->send(); + + $this->dispatch('refresh-security-data'); + } else { + throw new \Exception($result['error'] ?? __('Failed to delete file')); + } + } catch (\Exception $e) { + Notification::make() + ->title(__('Error')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + }), + ]) + ->striped() + ->emptyStateHeading(__('No quarantined files')) + ->emptyStateDescription(__('No files are currently in quarantine.')) + ->emptyStateIcon('heroicon-o-archive-box'); + } + + public function render() + { + return $this->getTable()->render(); + } +} diff --git a/app/Filament/Admin/Widgets/Security/ThreatsTable.php b/app/Filament/Admin/Widgets/Security/ThreatsTable.php new file mode 100644 index 0000000..b68a507 --- /dev/null +++ b/app/Filament/Admin/Widgets/Security/ThreatsTable.php @@ -0,0 +1,59 @@ +records(fn () => array_reverse($this->threats)) + ->columns([ + TextColumn::make('threat') + ->label(__('Threat')) + ->icon('heroicon-o-exclamation-triangle') + ->iconColor('danger') + ->badge() + ->color('danger') + ->searchable(), + TextColumn::make('file') + ->label(__('File Path')) + ->formatStateUsing(fn (?string $state): string => $state ? basename($state) : '-') + ->tooltip(fn (array $record): string => $record['file'] ?? '') + ->fontFamily('mono') + ->limit(50), + ]) + ->striped() + ->emptyStateHeading(__('No threats detected')) + ->emptyStateDescription(__('No recent threats have been found.')) + ->emptyStateIcon('heroicon-o-check-circle'); + } + + public function render() + { + return $this->getTable()->render(); + } +} diff --git a/app/Filament/Admin/Widgets/Security/WpscanResultsTable.php b/app/Filament/Admin/Widgets/Security/WpscanResultsTable.php new file mode 100644 index 0000000..720b945 --- /dev/null +++ b/app/Filament/Admin/Widgets/Security/WpscanResultsTable.php @@ -0,0 +1,72 @@ +results['vulnerabilities'] ?? []; + return array_map(fn ($vuln, $index) => [ + 'id' => $index, + 'title' => is_array($vuln) ? ($vuln['title'] ?? 'Unknown') : $vuln, + 'severity' => is_array($vuln) ? ($vuln['severity'] ?? 'unknown') : 'unknown', + ], $vulnerabilities, array_keys($vulnerabilities)); + } + + public function table(Table $table): Table + { + return $table + ->records(fn () => $this->getRecords()) + ->columns([ + TextColumn::make('title') + ->label(__('Vulnerability')) + ->icon('heroicon-o-exclamation-triangle') + ->iconColor('danger') + ->wrap() + ->searchable(), + TextColumn::make('severity') + ->label(__('Severity')) + ->badge() + ->color(fn (string $state): string => match (strtolower($state)) { + 'critical' => 'danger', + 'high' => 'danger', + 'medium' => 'warning', + 'low' => 'info', + default => 'gray', + }), + ]) + ->striped() + ->emptyStateHeading(__('No vulnerabilities found')) + ->emptyStateDescription(__('The WordPress site appears to be secure.')) + ->emptyStateIcon('heroicon-o-check-circle'); + } + + public function render() + { + return $this->getTable()->render(); + } +} diff --git a/app/Filament/Admin/Widgets/ServerChartsWidget.php b/app/Filament/Admin/Widgets/ServerChartsWidget.php new file mode 100644 index 0000000..46abfed --- /dev/null +++ b/app/Filament/Admin/Widgets/ServerChartsWidget.php @@ -0,0 +1,77 @@ +refreshKey++; + $data = $this->getData(); + $this->dispatch('server-charts-updated', [ + 'cpu' => $data['cpu']['usage'] ?? 0, + 'memory' => $data['memory']['usage'] ?? 0, + 'disk' => $data['disk']['partitions'][0]['usage_percent'] ?? 0, + ]); + } + + public function getData(): array + { + try { + $agent = new AgentClient(); + $overview = $agent->metricsOverview(); + $disk = $agent->metricsDisk()['data'] ?? []; + + $cpu = $overview['cpu'] ?? []; + $memory = $overview['memory'] ?? []; + + // Calculate memory usage if not provided + $memUsage = $memory['usage_percent'] ?? $memory['usage'] ?? 0; + if ($memUsage == 0 && ($memory['total'] ?? 0) > 0) { + $memUsage = (($memory['used'] ?? 0) / $memory['total']) * 100; + } + + return [ + 'cpu' => [ + 'usage' => round($cpu['usage'] ?? 0, 1), + 'cores' => $cpu['cores'] ?? 0, + 'model' => $cpu['model'] ?? 'Unknown', + ], + 'memory' => [ + 'usage' => round($memUsage, 1), + 'used' => $memory['used'] ?? 0, + 'total' => $memory['total'] ?? 0, + 'free' => $memory['free'] ?? 0, + 'cached' => $memory['cached'] ?? 0, + ], + 'disk' => [ + 'partitions' => $disk['partitions'] ?? [], + ], + 'load' => $overview['load'] ?? [], + 'uptime' => $overview['uptime']['human'] ?? 'N/A', + ]; + } catch (\Exception $e) { + return [ + 'error' => $e->getMessage(), + 'cpu' => ['usage' => 0, 'cores' => 0, 'model' => 'Error'], + 'memory' => ['usage' => 0, 'used' => 0, 'total' => 0, 'free' => 0, 'cached' => 0], + 'disk' => ['partitions' => []], + 'load' => [], + 'uptime' => 'N/A', + ]; + } + } +} diff --git a/app/Filament/Admin/Widgets/ServerInfoWidget.php b/app/Filament/Admin/Widgets/ServerInfoWidget.php new file mode 100644 index 0000000..e4aae67 --- /dev/null +++ b/app/Filament/Admin/Widgets/ServerInfoWidget.php @@ -0,0 +1,16 @@ +metricsOverview(); + $cpu = $overview['cpu'] ?? []; + $memory = $overview['memory'] ?? []; + $disk = $agent->metricsDisk()['data'] ?? []; + } catch (\Exception $e) { + return [ + Stat::make(__('Error'), __('Failed to load metrics')) + ->description($e->getMessage()) + ->color('danger'), + ]; + } + + $cpuUsage = $cpu['usage'] ?? 0; + $memUsage = $memory['usage_percent'] ?? 0; + $loadAvg = $overview['load']['1min'] ?? 0; + $uptime = $overview['uptime']['human'] ?? 'N/A'; + + // Disk I/O - get first disk's I/O stats + $io = $disk['io'] ?? []; + $firstDisk = array_values($io)[0] ?? []; + $readBytes = ($firstDisk['read_sectors'] ?? 0) * 512; + $writeBytes = ($firstDisk['write_sectors'] ?? 0) * 512; + $ioWait = $this->getIoWait(); + + return [ + Stat::make(__('CPU Usage'), $cpuUsage . '%') + ->description(($cpu['cores'] ?? 0) . ' ' . __('cores')) + ->descriptionIcon('heroicon-m-cpu-chip') + ->color('primary') + ->chart($this->generateSparkline($cpuUsage)), + + Stat::make(__('Memory'), $memUsage . '%') + ->description(number_format(($memory['used'] ?? 0) / 1024, 1) . ' / ' . number_format(($memory['total'] ?? 0) / 1024, 1) . ' GB') + ->descriptionIcon('heroicon-m-server') + ->color('info') + ->chart($this->generateSparkline($memUsage)), + + Stat::make(__('Load Average'), (string) $loadAvg) + ->description(__('Uptime') . ': ' . $uptime) + ->descriptionIcon('heroicon-m-clock') + ->color('warning'), + + Stat::make(__('Disk I/O'), $ioWait . '% ' . __('wait')) + ->description(__('R') . ': ' . $this->formatBytes($readBytes) . ' | ' . __('W') . ': ' . $this->formatBytes($writeBytes)) + ->descriptionIcon('heroicon-m-circle-stack') + ->color($ioWait > 20 ? 'danger' : ($ioWait > 10 ? 'warning' : 'gray')), + ]; + } + + protected function getIoWait(): float + { + $stat = @file_get_contents('/proc/stat'); + if (!$stat) { + return 0; + } + + if (preg_match('/^cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/m', $stat, $matches)) { + $user = (int) $matches[1]; + $nice = (int) $matches[2]; + $system = (int) $matches[3]; + $idle = (int) $matches[4]; + $iowait = (int) $matches[5]; + + $total = $user + $nice + $system + $idle + $iowait; + if ($total > 0) { + return round(($iowait / $total) * 100, 1); + } + } + + return 0; + } + + protected function formatBytes(int $bytes): string + { + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $i = 0; + while ($bytes >= 1024 && $i < count($units) - 1) { + $bytes /= 1024; + $i++; + } + return round($bytes, 1) . ' ' . $units[$i]; + } + + protected function generateSparkline(float $value): array + { + // Generate a simple sparkline based on current value + $base = max(0, $value - 20); + return [ + $base + rand(0, 10), + $base + rand(0, 15), + $base + rand(0, 10), + $base + rand(0, 15), + $base + rand(0, 10), + $value, + ]; + } +} diff --git a/app/Filament/Admin/Widgets/ServicesTableWidget.php b/app/Filament/Admin/Widgets/ServicesTableWidget.php new file mode 100644 index 0000000..e91f488 --- /dev/null +++ b/app/Filament/Admin/Widgets/ServicesTableWidget.php @@ -0,0 +1,199 @@ +loadServices(); + } + + protected function loadServices(): void + { + $serviceList = [ + 'nginx' => 'Nginx', + 'mariadb' => 'MariaDB', + 'php-fpm' => 'PHP-FPM', + 'postfix' => 'Postfix', + 'dovecot' => 'Dovecot', + 'named' => 'BIND DNS', + 'redis-server' => 'Redis', + 'fail2ban' => 'Fail2Ban', + ]; + + $result = []; + foreach ($serviceList as $service => $name) { + $status = $this->checkServiceStatus($service); + if ($status !== null) { + $result[] = [ + 'key' => $service, + 'name' => $name, + 'active' => $status, + ]; + } + } + + $this->services = $result; + } + + protected function checkServiceStatus(string $service): ?bool + { + exec("systemctl list-unit-files {$service}.service 2>/dev/null | grep -q {$service}", $output, $exists); + if ($exists !== 0) { + if ($service === 'mariadb') { + exec('systemctl list-unit-files mysql.service 2>/dev/null | grep -q mysql', $output, $mysqlExists); + if ($mysqlExists !== 0) { + return null; + } + $service = 'mysql'; + } elseif ($service === 'php-fpm') { + exec("systemctl list-unit-files 'php*-fpm.service' 2>/dev/null | grep -oP 'php[0-9.]+-fpm'", $phpOutput); + if (empty($phpOutput)) { + return null; + } + $service = trim($phpOutput[0]); + } else { + return null; + } + } + + exec("systemctl is-active {$service} 2>/dev/null", $statusOutput, $code); + + return $code === 0; + } + + public function makeFilamentTranslatableContentDriver(): ?\Filament\Support\Contracts\TranslatableContentDriver + { + return null; + } + + public function table(Table $table): Table + { + return $table + ->records(fn () => $this->services) + ->columns([ + TextColumn::make('name') + ->label(__('Service')) + ->weight('medium'), + IconColumn::make('active') + ->label(__('Status')) + ->boolean() + ->trueIcon('heroicon-o-check-circle') + ->falseIcon('heroicon-o-x-circle') + ->trueColor('success') + ->falseColor('danger'), + ]) + ->actions([ + Action::make('start') + ->label(__('Start')) + ->icon('heroicon-o-play') + ->color('success') + ->size('sm') + ->visible(fn (array $record): bool => ! ($record['active'] ?? true)) + ->action(function (array $record): void { + try { + $agent = new AgentClient; + $result = $agent->send('service.start', ['service' => $record['key']]); + + if ($result['success'] ?? false) { + Notification::make() + ->title(__('Service started')) + ->body(__(':service has been started successfully.', ['service' => $record['name']])) + ->success() + ->send(); + + $this->loadServices(); + $this->resetTable(); + } else { + throw new \Exception($result['error'] ?? __('Failed to start service')); + } + } catch (\Exception $e) { + Notification::make() + ->title(__('Failed to start service')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + }), + Action::make('restart') + ->label(fn (array $record): string => $this->shouldReloadService($record['key']) ? __('Reload') : __('Restart')) + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->size('sm') + ->visible(fn (array $record): bool => $record['active'] ?? false) + ->requiresConfirmation() + ->modalHeading(fn (array $record): string => $this->shouldReloadService($record['key']) ? __('Reload Service') : __('Restart Service')) + ->modalDescription(fn (array $record): string => $this->shouldReloadService($record['key']) + ? __('Are you sure you want to reload :service?', ['service' => $record['name']]) + : __('Are you sure you want to restart :service?', ['service' => $record['name']]) + ) + ->action(function (array $record): void { + try { + $agent = new AgentClient; + $action = $this->shouldReloadService($record['key']) ? 'reload' : 'restart'; + $result = $agent->send("service.{$action}", ['service' => $record['key']]); + + if ($result['success'] ?? false) { + $verb = $action === 'reload' ? __('reloaded') : __('restarted'); + Notification::make() + ->title($action === 'reload' ? __('Service reloaded') : __('Service restarted')) + ->body(__(':service has been :action successfully.', ['service' => $record['name'], 'action' => $verb])) + ->success() + ->send(); + + sleep(1); + $this->loadServices(); + $this->resetTable(); + } else { + throw new \Exception($result['error'] ?? ($action === 'reload' ? __('Failed to reload service') : __('Failed to restart service'))); + } + } catch (\Exception $e) { + Notification::make() + ->title($this->shouldReloadService($record['key']) ? __('Failed to reload service') : __('Failed to restart service')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + }), + ]) + ->paginated(false) + ->striped(); + } + + public function render() + { + return $this->getTable()->render(); + } + + protected function shouldReloadService(string $service): bool + { + if ($service === 'nginx') { + return true; + } + + return preg_match('/^php(\d+\.\d+)?-fpm$/', $service) === 1; + } +} diff --git a/app/Filament/Admin/Widgets/Settings/DnssecTable.php b/app/Filament/Admin/Widgets/Settings/DnssecTable.php new file mode 100644 index 0000000..fd907bd --- /dev/null +++ b/app/Filament/Admin/Widgets/Settings/DnssecTable.php @@ -0,0 +1,217 @@ +getAgent()->dnsGetDnssecStatus($domain); + if ($result['success'] ?? false) { + return [ + 'enabled' => $result['enabled'] ?? false, + 'keys' => $result['keys'] ?? [], + 'message' => $result['message'] ?? '', + ]; + } + } catch (\Exception $e) { + // Silently fail + } + + return ['enabled' => false, 'keys' => [], 'message' => '']; + } + + protected function getDsRecords(string $domain): ?array + { + try { + $result = $this->getAgent()->dnsGetDsRecords($domain); + if ($result['success'] ?? false) { + return $result; + } + } catch (\Exception $e) { + // Silently fail + } + + return null; + } + + public function table(Table $table): Table + { + return $table + ->query( + Domain::query()->orderBy('domain') + ) + ->columns([ + TextColumn::make('domain') + ->label(__('Domain')) + ->searchable() + ->sortable(), + TextColumn::make('user.username') + ->label(__('Owner')) + ->sortable(), + IconColumn::make('dnssec_status') + ->label(__('DNSSEC')) + ->state(function (Domain $record): bool { + $status = $this->getDnssecStatus($record->domain); + return $status['enabled'] ?? false; + }) + ->boolean() + ->trueIcon('heroicon-o-shield-check') + ->falseIcon('heroicon-o-shield-exclamation') + ->trueColor('success') + ->falseColor('gray'), + TextColumn::make('keys_info') + ->label(__('Keys')) + ->state(function (Domain $record): string { + $status = $this->getDnssecStatus($record->domain); + if (!($status['enabled'] ?? false)) { + return '-'; + } + $keys = $status['keys'] ?? []; + if (empty($keys)) { + return '-'; + } + $ksk = collect($keys)->firstWhere('type', 'KSK'); + $zsk = collect($keys)->firstWhere('type', 'ZSK'); + $info = []; + if ($ksk) { + $info[] = "KSK: {$ksk['key_id']}"; + } + if ($zsk) { + $info[] = "ZSK: {$zsk['key_id']}"; + } + return implode(', ', $info) ?: '-'; + }) + ->fontFamily('mono') + ->color('gray'), + ]) + ->actions([ + Action::make('enable') + ->label(__('Enable')) + ->icon('heroicon-o-shield-check') + ->color('success') + ->requiresConfirmation() + ->modalHeading(fn (Domain $record): string => __('Enable DNSSEC for :domain', ['domain' => $record->domain])) + ->modalDescription(__('This will generate DNSSEC keys and sign the zone. After enabling, you must add the DS record to your domain registrar.')) + ->modalIcon('heroicon-o-shield-check') + ->modalIconColor('success') + ->visible(function (Domain $record): bool { + $status = $this->getDnssecStatus($record->domain); + return !($status['enabled'] ?? false); + }) + ->action(function (Domain $record): void { + try { + $result = $this->getAgent()->dnsEnableDnssec($record->domain); + if ($result['success'] ?? false) { + Notification::make() + ->title(__('DNSSEC Enabled')) + ->body(__('DNSSEC has been enabled for :domain. Add the DS record to your registrar to complete setup.', ['domain' => $record->domain])) + ->success() + ->send(); + $this->resetTable(); + } else { + throw new \Exception($result['error'] ?? __('Unknown error')); + } + } catch (\Exception $e) { + Notification::make() + ->title(__('Failed to enable DNSSEC')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + }), + Action::make('viewDs') + ->label(__('DS Record')) + ->icon('heroicon-o-clipboard-document') + ->color('gray') + ->visible(function (Domain $record): bool { + $status = $this->getDnssecStatus($record->domain); + return $status['enabled'] ?? false; + }) + ->modalHeading(fn (Domain $record): string => __('DS Records for :domain', ['domain' => $record->domain])) + ->modalDescription(__('Add one of these DS records to your domain registrar to complete DNSSEC setup.')) + ->modalContent(function (Domain $record) { + $dsRecords = $this->getDsRecords($record->domain); + return view('filament.admin.components.dnssec-ds-records', ['dsRecords' => $dsRecords, 'domain' => $record->domain]); + }) + ->modalSubmitAction(false) + ->modalCancelActionLabel(__('Close')), + Action::make('disable') + ->label(__('Disable')) + ->icon('heroicon-o-shield-exclamation') + ->color('danger') + ->requiresConfirmation() + ->modalHeading(fn (Domain $record): string => __('Disable DNSSEC for :domain', ['domain' => $record->domain])) + ->modalDescription(__('Are you sure? Remember to remove the DS records from your registrar FIRST to avoid DNS resolution issues.')) + ->modalIcon('heroicon-o-exclamation-triangle') + ->modalIconColor('danger') + ->visible(function (Domain $record): bool { + $status = $this->getDnssecStatus($record->domain); + return $status['enabled'] ?? false; + }) + ->action(function (Domain $record): void { + try { + $result = $this->getAgent()->dnsDisableDnssec($record->domain); + if ($result['success'] ?? false) { + Notification::make() + ->title(__('DNSSEC Disabled')) + ->body(__('DNSSEC has been disabled for :domain.', ['domain' => $record->domain])) + ->success() + ->send(); + $this->resetTable(); + } else { + throw new \Exception($result['error'] ?? __('Unknown error')); + } + } catch (\Exception $e) { + Notification::make() + ->title(__('Failed to disable DNSSEC')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + }), + ]) + ->striped() + ->paginated([10, 25, 50]) + ->emptyStateHeading(__('No domains')) + ->emptyStateDescription(__('Add domains to manage their DNSSEC settings.')) + ->emptyStateIcon('heroicon-o-globe-alt'); + } + + public function render() + { + return $this->getTable()->render(); + } +} diff --git a/app/Filament/Admin/Widgets/Settings/NotificationLogTable.php b/app/Filament/Admin/Widgets/Settings/NotificationLogTable.php new file mode 100644 index 0000000..52495e0 --- /dev/null +++ b/app/Filament/Admin/Widgets/Settings/NotificationLogTable.php @@ -0,0 +1,164 @@ +query( + NotificationLog::query()->latest() + ) + ->columns([ + TextColumn::make('created_at') + ->label(__('Date')) + ->dateTime('M d, H:i') + ->sortable(), + TextColumn::make('type') + ->label(__('Type')) + ->badge() + ->formatStateUsing(fn (string $state): string => match ($state) { + 'ssl_errors' => __('SSL'), + 'backup_failures' => __('Backup'), + 'disk_quota' => __('Quota'), + 'login_failures' => __('Login'), + 'ssh_logins' => __('SSH'), + 'system_updates' => __('Updates'), + 'service_health' => __('Health'), + 'high_load' => __('Load'), + 'test' => __('Test'), + default => ucfirst(str_replace('_', ' ', $state)), + }) + ->color(fn (string $state): string => match ($state) { + 'ssl_errors' => 'warning', + 'backup_failures' => 'danger', + 'disk_quota' => 'warning', + 'login_failures' => 'danger', + 'ssh_logins' => 'info', + 'system_updates' => 'info', + 'service_health' => 'primary', + 'high_load' => 'danger', + 'test' => 'success', + default => 'gray', + }), + TextColumn::make('subject') + ->label(__('Subject')) + ->limit(40) + ->tooltip(fn (NotificationLog $record): string => $record->subject) + ->searchable(), + TextColumn::make('recipients') + ->label(__('Recipients')) + ->formatStateUsing(function ($state): string { + if (is_string($state)) { + $state = json_decode($state, true) ?? []; + } + return is_array($state) && count($state) > 0 ? implode(', ', $state) : '-'; + }) + ->limit(30) + ->color('gray'), + IconColumn::make('status') + ->label(__('Status')) + ->icon(fn (string $state): string => match ($state) { + 'sent' => 'heroicon-o-check-circle', + 'failed' => 'heroicon-o-x-circle', + 'skipped' => 'heroicon-o-minus-circle', + default => 'heroicon-o-question-mark-circle', + }) + ->color(fn (string $state): string => match ($state) { + 'sent' => 'success', + 'failed' => 'danger', + 'skipped' => 'gray', + default => 'gray', + }) + ->tooltip(fn (NotificationLog $record): ?string => $record->error), + ]) + ->filters([ + SelectFilter::make('status') + ->label(__('Status')) + ->options([ + 'sent' => __('Sent'), + 'failed' => __('Failed'), + 'skipped' => __('Skipped'), + ]), + SelectFilter::make('type') + ->label(__('Type')) + ->options([ + 'ssl_errors' => __('SSL Certificate'), + 'backup_failures' => __('Backup'), + 'disk_quota' => __('Disk Quota'), + 'login_failures' => __('Login Failure'), + 'ssh_logins' => __('SSH Login'), + 'system_updates' => __('System Updates'), + 'service_health' => __('Service Health'), + 'high_load' => __('High Load'), + 'test' => __('Test'), + ]), + ]) + ->actions([ + Action::make('view') + ->label(__('View')) + ->icon('heroicon-o-eye') + ->color('gray') + ->modalHeading(fn (NotificationLog $record): string => $record->subject) + ->modalContent(fn (NotificationLog $record) => view('filament.admin.components.notification-log-detail', ['record' => $record])) + ->modalSubmitAction(false) + ->modalCancelActionLabel(__('Close')), + ]) + ->headerActions([ + Action::make('cleanup') + ->label(__('Clear Old Logs')) + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->modalHeading(__('Clear Old Notification Logs')) + ->modalDescription(__('This will delete all notification logs older than 30 days. This action cannot be undone.')) + ->action(function (): void { + $deleted = NotificationLog::cleanup(30); + Notification::make() + ->title(__('Logs cleaned up')) + ->body(__(':count old log entries deleted', ['count' => $deleted])) + ->success() + ->send(); + $this->resetTable(); + }), + ]) + ->defaultSort('created_at', 'desc') + ->paginated([10, 25, 50]) + ->striped() + ->emptyStateHeading(__('No notification logs')) + ->emptyStateDescription(__('Sent notifications will appear here.')) + ->emptyStateIcon('heroicon-o-bell-slash'); + } + + public function render() + { + return $this->getTable()->render(); + } +} diff --git a/app/Filament/Admin/Widgets/SslStatsOverview.php b/app/Filament/Admin/Widgets/SslStatsOverview.php new file mode 100644 index 0000000..6fa7f05 --- /dev/null +++ b/app/Filament/Admin/Widgets/SslStatsOverview.php @@ -0,0 +1,64 @@ +count(); + $expiringSoon = SslCertificate::where('status', 'active') + ->where('expires_at', '<=', now()->addDays(30)) + ->where('expires_at', '>', now()) + ->count(); + $expired = SslCertificate::where('status', 'expired') + ->orWhere(function ($q) { + $q->where('expires_at', '<', now()); + }) + ->count(); + $failed = SslCertificate::where('status', 'failed')->count(); + $withoutSsl = $totalDomains - $domainsWithSsl; + + return [ + Stat::make(__('Total Domains'), (string) $totalDomains) + ->description(__('All registered domains')) + ->descriptionIcon('heroicon-m-globe-alt') + ->color('gray'), + + Stat::make(__('With SSL'), (string) $domainsWithSsl) + ->description(__('Active certificates')) + ->descriptionIcon('heroicon-m-shield-check') + ->color('success'), + + Stat::make(__('Without SSL'), (string) $withoutSsl) + ->description(__('No certificate')) + ->descriptionIcon('heroicon-m-shield-exclamation') + ->color('gray'), + + Stat::make(__('Expiring Soon'), (string) $expiringSoon) + ->description(__('Within 30 days')) + ->descriptionIcon('heroicon-m-clock') + ->color($expiringSoon > 0 ? 'warning' : 'success'), + + Stat::make(__('Expired'), (string) $expired) + ->description(__('Need renewal')) + ->descriptionIcon('heroicon-m-x-circle') + ->color($expired > 0 ? 'danger' : 'success'), + + Stat::make(__('Failed'), (string) $failed) + ->description(__('Issuance failed')) + ->descriptionIcon('heroicon-m-exclamation-triangle') + ->color($failed > 0 ? 'danger' : 'success'), + ]; + } +} diff --git a/app/Filament/Admin/Widgets/SystemInfoTableWidget.php b/app/Filament/Admin/Widgets/SystemInfoTableWidget.php new file mode 100644 index 0000000..8280234 --- /dev/null +++ b/app/Filament/Admin/Widgets/SystemInfoTableWidget.php @@ -0,0 +1,216 @@ +loadInfo(); + } + + protected function loadInfo(): void + { + try { + $agent = new AgentClient; + $overview = $agent->metricsOverview(); + $network = $agent->metricsNetwork()['data'] ?? []; + + // Get primary interface IP + $primaryIp = '127.0.0.1'; + $interfaces = $network['interfaces'] ?? []; + foreach ($interfaces as $name => $iface) { + if ($name !== 'lo' && ! empty($iface['ip'])) { + $primaryIp = explode('/', $iface['ip'])[0]; + break; + } + } + + // Parse OS info + $osInfo = $overview['os'] ?? []; + $osName = is_array($osInfo) ? ($osInfo['name'] ?? 'Linux') : 'Linux'; + $osVersion = is_array($osInfo) ? ($osInfo['version'] ?? '') : ''; + + // Format memory + $ramTotal = $overview['memory']['total'] ?? 0; + $ramUsed = $overview['memory']['used'] ?? 0; + $ramPercent = round($overview['memory']['usage_percent'] ?? 0, 1); + + // Shorten CPU model + $cpuModel = $overview['cpu']['model'] ?? 'Unknown'; + $cpuModel = str_replace(['(R)', '(TM)', 'CPU', '@'], '', $cpuModel); + $cpuModel = preg_replace('/\s+/', ' ', trim($cpuModel)); + if (strlen($cpuModel) > 35) { + $cpuModel = substr($cpuModel, 0, 32).'...'; + } + + $this->info = [ + [ + 'category' => __('Server'), + 'property' => __('Hostname'), + 'value' => $overview['hostname'] ?? gethostname(), + ], + [ + 'category' => __('Server'), + 'property' => __('Uptime'), + 'value' => $overview['uptime']['human'] ?? 'N/A', + ], + [ + 'category' => __('Server'), + 'property' => __('OS'), + 'value' => trim("$osName $osVersion") ?: 'Linux', + ], + [ + 'category' => __('Server'), + 'property' => __('IP Address'), + 'value' => $primaryIp, + ], + [ + 'category' => __('Server'), + 'property' => __('Connections'), + 'value' => number_format($network['connections'] ?? 0), + ], + [ + 'category' => __('Hardware'), + 'property' => __('Processor'), + 'value' => $cpuModel, + ], + [ + 'category' => __('Hardware'), + 'property' => __('CPU Cores'), + 'value' => (string) ($overview['cpu']['cores'] ?? 1), + ], + [ + 'category' => __('Hardware'), + 'property' => __('Memory'), + 'value' => $this->formatMb($ramUsed).' / '.$this->formatMb($ramTotal)." ({$ramPercent}%)", + ], + [ + 'category' => __('Software'), + 'property' => 'PHP', + 'value' => PHP_VERSION, + ], + [ + 'category' => __('Software'), + 'property' => __('Database'), + 'value' => $this->getDatabaseVersion(), + ], + [ + 'category' => __('Software'), + 'property' => __('Web Server'), + 'value' => $this->getWebserverVersion(), + ], + [ + 'category' => __('Software'), + 'property' => 'Laravel', + 'value' => app()->version(), + ], + ]; + } catch (\Exception $e) { + $this->info = []; + } + } + + protected function formatMb(int $mb): string + { + if ($mb >= 1024) { + return round($mb / 1024, 1).' GB'; + } + + return $mb.' MB'; + } + + protected function getDatabaseVersion(): string + { + // Try SQL query first + try { + $version = \DB::select('SELECT VERSION() as version')[0]->version ?? ''; + if (preg_match('/^(\d+\.\d+\.\d+)/', $version, $matches)) { + $dbType = str_contains(strtolower($version), 'mariadb') ? 'MariaDB' : 'MySQL'; + + return "$dbType {$matches[1]}"; + } + + if ($version) { + return $version; + } + } catch (\Exception $e) { + // Fall through to command line fallback + } + + // Fallback to mysqld --version command + $output = shell_exec('mysqld --version 2>/dev/null'); + if ($output && preg_match('/Ver\s+(\d+\.\d+\.\d+)-?(MariaDB)?/i', $output, $matches)) { + $dbType = ! empty($matches[2]) ? 'MariaDB' : 'MySQL'; + + return "$dbType {$matches[1]}"; + } + + return 'N/A'; + } + + protected function getWebserverVersion(): string + { + $version = shell_exec('nginx -v 2>&1'); + if ($version && preg_match('/nginx\/(\d+\.\d+\.\d+)/', $version, $matches)) { + return "Nginx {$matches[1]}"; + } + + return 'Nginx'; + } + + public function makeFilamentTranslatableContentDriver(): ?\Filament\Support\Contracts\TranslatableContentDriver + { + return null; + } + + public function table(Table $table): Table + { + return $table + ->records(fn () => $this->info) + ->columns([ + TextColumn::make('category') + ->label(__('Category')) + ->badge() + ->color(fn (string $state): string => match ($state) { + __('Server') => 'primary', + __('Hardware') => 'warning', + __('Software') => 'success', + default => 'gray', + }), + TextColumn::make('property') + ->label(__('Property')) + ->weight('medium'), + TextColumn::make('value') + ->label(__('Value')) + ->fontFamily('mono') + ->color('gray'), + ]) + ->paginated(false) + ->striped(); + } + + public function render() + { + return $this->getTable()->render(); + } +} diff --git a/app/Filament/Admin/Widgets/WhmAccountConfigTable.php b/app/Filament/Admin/Widgets/WhmAccountConfigTable.php new file mode 100644 index 0000000..90ddaf5 --- /dev/null +++ b/app/Filament/Admin/Widgets/WhmAccountConfigTable.php @@ -0,0 +1,119 @@ +accounts = ! empty($accounts) ? $accounts : session('whm_migration.accounts', []); + $this->selectedAccounts = ! empty($selectedAccounts) ? $selectedAccounts : session('whm_migration.selectedAccounts', []); + $this->accountConfig = ! empty($accountConfig) ? $accountConfig : session('whm_migration.accountConfig', []); + } + + #[On('whm-config-updated')] + public function refreshConfig(): void + { + // Reload from session since parent may have updated + $this->accounts = session('whm_migration.accounts', []); + $this->selectedAccounts = session('whm_migration.selectedAccounts', []); + $this->accountConfig = session('whm_migration.accountConfig', []); + $this->resetTable(); + } + + public function makeFilamentTranslatableContentDriver(): ?TranslatableContentDriver + { + return null; + } + + protected function getRecords(): array + { + $records = []; + + foreach ($this->selectedAccounts as $cpanelUser) { + $account = collect($this->accounts)->firstWhere('user', $cpanelUser); + $config = $this->accountConfig[$cpanelUser] ?? []; + + $domain = $account['domain'] ?? ''; + $email = $config['email'] ?? $account['email'] ?? "{$cpanelUser}@{$domain}"; + + $existingUser = User::where('username', $cpanelUser)->first(); + + $records[] = [ + 'user' => $cpanelUser, + 'domain' => $domain, + 'email' => $email, + 'exists' => $existingUser !== null, + 'diskused' => $account['diskused'] ?? '', + ]; + } + + return $records; + } + + public function table(Table $table): Table + { + return $table + ->records(fn () => $this->getRecords()) + ->columns([ + IconColumn::make('exists') + ->label(__('Status')) + ->boolean() + ->trueIcon('heroicon-o-exclamation-triangle') + ->falseIcon('heroicon-o-user-plus') + ->trueColor('warning') + ->falseColor('success') + ->tooltip(fn (array $record): string => $record['exists'] + ? __('User exists - will restore to existing account') + : __('New user will be created')), + TextColumn::make('user') + ->label(__('Username')) + ->weight('bold'), + TextColumn::make('domain') + ->label(__('Domain')), + TextColumn::make('email') + ->label(__('Email')) + ->icon('heroicon-o-envelope'), + TextColumn::make('diskused') + ->label(__('Size')), + ]) + ->striped() + ->paginated([10, 25, 50]) + ->defaultPaginationPageOption(10) + ->emptyStateHeading(__('No accounts selected')) + ->emptyStateDescription(__('Go back to Step 2 and select accounts to migrate')) + ->emptyStateIcon('heroicon-o-user-group'); + } + + public function render() + { + return $this->getTable()->render(); + } +} diff --git a/app/Filament/Admin/Widgets/WhmAccountsTable.php b/app/Filament/Admin/Widgets/WhmAccountsTable.php new file mode 100644 index 0000000..4a9a24e --- /dev/null +++ b/app/Filament/Admin/Widgets/WhmAccountsTable.php @@ -0,0 +1,146 @@ +accounts = ! empty($accounts) ? $accounts : session('whm_migration.accounts', []); + $this->selectedAccounts = ! empty($selectedAccounts) ? $selectedAccounts : session('whm_migration.selectedAccounts', []); + } + + #[On('whm-accounts-updated')] + public function refreshAccounts(): void + { + // Reload from session since parent may have updated + $this->accounts = session('whm_migration.accounts', []); + $this->selectedAccounts = session('whm_migration.selectedAccounts', []); + $this->resetTable(); + } + + public function makeFilamentTranslatableContentDriver(): ?TranslatableContentDriver + { + return null; + } + + protected function getRecords(): array + { + $collection = collect($this->accounts)->map(function ($account) { + $account['is_selected'] = in_array($account['user'] ?? '', $this->selectedAccounts); + + return $account; + }); + + // Get sort column and direction from table state + $sortColumn = $this->getTableSortColumn(); + $sortDirection = $this->getTableSortDirection() ?? 'asc'; + + if ($sortColumn) { + $collection = $sortDirection === 'desc' + ? $collection->sortByDesc($sortColumn) + : $collection->sortBy($sortColumn); + } + + return $collection->values()->all(); + } + + public function table(Table $table): Table + { + return $table + ->records(fn () => $this->getRecords()) + ->columns([ + IconColumn::make('is_selected') + ->label('') + ->boolean() + ->trueIcon('heroicon-s-check-circle') + ->falseIcon('heroicon-o-minus-circle') + ->trueColor('primary') + ->falseColor('gray') + ->size(IconSize::Medium), + TextColumn::make('user') + ->label(__('Username')) + ->weight('bold') + ->sortable(query: fn ($query, string $direction) => $query), + TextColumn::make('domain') + ->label(__('Domain')) + ->wrap() + ->sortable(query: fn ($query, string $direction) => $query), + TextColumn::make('diskused') + ->label(__('Disk')) + ->toggleable() + ->sortable(query: fn ($query, string $direction) => $query), + TextColumn::make('plan') + ->label(__('Plan')) + ->toggleable() + ->limit(20) + ->sortable(query: fn ($query, string $direction) => $query), + IconColumn::make('suspended') + ->label(__('Status')) + ->boolean() + ->trueIcon('heroicon-o-exclamation-triangle') + ->falseIcon('heroicon-o-check-circle') + ->trueColor('warning') + ->falseColor('success') + ->getStateUsing(fn (array $record): bool => $record['suspended'] ?? false), + ]) + ->defaultSort('user') + ->recordAction('toggleSelection') + ->actions([ + Action::make('toggleSelection') + ->label(fn (array $record): string => in_array($record['user'] ?? '', $this->selectedAccounts) ? __('Deselect') : __('Select')) + ->icon(fn (array $record): string => in_array($record['user'] ?? '', $this->selectedAccounts) ? 'heroicon-o-x-mark' : 'heroicon-o-check') + ->color(fn (array $record): string => in_array($record['user'] ?? '', $this->selectedAccounts) ? 'gray' : 'primary') + ->action(function (array $record): void { + $user = $record['user'] ?? ''; + if (in_array($user, $this->selectedAccounts)) { + $this->selectedAccounts = array_values(array_diff($this->selectedAccounts, [$user])); + } else { + $this->selectedAccounts[] = $user; + } + // Update session + session()->put('whm_migration.selectedAccounts', $this->selectedAccounts); + $this->dispatch('whm-selection-updated', selectedAccounts: $this->selectedAccounts); + $this->resetTable(); + }), + ]) + ->striped() + ->paginated([10, 25, 50]) + ->defaultPaginationPageOption(25) + ->emptyStateHeading(__('No accounts found')) + ->emptyStateDescription(__('Connect to a WHM server to see available accounts')) + ->emptyStateIcon('heroicon-o-user-group') + ->poll(null); + } + + public function render() + { + return $this->getTable()->render(); + } +} diff --git a/app/Filament/Admin/Widgets/WhmMigrationStatusTable.php b/app/Filament/Admin/Widgets/WhmMigrationStatusTable.php new file mode 100644 index 0000000..60b1b91 --- /dev/null +++ b/app/Filament/Admin/Widgets/WhmMigrationStatusTable.php @@ -0,0 +1,182 @@ +selectedAccounts = ! empty($selectedAccounts) ? $selectedAccounts : session('whm_migration.selectedAccounts', []); + $this->migrationStatus = ! empty($migrationStatus) ? $migrationStatus : session('whm_migration.migrationStatus', []); + $this->isMigrating = $isMigrating; + $this->migrationCacheKey = $migrationCacheKey; + $this->loadStateFromCache(); + } + + #[On('whm-migration-status-updated')] + public function refreshStatus(): void + { + $this->loadStateFromCache(); + $this->resetTable(); + } + + public function makeFilamentTranslatableContentDriver(): ?TranslatableContentDriver + { + return null; + } + + protected function getRecords(): array + { + $this->loadStateFromCache(); + + $records = []; + + foreach ($this->selectedAccounts as $user) { + $status = $this->migrationStatus[$user] ?? ['status' => 'pending', 'log' => []]; + $lastLog = end($status['log']) ?: null; + + $records[] = [ + 'user' => $user, + 'status' => $status['status'] ?? 'pending', + 'status_text' => $this->getStatusText($status['status'] ?? 'pending'), + 'message' => $lastLog ? $lastLog['message'] : $this->getStatusText($status['status'] ?? 'pending'), + 'progress' => $status['progress'] ?? 0, + 'log_count' => count($status['log'] ?? []), + ]; + } + + return $records; + } + + protected function getStatusText(string $status): string + { + return match ($status) { + 'pending' => __('Waiting...'), + 'processing' => __('Processing...'), + 'backup_creating' => __('Creating backup...'), + 'backup_downloading' => __('Downloading backup...'), + 'restoring' => __('Restoring...'), + 'completed' => __('Completed'), + 'error' => __('Error'), + default => __('Unknown'), + }; + } + + public function table(Table $table): Table + { + return $table + ->records(fn () => $this->getRecords()) + ->columns([ + IconColumn::make('status') + ->label('') + ->icon(fn (array $record): string => match ($record['status']) { + 'pending' => 'heroicon-o-clock', + 'processing', 'backup_creating', 'backup_downloading', 'restoring' => 'heroicon-o-arrow-path', + 'completed' => 'heroicon-o-check-circle', + 'error' => 'heroicon-o-x-circle', + default => 'heroicon-o-question-mark-circle', + }) + ->color(fn (array $record): string => match ($record['status']) { + 'pending' => 'gray', + 'processing', 'backup_creating', 'backup_downloading', 'restoring' => 'warning', + 'completed' => 'success', + 'error' => 'danger', + default => 'gray', + }) + ->size(IconSize::Small) + ->extraAttributes(fn (array $record): array => in_array($record['status'], ['processing', 'backup_creating', 'backup_downloading', 'restoring']) + ? ['class' => 'animate-spin'] + : []), + TextColumn::make('user') + ->label(__('Account')) + ->weight(FontWeight::Bold) + ->searchable(), + TextColumn::make('status_text') + ->label(__('Status')) + ->badge() + ->color(fn (array $record): string => match ($record['status']) { + 'pending' => 'gray', + 'processing', 'backup_creating', 'backup_downloading', 'restoring' => 'warning', + 'completed' => 'success', + 'error' => 'danger', + default => 'gray', + }), + TextColumn::make('message') + ->label(__('Current Action')) + ->wrap() + ->limit(50), + ]) + ->striped() + ->paginated(false) + ->poll($this->shouldPoll() ? '3s' : null) + ->emptyStateHeading(__('No accounts in migration')) + ->emptyStateDescription(__('Select accounts and start migration')) + ->emptyStateIcon('heroicon-o-queue-list'); + } + + protected function shouldPoll(): bool + { + if ($this->isMigrating) { + return true; + } + + foreach ($this->migrationStatus as $status) { + if (! in_array($status['status'] ?? null, ['completed', 'error'], true)) { + return true; + } + } + + return false; + } + + protected function loadStateFromCache(): void + { + if (! $this->migrationCacheKey) { + return; + } + + $state = Cache::get($this->migrationCacheKey); + if (! is_array($state)) { + return; + } + + $this->selectedAccounts = $state['selectedAccounts'] ?? $this->selectedAccounts; + $this->migrationStatus = $state['migrationStatus'] ?? $this->migrationStatus; + $this->isMigrating = (bool) ($state['isMigrating'] ?? $this->isMigrating); + } + + public function render() + { + return $this->getTable()->render(); + } +} diff --git a/app/Filament/AvatarProviders/InitialsAvatarProvider.php b/app/Filament/AvatarProviders/InitialsAvatarProvider.php new file mode 100644 index 0000000..88a53f5 --- /dev/null +++ b/app/Filament/AvatarProviders/InitialsAvatarProvider.php @@ -0,0 +1,54 @@ +name ?? $record->email ?? 'U'; + $initials = $this->getInitials($name); + + // Generate a consistent color based on the name + $hash = crc32($name); + $hue = $hash % 360; + + // Generate SVG avatar + $svg = $this->generateSvg($initials, $hue); + + return 'data:image/svg+xml;base64,' . base64_encode($svg); + } + + private function getInitials(string $name): string + { + $words = preg_split('/[\s@._-]+/', trim($name)); + $initials = ''; + + foreach ($words as $word) { + if (!empty($word)) { + $initials .= mb_strtoupper(mb_substr($word, 0, 1)); + if (mb_strlen($initials) >= 2) { + break; + } + } + } + + return $initials ?: 'U'; + } + + private function generateSvg(string $initials, int $hue): string + { + $bgColor = "hsl({$hue}, 50%, 50%)"; + + return << + + {$initials} + +SVG; + } +} diff --git a/app/Filament/Concerns/HasPageTour.php b/app/Filament/Concerns/HasPageTour.php new file mode 100644 index 0000000..f80f936 --- /dev/null +++ b/app/Filament/Concerns/HasPageTour.php @@ -0,0 +1,19 @@ +label(__('Take Tour')) + ->icon('heroicon-o-academic-cap') + ->color('gray') + ->action(function (): void { + $this->dispatch('start-page-tour'); + }); + } +} diff --git a/app/Filament/Jabali/Pages/Auth/Login.php b/app/Filament/Jabali/Pages/Auth/Login.php new file mode 100644 index 0000000..bffeb46 --- /dev/null +++ b/app/Filament/Jabali/Pages/Auth/Login.php @@ -0,0 +1,51 @@ +form->getState(); + + // Check credentials without logging in + $user = User::where('email', $data['email'])->first(); + + if ($user && \Hash::check($data['password'], $user->password)) { + // Check if 2FA is enabled + if ($user->two_factor_secret && $user->two_factor_confirmed_at) { + // Store user ID in session for 2FA challenge + session(['login.id' => $user->id]); + session(['login.remember' => $data['remember'] ?? false]); + + // Redirect to 2FA challenge + $this->redirect(route('filament.jabali.auth.two-factor-challenge')); + return null; + } + } + + $response = parent::authenticate(); + + // If authentication successful, check if user is admin + $user = Filament::auth()->user(); + if ($user && $user->is_admin) { + // Log out from user panel guard + Filament::auth()->logout(); + + // Redirect admins to admin panel using Livewire's redirect + $this->redirect(route('filament.admin.pages.dashboard')); + + return null; + } + + return $response; + } +} diff --git a/app/Filament/Jabali/Pages/Auth/TwoFactorChallenge.php b/app/Filament/Jabali/Pages/Auth/TwoFactorChallenge.php new file mode 100644 index 0000000..86cf465 --- /dev/null +++ b/app/Filament/Jabali/Pages/Auth/TwoFactorChallenge.php @@ -0,0 +1,173 @@ +redirect(Filament::getLoginUrl()); + + return; + } + + $this->form->fill(); + } + + public function getTitle(): string|Htmlable + { + return __('Two-Factor Authentication'); + } + + public function getHeading(): string|Htmlable + { + return __('Two-Factor Authentication'); + } + + public function getSubheading(): string|Htmlable|null + { + return $this->useRecoveryCode + ? __('Please enter one of your emergency recovery codes.') + : __('Please enter the authentication code from your app.'); + } + + public function form(Schema $schema): Schema + { + return $schema + ->schema([ + TextInput::make('code') + ->label($this->useRecoveryCode ? __('Recovery Code') : __('Authentication Code')) + ->placeholder($this->useRecoveryCode ? __('Enter recovery code') : '000000') + ->required() + ->autocomplete('one-time-code') + ->autofocus() + ->extraInputAttributes([ + 'inputmode' => $this->useRecoveryCode ? 'text' : 'numeric', + 'pattern' => $this->useRecoveryCode ? null : '[0-9]*', + 'maxlength' => $this->useRecoveryCode ? 21 : 6, + ]), + ]) + ->statePath('data'); + } + + public function authenticate(): void + { + $data = $this->form->getState(); + $code = $data['code']; + + $user = $this->getChallengedUser(); + + if (! $user) { + $this->redirect(Filament::getLoginUrl()); + + return; + } + + $valid = $this->useRecoveryCode + ? $this->validateRecoveryCode($user, $code) + : $this->validateAuthenticationCode($user, $code); + + if (! $valid) { + Notification::make() + ->title(__('Invalid Code')) + ->body($this->useRecoveryCode + ? __('The recovery code is invalid.') + : __('The authentication code is invalid.')) + ->danger() + ->send(); + + $this->form->fill(); + + return; + } + + // Clear the challenge session + session()->forget('login.id'); + session()->forget('login.remember'); + + // Login the user + Auth::guard(Filament::getAuthGuard())->login($user, session('login.remember', false)); + + session()->regenerate(); + + $this->redirect(Filament::getUrl()); + } + + protected function getChallengedUser() + { + $userId = session('login.id'); + + if (! $userId) { + return null; + } + + return \App\Models\User::find($userId); + } + + protected function validateAuthenticationCode($user, string $code): bool + { + return app(TwoFactorAuthenticationProvider::class)->verify( + decrypt($user->two_factor_secret), + $code + ); + } + + protected function validateRecoveryCode($user, string $code): bool + { + $codes = json_decode(decrypt($user->two_factor_recovery_codes), true); + + $code = str_replace('-', '', trim($code)); + + foreach ($codes as $index => $storedCode) { + $storedCode = str_replace('-', '', $storedCode); + if (hash_equals($storedCode, $code)) { + // Remove the used code + unset($codes[$index]); + $user->forceFill([ + 'two_factor_recovery_codes' => encrypt(json_encode(array_values($codes))), + ])->save(); + + event(new RecoveryCodeReplaced($user, $code)); + + return true; + } + } + + return false; + } + + public function toggleRecoveryCode(): void + { + $this->useRecoveryCode = ! $this->useRecoveryCode; + $this->form->fill(); + } + + protected function hasFullWidthFormActions(): bool + { + return true; + } +} diff --git a/app/Filament/Jabali/Pages/Backups.php b/app/Filament/Jabali/Pages/Backups.php new file mode 100644 index 0000000..ab54e53 --- /dev/null +++ b/app/Filament/Jabali/Pages/Backups.php @@ -0,0 +1,1241 @@ +agent ??= new AgentClient; + } + + protected function getUser() + { + return Auth::user(); + } + + public function mount(): void + { + $this->activeTab = $this->normalizeTabName($this->activeTab); + } + + protected function normalizeTabName(?string $tab): string + { + return match ($tab) { + 'local', 'remote', 'destinations', 'history' => $tab, + default => 'local', + }; + } + + public function setTab(string $tab): void + { + $this->activeTab = $this->normalizeTabName($tab); + $this->resetTable(); + } + + protected function getForms(): array + { + return ['backupsForm']; + } + + public function backupsForm(Schema $schema): Schema + { + return $schema->schema([ + Section::make(__('Backup Management')) + ->description(__('Create and manage backups of your account data. Backups include your websites, databases, and mailboxes. You can restore from any backup at any time.')) + ->icon('heroicon-o-information-circle') + ->iconColor('info'), + View::make('filament.jabali.components.backup-tabs-nav'), + ]); + } + + public function table(Table $table): Table + { + return match ($this->activeTab) { + 'local' => $this->localBackupsTable($table), + 'remote' => $this->remoteBackupsTable($table), + 'destinations' => $this->destinationsTable($table), + 'history' => $this->restoreHistoryTable($table), + default => $this->localBackupsTable($table), + }; + } + + protected function localBackupsTable(Table $table): Table + { + return $table + ->query(Backup::query()->where('user_id', $this->getUser()->id)) + ->columns([ + TextColumn::make('name') + ->label(__('Name')) + ->searchable() + ->sortable() + ->description(fn (Backup $record) => $record->created_at->format('M j, Y H:i')), + TextColumn::make('status') + ->label(__('Status')) + ->badge() + ->color(fn (string $state): string => match ($state) { + 'completed' => 'success', + 'running' => 'warning', + 'pending' => 'gray', + 'failed' => 'danger', + default => 'gray', + }) + ->formatStateUsing(fn (string $state): string => ucfirst($state)), + TextColumn::make('size_human') + ->label(__('Size')) + ->sortable(query: fn (Builder $query, string $direction) => $query->orderBy('size_bytes', $direction)), + IconColumn::make('include_files') + ->label(__('Files')) + ->boolean() + ->trueIcon('heroicon-o-check-circle') + ->falseIcon('heroicon-o-x-circle') + ->trueColor('success') + ->falseColor('gray'), + IconColumn::make('include_databases') + ->label(__('DB')) + ->boolean() + ->trueIcon('heroicon-o-check-circle') + ->falseIcon('heroicon-o-x-circle') + ->trueColor('success') + ->falseColor('gray'), + IconColumn::make('include_mailboxes') + ->label(__('Mail')) + ->boolean() + ->trueIcon('heroicon-o-check-circle') + ->falseIcon('heroicon-o-x-circle') + ->trueColor('success') + ->falseColor('gray'), + TextColumn::make('created_at') + ->label(__('Created')) + ->dateTime('M j, Y H:i') + ->sortable(), + ]) + ->defaultSort('created_at', 'desc') + ->recordActions([ + Action::make('download') + ->label(__('Download')) + ->icon('heroicon-o-arrow-down-tray') + ->color('gray') + ->size('sm') + ->url(fn (Backup $record) => route('filament.jabali.pages.backup-download', ['path' => base64_encode($record->local_path ?? '')])) + ->openUrlInNewTab() + ->visible(fn (Backup $record) => $record->canDownload()), + Action::make('restore') + ->label(__('Restore')) + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->size('sm') + ->modalHeading(__('Restore Backup')) + ->modalDescription(__('Select what you want to restore from this backup. Existing data may be overwritten.')) + ->modalIcon('heroicon-o-arrow-path') + ->modalIconColor('warning') + ->modalSubmitActionLabel(__('Start Restore')) + ->form([ + Section::make(__('Restore Options')) + ->description(__('Choose which types of data to restore')) + ->schema([ + Grid::make(2)->schema([ + Toggle::make('restore_files') + ->label(__('Restore Website Files')) + ->default(true) + ->helperText(__('Restores all website files to their original locations')), + Toggle::make('restore_databases') + ->label(__('Restore Databases')) + ->default(true) + ->helperText(__('Restores MySQL databases (overwrites existing data)')), + Toggle::make('restore_mailboxes') + ->label(__('Restore Mailboxes')) + ->default(true) + ->helperText(__('Restores email accounts and messages')), + ]), + ]), + ]) + ->action(function (array $data, Backup $record): void { + $this->selectedBackupId = $record->id; + $this->performRestore($data); + }) + ->visible(fn (Backup $record) => $record->status === 'completed'), + Action::make('delete') + ->label(__('Delete')) + ->icon('heroicon-o-trash') + ->color('danger') + ->size('sm') + ->requiresConfirmation() + ->modalHeading(__('Delete Backup')) + ->modalDescription(__('Are you sure you want to delete this backup? This action cannot be undone.')) + ->action(function (Backup $record): void { + $user = $this->getUser(); + if ($record->local_path) { + try { + $this->getAgent()->backupDelete($user->username, $record->local_path); + } catch (Exception) { + // Continue anyway + } + } + $record->delete(); + Notification::make()->title(__('Backup deleted'))->success()->send(); + }), + ]) + ->emptyStateHeading(__('No backups yet')) + ->emptyStateDescription(__('Click "Create Backup" to create your first backup of your account data.')) + ->emptyStateIcon('heroicon-o-archive-box') + ->striped(); + } + + protected function remoteBackupsTable(Table $table): Table + { + return $table + ->query( + UserRemoteBackup::query() + ->where('user_id', $this->getUser()->id) + ->with('destination') + ->orderByDesc('backup_date') + ) + ->columns([ + TextColumn::make('backup_name') + ->label(__('Backup')) + ->icon('heroicon-o-cloud') + ->iconColor('info') + ->description(fn (UserRemoteBackup $record): string => $record->backup_date?->format('M j, Y H:i') ?? '') + ->searchable(), + TextColumn::make('backup_type') + ->label(__('Type')) + ->badge() + ->formatStateUsing(fn (string $state): string => $state === 'incremental' ? __('Incremental') : __('Full')) + ->color(fn (string $state): string => $state === 'incremental' ? 'info' : 'success'), + TextColumn::make('destination.name') + ->label(__('Destination')), + ]) + ->recordActions([ + Action::make('restore') + ->label(__('Restore')) + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->size('sm') + ->modalHeading(__('Restore from Server Backup')) + ->modalDescription(__('This will download and restore the backup. Select what you want to restore.')) + ->form([ + Section::make(__('Restore Options')) + ->schema([ + Grid::make(2)->schema([ + Toggle::make('restore_files')->label(__('Website Files'))->default(true), + Toggle::make('restore_databases')->label(__('Databases'))->default(true), + Toggle::make('restore_mailboxes')->label(__('Mailboxes'))->default(true), + ]), + ]), + ]) + ->action(fn (array $data, UserRemoteBackup $record) => $this->restoreFromRemoteBackup($record, $data)), + Action::make('download') + ->label(__('Download')) + ->icon('heroicon-o-arrow-down-tray') + ->color('gray') + ->size('sm') + ->action(fn (UserRemoteBackup $record) => $this->downloadFromRemote($record->destination_id, $record->backup_path)), + ]) + ->headerActions([ + Action::make('refresh') + ->label(__('Refresh')) + ->icon('heroicon-o-arrow-path') + ->color('gray') + ->action(function () { + // Dispatch re-indexing job + \App\Jobs\IndexRemoteBackups::dispatch(); + Notification::make() + ->title(__('Refreshing backup list')) + ->body(__('The backup list will be updated in a moment.')) + ->info() + ->send(); + }), + ]) + ->emptyStateHeading(__('No server backups found')) + ->emptyStateDescription(__('Your backups from scheduled server backups will appear here when available.')) + ->emptyStateIcon('heroicon-o-cloud') + ->striped(); + } + + protected function restoreHistoryTable(Table $table): Table + { + return $table + ->query(BackupRestore::query()->where('user_id', $this->getUser()->id)->with('backup')) + ->columns([ + TextColumn::make('backup.name') + ->label(__('Backup')) + ->placeholder(__('Unknown')) + ->searchable(), + TextColumn::make('status') + ->label(__('Status')) + ->badge() + ->formatStateUsing(fn (BackupRestore $record): string => $record->status_label) + ->color(fn (BackupRestore $record): string => $record->status_color), + TextColumn::make('progress') + ->label(__('Progress')) + ->formatStateUsing(fn (int $state): string => $state.'%') + ->color(fn (BackupRestore $record): string => $record->status === 'running' ? 'warning' : 'gray'), + TextColumn::make('created_at') + ->label(__('Date')) + ->dateTime('M j, Y H:i') + ->sortable(), + TextColumn::make('duration') + ->label(__('Duration')) + ->placeholder('-'), + ]) + ->defaultSort('created_at', 'desc') + ->emptyStateHeading(__('No restore history')) + ->emptyStateDescription(__('Your backup restore operations will appear here.')) + ->emptyStateIcon('heroicon-o-arrow-path') + ->striped(); + } + + protected function destinationsTable(Table $table): Table + { + return $table + ->query(BackupDestination::query()->where('user_id', $this->getUser()->id)->where('is_server_backup', false)) + ->columns([ + TextColumn::make('name') + ->label(__('Name')) + ->weight('medium') + ->searchable(), + TextColumn::make('type') + ->label(__('Type')) + ->badge() + ->formatStateUsing(fn (string $state): string => strtoupper($state)) + ->color('info'), + TextColumn::make('test_status') + ->label(__('Status')) + ->badge() + ->formatStateUsing(fn (?string $state): string => match ($state) { + 'success' => __('Connected'), + 'failed' => __('Failed'), + default => __('Not Tested'), + }) + ->color(fn (?string $state): string => match ($state) { + 'success' => 'success', + 'failed' => 'danger', + default => 'gray', + }), + TextColumn::make('last_tested_at') + ->label(__('Last Tested')) + ->since() + ->placeholder(__('Never')), + ]) + ->recordActions([ + Action::make('test') + ->label(__('Test')) + ->icon('heroicon-o-check-circle') + ->color('success') + ->size('sm') + ->action(fn (BackupDestination $record) => $this->testUserDestination($record->id)), + Action::make('delete') + ->label(__('Delete')) + ->icon('heroicon-o-trash') + ->color('danger') + ->size('sm') + ->requiresConfirmation() + ->action(fn (BackupDestination $record) => $this->deleteUserDestination($record->id)), + ]) + ->headerActions([ + $this->addDestinationAction(), + ]) + ->emptyStateHeading(__('No SFTP destinations configured')) + ->emptyStateDescription(__('Add an SFTP server to upload your backups to remote storage.')) + ->emptyStateIcon('heroicon-o-server-stack') + ->striped(); + } + + public function restoreFromRemoteBackup(UserRemoteBackup $record, array $data): void + { + $user = $this->getUser(); + $destination = $record->destination; + + if (! $destination) { + Notification::make()->title(__('Destination not found'))->danger()->send(); + + return; + } + + // Create temp directory for download + $tempPath = sys_get_temp_dir().'/jabali_restore_'.uniqid(); + mkdir($tempPath, 0755, true); + + Notification::make() + ->title(__('Downloading backup')) + ->body(__('Please wait while the backup is downloaded...')) + ->info() + ->send(); + + try { + $config = array_merge($destination->config ?? [], ['type' => $destination->type]); + + $downloadResult = $this->getAgent()->send('backup.download_remote', [ + 'remote_path' => $record->backup_path, + 'local_path' => $tempPath, + 'destination' => $config, + ]); + + if (! ($downloadResult['success'] ?? false)) { + throw new Exception($downloadResult['error'] ?? __('Download failed')); + } + + // Now restore from the downloaded backup + $restoreResult = $this->getAgent()->send('backup.restore', [ + 'username' => $user->username, + 'backup_path' => $tempPath, + 'restore_files' => $data['restore_files'] ?? false, + 'restore_databases' => $data['restore_databases'] ?? false, + 'restore_mailboxes' => $data['restore_mailboxes'] ?? false, + ]); + + // Cleanup temp directory + exec('rm -rf '.escapeshellarg($tempPath)); + + if ($restoreResult['success'] ?? false) { + $restoredItems = []; + + // Create database records for restored mailboxes + $restoredMailboxes = $restoreResult['restored']['mailboxes'] ?? []; + if (! empty($restoredMailboxes)) { + foreach ($restoredMailboxes as $mailboxEmail) { + $this->createMailboxRecord($user, $mailboxEmail); + } + $restoredItems[] = count($restoredMailboxes).' '.__('mailbox(es)'); + } + + // Count other restored items + if (! empty($restoreResult['restored']['files'] ?? [])) { + $restoredItems[] = count($restoreResult['restored']['files']).' '.__('domain(s)'); + } + if (! empty($restoreResult['restored']['databases'] ?? [])) { + $restoredItems[] = count($restoreResult['restored']['databases']).' '.__('database(s)'); + } + + $message = ! empty($restoredItems) ? implode(', ', $restoredItems) : __('No items needed restoring'); + + Notification::make() + ->title(__('Restore completed')) + ->body($message) + ->success() + ->send(); + } else { + throw new Exception($restoreResult['error'] ?? __('Restore failed')); + } + } catch (Exception $e) { + // Cleanup on error + if (is_dir($tempPath)) { + exec('rm -rf '.escapeshellarg($tempPath)); + } + Notification::make() + ->title(__('Restore failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + /** + * Create database record for a restored mailbox. + * Files are restored by the agent, this creates the DB entry so the mailbox appears in the panel. + */ + protected function createMailboxRecord($user, string $mailboxEmail): void + { + // Parse email + $parts = explode('@', $mailboxEmail); + if (count($parts) !== 2) { + return; + } + + $localPart = $parts[0]; + $domainName = $parts[1]; + + // Check if mailbox already exists + $existingMailbox = Mailbox::whereHas('emailDomain.domain', function ($query) use ($domainName) { + $query->where('domain', $domainName); + })->where('local_part', $localPart)->first(); + + if ($existingMailbox) { + return; // Already exists + } + + // Find the domain + $domain = Domain::where('domain', $domainName) + ->where('user_id', $user->id) + ->first(); + + if (! $domain) { + return; // Domain not found for this user + } + + // Find or create email domain + $emailDomain = EmailDomain::firstOrCreate( + ['domain_id' => $domain->id], + [ + 'is_active' => true, + 'max_mailboxes' => 10, + 'max_quota_bytes' => 10737418240, // 10GB + ] + ); + + // Generate a temporary password + $tempPassword = Str::random(16); + + // Get password hash from agent + try { + $result = $this->getAgent()->send('email.hash_password', ['password' => $tempPassword]); + $passwordHash = $result['password_hash'] ?? ''; + } catch (\Exception $e) { + // Fallback: generate SHA512-CRYPT hash in PHP + $passwordHash = '{SHA512-CRYPT}'.crypt($tempPassword, '$6$'.bin2hex(random_bytes(8)).'$'); + } + + // Get system user UID/GID + $userInfo = posix_getpwnam($user->username); + $systemUid = $userInfo['uid'] ?? null; + $systemGid = $userInfo['gid'] ?? null; + + // The maildir path in user's home directory + $maildirPath = "/home/{$user->username}/mail/{$domainName}/{$localPart}/"; + + // Create the mailbox record + Mailbox::create([ + 'email_domain_id' => $emailDomain->id, + 'user_id' => $user->id, + 'local_part' => $localPart, + 'password_hash' => $passwordHash, + 'password_encrypted' => Crypt::encryptString($tempPassword), + 'maildir_path' => $maildirPath, + 'system_uid' => $systemUid, + 'system_gid' => $systemGid, + 'name' => $localPart, + 'quota_bytes' => 1073741824, // 1GB default + 'is_active' => true, + 'imap_enabled' => true, + 'pop3_enabled' => true, + 'smtp_enabled' => true, + ]); + } + + protected function getHeaderActions(): array + { + return [ + $this->getTourAction(), + Action::make('createBackup') + ->label(__('Create Backup')) + ->icon('heroicon-o-archive-box-arrow-down') + ->color('primary') + ->modalHeading(__('Create Backup')) + ->modalDescription(__('Create a backup of your account data including websites, databases, and mailboxes.')) + ->modalIcon('heroicon-o-archive-box-arrow-down') + ->modalIconColor('primary') + ->modalSubmitActionLabel(__('Create Backup')) + ->form([ + TextInput::make('name') + ->label(__('Backup Name')) + ->default(fn () => __('Backup').' '.now()->format('Y-m-d H:i')) + ->required() + ->helperText(__('A descriptive name to identify this backup')), + Select::make('destination_id') + ->label(__('Save To')) + ->options(fn () => BackupDestination::where('user_id', Auth::id()) + ->where('is_active', true) + ->pluck('name', 'id') + ->prepend(__('Local (Home Folder)'), '')) + ->default('') + ->helperText(__('Choose where to store your backup')), + Section::make(__('What to Include')) + ->description(__('Select the data you want to include in this backup')) + ->schema([ + Grid::make(2)->schema([ + Toggle::make('include_files') + ->label(__('Website Files')) + ->default(true) + ->helperText(__('All files in your domains folder')), + Toggle::make('include_databases') + ->label(__('Databases')) + ->default(true) + ->helperText(__('All MySQL databases and data')), + Toggle::make('include_mailboxes') + ->label(__('Mailboxes')) + ->default(true) + ->helperText(__('All email accounts and messages')), + Toggle::make('include_ssl') + ->label(__('SSL Certificates')) + ->default(true) + ->helperText(__('SSL certificates for your domains')), + ]), + ]), + ]) + ->action(function (array $data) { + $this->createBackup($data); + }), + ]; + } + + public function createBackup(array $data): void + { + $user = $this->getUser(); + $timestamp = now()->format('Y-m-d_His'); + $filename = "backup_{$timestamp}.tar.gz"; + $outputPath = "/home/{$user->username}/backups/{$filename}"; + $destinationId = ! empty($data['destination_id']) ? (int) $data['destination_id'] : null; + + $backup = Backup::create([ + 'user_id' => $user->id, + 'name' => $data['name'], + 'filename' => $filename, + 'type' => 'full', + 'include_files' => $data['include_files'] ?? true, + 'include_databases' => $data['include_databases'] ?? true, + 'include_mailboxes' => $data['include_mailboxes'] ?? true, + 'destination_id' => $destinationId, + 'status' => 'pending', + 'local_path' => $outputPath, + ]); + + try { + $backup->update(['status' => 'running', 'started_at' => now()]); + + $result = $this->getAgent()->backupCreate($user->username, $outputPath, [ + 'include_files' => $data['include_files'] ?? true, + 'include_databases' => $data['include_databases'] ?? true, + 'include_mailboxes' => $data['include_mailboxes'] ?? true, + 'include_ssl' => $data['include_ssl'] ?? true, + ]); + + if ($result['success']) { + $backup->update([ + 'status' => 'completed', + 'completed_at' => now(), + 'size_bytes' => $result['size'] ?? 0, + 'checksum' => $result['checksum'] ?? null, + 'domains' => $result['domains'] ?? null, + 'databases' => $result['databases'] ?? null, + 'mailboxes' => $result['mailboxes'] ?? null, + ]); + + // Upload to SFTP if destination selected + if ($destinationId) { + $this->uploadBackupToDestination($backup, $destinationId); + } else { + Notification::make()->title(__('Backup created successfully'))->success()->send(); + } + } else { + throw new Exception($result['error'] ?? 'Backup failed'); + } + } catch (Exception $e) { + $backup->update([ + 'status' => 'failed', + 'completed_at' => now(), + 'error_message' => $e->getMessage(), + ]); + Notification::make()->title(__('Backup failed'))->body($e->getMessage())->danger()->send(); + } + + $this->resetTable(); + } + + protected function uploadBackupToDestination(Backup $backup, int $destinationId): void + { + $user = $this->getUser(); + $destination = BackupDestination::where('id', $destinationId) + ->where('user_id', $user->id) + ->first(); + + if (! $destination) { + Notification::make() + ->title(__('Backup created locally')) + ->body(__('Could not upload to destination - not found')) + ->warning() + ->send(); + + return; + } + + try { + $backup->update(['status' => 'uploading']); + + $config = array_merge($destination->config ?? [], ['type' => $destination->type]); + + $result = $this->getAgent()->send('backup.upload_remote', [ + 'local_path' => $backup->local_path, + 'destination' => $config, + 'backup_type' => 'full', + ]); + + if ($result['success'] ?? false) { + $backup->update([ + 'status' => 'completed', + 'remote_path' => $result['remote_path'] ?? null, + ]); + Notification::make() + ->title(__('Backup created and uploaded')) + ->body(__('Backup saved to :destination', ['destination' => $destination->name])) + ->success() + ->send(); + } else { + throw new Exception($result['error'] ?? __('Upload failed')); + } + } catch (Exception $e) { + $backup->update([ + 'status' => 'completed', + 'error_message' => __('Local backup created, but upload failed: ').$e->getMessage(), + ]); + Notification::make() + ->title(__('Backup created locally')) + ->body(__('Upload to :destination failed: :error', [ + 'destination' => $destination->name, + 'error' => $e->getMessage(), + ])) + ->warning() + ->send(); + } + } + + public ?int $selectedBackupIdForDelete = null; + + public ?string $selectedPathForDelete = null; + + public function confirmDeleteBackup(int $id): void + { + $this->selectedBackupIdForDelete = $id; + $this->mountAction('deleteBackupAction'); + } + + public function deleteBackupAction(): Action + { + return Action::make('deleteBackupAction') + ->requiresConfirmation() + ->modalHeading(__('Delete Backup')) + ->modalDescription(__('Are you sure you want to delete this backup? This action cannot be undone.')) + ->modalIcon('heroicon-o-trash') + ->modalIconColor('danger') + ->modalSubmitActionLabel(__('Delete Backup')) + ->color('danger') + ->action(function (): void { + $user = $this->getUser(); + $backup = Backup::where('id', $this->selectedBackupIdForDelete)->where('user_id', $user->id)->first(); + + if (! $backup) { + Notification::make()->title(__('Backup not found'))->danger()->send(); + + return; + } + + // Delete the file + if ($backup->local_path) { + try { + $this->getAgent()->backupDelete($user->username, $backup->local_path); + } catch (Exception $e) { + // Continue anyway + } + } + + $backup->delete(); + Notification::make()->title(__('Backup deleted'))->success()->send(); + $this->resetTable(); + }); + } + + public function confirmDeleteLocalFile(string $path): void + { + $this->selectedPathForDelete = $path; + $this->mountAction('deleteLocalFileAction'); + } + + public function deleteLocalFileAction(): Action + { + return Action::make('deleteLocalFileAction') + ->requiresConfirmation() + ->modalHeading(__('Delete Backup File')) + ->modalDescription(__('Are you sure you want to delete this backup file? This action cannot be undone.')) + ->modalIcon('heroicon-o-trash') + ->modalIconColor('danger') + ->modalSubmitActionLabel(__('Delete')) + ->color('danger') + ->action(function (): void { + $user = $this->getUser(); + + try { + $result = $this->getAgent()->backupDelete($user->username, $this->selectedPathForDelete); + if ($result['success']) { + Notification::make()->title(__('Backup deleted'))->success()->send(); + } else { + throw new Exception($result['error'] ?? 'Delete failed'); + } + } catch (Exception $e) { + Notification::make()->title(__('Delete failed'))->body($e->getMessage())->danger()->send(); + } + + $this->resetTable(); + }); + } + + public function downloadFromRemote(int $destinationId, string $remotePath): void + { + $user = $this->getUser(); + $destination = BackupDestination::find($destinationId); + + if (! $destination) { + Notification::make()->title(__('Destination not found'))->danger()->send(); + + return; + } + + // Create a timestamped tar.gz filename + // remotePath is like "2026-01-20_143000/user", extract the date part + $pathParts = explode('/', trim($remotePath, '/')); + $backupDate = $pathParts[0] ?? now()->format('Y-m-d_His'); + $timestamp = now()->format('Y-m-d_His'); + $filename = "backup_{$timestamp}.tar.gz"; + $localPath = "/home/{$user->username}/backups/{$filename}"; + + try { + Notification::make() + ->title(__('Downloading backup...')) + ->body(__('This may take a few minutes. Please wait.')) + ->info() + ->send(); + + $config = array_merge($destination->config ?? [], ['type' => $destination->type]); + + // Use the new agent action that creates a tar.gz archive + $result = $this->getAgent()->send('backup.download_user_archive', [ + 'username' => $user->username, + 'remote_path' => $remotePath, + 'destination' => $config, + 'output_path' => $localPath, + ]); + + if ($result['success'] ?? false) { + // Create backup record + $backup = Backup::create([ + 'user_id' => $user->id, + 'name' => "Server Backup ({$backupDate})", + 'filename' => $filename, + 'type' => 'full', + 'status' => 'completed', + 'local_path' => $localPath, + 'size_bytes' => $result['size'] ?? 0, + 'completed_at' => now(), + ]); + + // Format size for display + $sizeFormatted = $this->formatBytes($result['size'] ?? 0); + + // Create download URL + $downloadUrl = url('/jabali-panel/backup-download?path='.base64_encode($localPath)); + + Notification::make() + ->title(__('Backup Ready')) + ->body(__('Your backup (:size) can be downloaded from My Backups or from your backups folder.', ['size' => $sizeFormatted])) + ->success() + ->persistent() + ->actions([ + \Filament\Actions\Action::make('download') + ->label(__('Download')) + ->url($downloadUrl) + ->openUrlInNewTab() + ->button(), + \Filament\Actions\Action::make('close') + ->label(__('Close')) + ->close() + ->color('gray'), + ]) + ->send(); + + $this->setTab('local'); + $this->resetTable(); + } else { + throw new Exception($result['error'] ?? 'Download failed'); + } + } catch (Exception $e) { + Notification::make()->title(__('Download failed'))->body($e->getMessage())->danger()->send(); + } + } + + public function restoreBackupAction(): Action + { + return Action::make('restoreBackup') + ->label(__('Restore')) + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->requiresConfirmation() + ->modalHeading(__('Restore Backup')) + ->modalDescription(__('Select what you want to restore from this backup. Existing data may be overwritten.')) + ->modalIcon('heroicon-o-arrow-path') + ->modalIconColor('warning') + ->modalSubmitActionLabel(__('Start Restore')) + ->form(function () { + $backup = $this->selectedBackupId ? Backup::find($this->selectedBackupId) : null; + $manifest = $backup ? [ + 'domains' => $backup->domains ?? [], + 'databases' => $backup->databases ?? [], + 'mailboxes' => $backup->mailboxes ?? [], + ] : []; + + return [ + Section::make(__('Restore Options')) + ->description(__('Choose which types of data to restore')) + ->schema([ + Grid::make(2)->schema([ + Toggle::make('restore_files') + ->label(__('Restore Website Files')) + ->default(true) + ->reactive() + ->helperText(__('Restores all website files to their original locations')), + Toggle::make('restore_databases') + ->label(__('Restore Databases')) + ->default(true) + ->reactive() + ->helperText(__('Restores MySQL databases (overwrites existing data)')), + Toggle::make('restore_mailboxes') + ->label(__('Restore Mailboxes')) + ->default(true) + ->helperText(__('Restores email accounts and messages')), + ]), + ]), + Section::make(__('Select Items')) + ->description(__('Leave empty to restore all items')) + ->schema([ + CheckboxList::make('selected_domains') + ->label(__('Domains to Restore')) + ->options(fn () => array_combine($manifest['domains'] ?? [], $manifest['domains'] ?? [])) + ->visible(fn ($get) => $get('restore_files') && ! empty($manifest['domains'])) + ->helperText(__('Select specific domains or leave empty for all')), + CheckboxList::make('selected_databases') + ->label(__('Databases to Restore')) + ->options(fn () => array_combine($manifest['databases'] ?? [], $manifest['databases'] ?? [])) + ->visible(fn ($get) => $get('restore_databases') && ! empty($manifest['databases'])) + ->helperText(__('Select specific databases or leave empty for all')), + CheckboxList::make('selected_mailboxes') + ->label(__('Mailboxes to Restore')) + ->options(fn () => array_combine($manifest['mailboxes'] ?? [], $manifest['mailboxes'] ?? [])) + ->visible(fn ($get) => $get('restore_mailboxes') && ! empty($manifest['mailboxes'])) + ->helperText(__('Select specific mailboxes or leave empty for all')), + ]), + ]; + }) + ->action(function (array $data) { + $this->performRestore($data); + }); + } + + public function startRestore(int $backupId): void + { + $this->selectedBackupId = $backupId; + $this->mountAction('restoreBackupAction'); + } + + public function performRestore(array $data): void + { + $user = $this->getUser(); + $backup = Backup::find($this->selectedBackupId); + + if (! $backup || $backup->user_id !== $user->id) { + Notification::make()->title(__('Backup not found'))->danger()->send(); + + return; + } + + $restore = BackupRestore::create([ + 'backup_id' => $backup->id, + 'user_id' => $user->id, + 'restore_files' => $data['restore_files'] ?? true, + 'restore_databases' => $data['restore_databases'] ?? true, + 'restore_mailboxes' => $data['restore_mailboxes'] ?? true, + 'selected_domains' => ! empty($data['selected_domains']) ? $data['selected_domains'] : null, + 'selected_databases' => ! empty($data['selected_databases']) ? $data['selected_databases'] : null, + 'selected_mailboxes' => ! empty($data['selected_mailboxes']) ? $data['selected_mailboxes'] : null, + 'status' => 'pending', + ]); + + try { + $restore->markAsRunning(); + + $result = $this->getAgent()->backupRestore($user->username, $backup->local_path, [ + 'restore_files' => $data['restore_files'] ?? true, + 'restore_databases' => $data['restore_databases'] ?? true, + 'restore_mailboxes' => $data['restore_mailboxes'] ?? true, + 'selected_domains' => ! empty($data['selected_domains']) ? $data['selected_domains'] : null, + 'selected_databases' => ! empty($data['selected_databases']) ? $data['selected_databases'] : null, + 'selected_mailboxes' => ! empty($data['selected_mailboxes']) ? $data['selected_mailboxes'] : null, + ]); + + if ($result['success']) { + $restore->markAsCompleted($result['restored'] ?? []); + Notification::make() + ->title(__('Restore completed')) + ->body(__('Restored: :domains domains, :databases databases, :mailboxes mailboxes', [ + 'domains' => $result['files_count'], + 'databases' => $result['databases_count'], + 'mailboxes' => $result['mailboxes_count'], + ])) + ->success() + ->send(); + } else { + throw new Exception($result['error'] ?? 'Restore failed'); + } + } catch (Exception $e) { + $restore->markAsFailed($e->getMessage()); + Notification::make()->title(__('Restore failed'))->body($e->getMessage())->danger()->send(); + } + + $this->resetTable(); + } + + protected function addDestinationAction(): Action + { + return Action::make('addDestination') + ->label(__('Add SFTP')) + ->icon('heroicon-o-plus') + ->color('primary') + ->modalHeading(__('Add SFTP Destination')) + ->modalDescription(__('Configure an SFTP server to store your backups remotely.')) + ->form([ + TextInput::make('name') + ->label(__('Name')) + ->placeholder(__('My Backup Server')) + ->required(), + Grid::make(2)->schema([ + TextInput::make('host') + ->label(__('Host')) + ->placeholder('backup.example.com') + ->required(), + TextInput::make('port') + ->label(__('Port')) + ->numeric() + ->default(22), + ]), + TextInput::make('username') + ->label(__('Username')) + ->required(), + TextInput::make('password') + ->label(__('Password')) + ->password() + ->helperText(__('Leave empty if using SSH key')), + Textarea::make('private_key') + ->label(__('SSH Private Key')) + ->rows(4) + ->helperText(__('Paste your private key here (optional)')), + TextInput::make('path') + ->label(__('Remote Path')) + ->default('/backups') + ->helperText(__('Directory on the server to store backups')), + FormActions::make([ + Action::make('testConnection') + ->label(__('Test Connection')) + ->icon('heroicon-o-signal') + ->color('gray') + ->action(function ($get, $livewire) { + $config = [ + 'type' => 'sftp', + 'host' => $get('host') ?? '', + 'port' => (int) ($get('port') ?? 22), + 'username' => $get('username') ?? '', + 'password' => $get('password') ?? '', + 'private_key' => $get('private_key') ?? '', + 'path' => $get('path') ?? '/backups', + ]; + + try { + $result = $livewire->getAgent()->backupTestDestination($config); + if ($result['success']) { + Notification::make() + ->title(__('Connection successful')) + ->success() + ->send(); + } else { + Notification::make() + ->title(__('Connection failed')) + ->body($result['error'] ?? __('Could not connect')) + ->danger() + ->send(); + } + } catch (Exception $e) { + Notification::make() + ->title(__('Connection failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + }), + ])->visible(fn ($get) => ! empty($get('host'))), + ]) + ->action(function (array $data) { + $user = $this->getUser(); + + // Test connection first + $config = [ + 'type' => 'sftp', + 'host' => $data['host'] ?? '', + 'port' => (int) ($data['port'] ?? 22), + 'username' => $data['username'] ?? '', + 'password' => $data['password'] ?? '', + 'private_key' => $data['private_key'] ?? '', + 'path' => $data['path'] ?? '/backups', + ]; + + try { + $result = $this->getAgent()->backupTestDestination($config); + if (! $result['success']) { + Notification::make() + ->title(__('Connection failed')) + ->body($result['error'] ?? __('Could not connect to SFTP server')) + ->danger() + ->send(); + + return; + } + } catch (Exception $e) { + Notification::make() + ->title(__('Connection failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + + return; + } + + BackupDestination::create([ + 'user_id' => $user->id, + 'name' => $data['name'], + 'type' => 'sftp', + 'config' => $config, + 'is_server_backup' => false, + 'is_active' => true, + 'last_tested_at' => now(), + 'test_status' => 'success', + ]); + + Notification::make()->title(__('SFTP destination added'))->success()->send(); + $this->resetTable(); + }); + } + + public function testUserDestination(int $id): void + { + $user = $this->getUser(); + $destination = BackupDestination::where('id', $id)->where('user_id', $user->id)->first(); + + if (! $destination) { + Notification::make()->title(__('Destination not found'))->danger()->send(); + + return; + } + + try { + $config = array_merge($destination->config ?? [], ['type' => $destination->type]); + $result = $this->getAgent()->backupTestDestination($config); + + $destination->update([ + 'last_tested_at' => now(), + 'test_status' => $result['success'] ? 'success' : 'failed', + 'test_message' => $result['message'] ?? $result['error'] ?? null, + ]); + + if ($result['success']) { + Notification::make()->title(__('Connection successful'))->success()->send(); + } else { + Notification::make()->title(__('Connection failed'))->body($result['error'] ?? __('Unknown error'))->danger()->send(); + } + } catch (Exception $e) { + $destination->update([ + 'last_tested_at' => now(), + 'test_status' => 'failed', + 'test_message' => $e->getMessage(), + ]); + Notification::make()->title(__('Test failed'))->body($e->getMessage())->danger()->send(); + } + + $this->resetTable(); + } + + public function deleteUserDestination(int $id): void + { + $user = $this->getUser(); + BackupDestination::where('id', $id)->where('user_id', $user->id)->delete(); + Notification::make()->title(__('Destination deleted'))->success()->send(); + $this->resetTable(); + } + + protected 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]; + } +} diff --git a/app/Filament/Jabali/Pages/CpanelMigration.php b/app/Filament/Jabali/Pages/CpanelMigration.php new file mode 100644 index 0000000..ee1cb7c --- /dev/null +++ b/app/Filament/Jabali/Pages/CpanelMigration.php @@ -0,0 +1,1734 @@ +label(__('Start Over')) + ->icon('heroicon-o-arrow-path') + ->color('gray') + ->requiresConfirmation() + ->modalHeading(__('Start Over')) + ->modalDescription(__('This will reset all migration data. Are you sure?')) + ->action('resetMigration'), + ]; + } + + public function mount(): void + { + $this->restoreMigrationStateFromSession(); + } + + public function updatedHostname(): void + { + $this->resetConnection(); + } + + public function updatedCpanelUsername(): void + { + $this->resetConnection(); + } + + public function updatedApiToken(): void + { + $this->resetConnection(); + } + + public function updatedPort(): void + { + $this->resetConnection(); + } + + public function updatedUseSSL(): void + { + $this->resetConnection(); + } + + public function updatedSourceType(): void + { + $this->backupInitiated = false; + $this->backupPid = null; + $this->backupInProgress = false; + $this->remoteBackupPath = null; + $this->backupFilename = null; + $this->backupPath = null; + $this->backupSize = 0; + $this->downloadProgress = 0; + $this->discoveredData = []; + $this->statusLog = []; + $this->analysisLog = []; + $this->isAnalyzing = false; + $this->step1Complete = false; + $this->localBackupPath = null; + + if ($this->sourceType === 'local') { + $this->loadLocalBackups(); + } + + $this->storeCredentialsInSession(); + } + + public function updatedLocalBackupPath(): void + { + if (! $this->localBackupPath) { + $this->backupFilename = null; + $this->backupPath = null; + $this->backupSize = 0; + $this->isConnected = false; + $this->discoveredData = []; + $this->statusLog = []; + $this->analysisLog = []; + $this->storeCredentialsInSession(); + + return; + } + + $this->selectLocalBackup(); + } + + public function getAgent(): AgentClient + { + return $this->agent ??= new AgentClient; + } + + protected function getUser(): User + { + return Auth::user(); + } + + protected function resetConnection(): void + { + $this->cpanel = null; + $this->isConnected = false; + $this->connectionInfo = []; + } + + protected function getCpanel(): ?CpanelApiService + { + if (! $this->hostname || ! $this->cpanelUsername || ! $this->apiToken) { + $this->restoreCredentialsFromSession(); + } + + if (! $this->hostname || ! $this->cpanelUsername || ! $this->apiToken) { + return null; + } + + return $this->cpanel ??= new CpanelApiService( + $this->hostname, + $this->cpanelUsername, + $this->apiToken, + $this->port, + $this->useSSL + ); + } + + protected function getBackupDestPath(): string + { + $user = $this->getUser(); + + return "/home/{$user->username}/cpanel-migration"; + } + + protected function getLocalBackupRoot(): string + { + $user = $this->getUser(); + + return "/home/{$user->username}/backups"; + } + + protected function loadLocalBackups(): void + { + $this->availableBackups = []; + + $result = $this->getAgent()->send('file.list', [ + 'username' => $this->getUser()->username, + 'path' => 'backups', + ]); + + if (! ($result['success'] ?? false)) { + $this->getAgent()->send('file.mkdir', [ + 'username' => $this->getUser()->username, + 'path' => 'backups', + ]); + + $result = $this->getAgent()->send('file.list', [ + 'username' => $this->getUser()->username, + 'path' => 'backups', + ]); + + if (! ($result['success'] ?? false)) { + return; + } + } + + $items = $result['items'] ?? []; + foreach ($items as $item) { + if (($item['is_dir'] ?? false) === true) { + continue; + } + + $name = (string) ($item['name'] ?? ''); + if (! preg_match('/\.(tar\.gz|tgz)$/i', $name)) { + continue; + } + + $this->availableBackups[] = $item; + } + } + + public function refreshLocalBackups(): void + { + $this->loadLocalBackups(); + + Notification::make() + ->title(__('Backup list refreshed')) + ->success() + ->send(); + } + + protected function getLocalBackupOptions(): array + { + $options = []; + foreach ($this->availableBackups as $item) { + $path = $item['path'] ?? null; + $name = $item['name'] ?? null; + if (! $path || ! $name) { + continue; + } + + $size = $this->formatBytes((int) ($item['size'] ?? 0)); + $options[$path] = "{$name} ({$size})"; + } + + return $options; + } + + public function selectLocalBackup(): void + { + if (! $this->localBackupPath) { + return; + } + + $info = $this->getAgent()->send('file.info', [ + 'username' => $this->getUser()->username, + 'path' => $this->localBackupPath, + ]); + + if (! ($info['success'] ?? false)) { + Notification::make() + ->title(__('Backup file not found')) + ->body($info['error'] ?? __('Unable to read backup file')) + ->danger() + ->send(); + + return; + } + + $details = $info['info'] ?? []; + if (! ($details['is_file'] ?? false)) { + Notification::make() + ->title(__('Invalid backup selection')) + ->body(__('Please select a backup file')) + ->warning() + ->send(); + + return; + } + + $this->backupFilename = $details['name'] ?? basename($this->localBackupPath); + $this->backupSize = (int) ($details['size'] ?? 0); + $this->backupPath = "/home/{$this->getUser()->username}/{$this->localBackupPath}"; + $this->isConnected = true; + + $this->statusLog = []; + $this->analysisLog = []; + $this->discoveredData = []; + $this->addStatusLog(__('Selected local backup: :name', ['name' => $this->backupFilename]), 'success'); + + $this->storeCredentialsInSession(); + } + + protected function storeCredentialsInSession(): void + { + session()->put('user_cpanel_migration.sourceType', $this->sourceType); + session()->put('user_cpanel_migration.localBackupPath', $this->localBackupPath); + session()->put('user_cpanel_migration.hostname', $this->hostname); + session()->put('user_cpanel_migration.username', $this->cpanelUsername); + session()->put('user_cpanel_migration.token', $this->apiToken); + session()->put('user_cpanel_migration.port', $this->port); + session()->put('user_cpanel_migration.useSSL', $this->useSSL); + session()->put('user_cpanel_migration.isConnected', $this->isConnected); + session()->put('user_cpanel_migration.connectionInfo', $this->connectionInfo); + session()->put('user_cpanel_migration.backupInitiated', $this->backupInitiated); + session()->put('user_cpanel_migration.backupPid', $this->backupPid); + session()->put('user_cpanel_migration.backupInProgress', $this->backupInProgress); + session()->put('user_cpanel_migration.remoteBackupPath', $this->remoteBackupPath); + session()->put('user_cpanel_migration.backupFilename', $this->backupFilename); + session()->put('user_cpanel_migration.backupPath', $this->backupPath); + session()->put('user_cpanel_migration.backupSize', $this->backupSize); + session()->put('user_cpanel_migration.downloadProgress', $this->downloadProgress); + session()->put('user_cpanel_migration.discoveredData', $this->discoveredData); + session()->put('user_cpanel_migration.statusLog', $this->statusLog); + session()->put('user_cpanel_migration.analysisLog', $this->analysisLog); + session()->put('user_cpanel_migration.isAnalyzing', $this->isAnalyzing); + session()->put('user_cpanel_migration.step1Complete', $this->step1Complete); + session()->put('user_cpanel_migration.pollCount', $this->pollCount); + + session()->save(); + } + + protected function restoreCredentialsFromSession(): void + { + if (! session()->has('user_cpanel_migration.sourceType') && ! session()->has('user_cpanel_migration.hostname')) { + return; + } + + $this->sourceType = session('user_cpanel_migration.sourceType', 'remote'); + $this->localBackupPath = session('user_cpanel_migration.localBackupPath'); + $this->hostname = session('user_cpanel_migration.hostname'); + $this->cpanelUsername = session('user_cpanel_migration.username'); + $this->apiToken = session('user_cpanel_migration.token'); + $this->port = session('user_cpanel_migration.port', 2083); + $this->useSSL = session('user_cpanel_migration.useSSL', true); + $this->isConnected = session('user_cpanel_migration.isConnected', false); + $this->connectionInfo = session('user_cpanel_migration.connectionInfo', []); + $this->backupInitiated = session('user_cpanel_migration.backupInitiated', false); + $this->backupPid = session('user_cpanel_migration.backupPid'); + $this->backupInProgress = session('user_cpanel_migration.backupInProgress', false); + $this->remoteBackupPath = session('user_cpanel_migration.remoteBackupPath'); + $this->backupFilename = session('user_cpanel_migration.backupFilename'); + $this->backupPath = session('user_cpanel_migration.backupPath'); + $this->backupSize = session('user_cpanel_migration.backupSize', 0); + $this->downloadProgress = session('user_cpanel_migration.downloadProgress', 0); + $this->discoveredData = session('user_cpanel_migration.discoveredData', []); + $this->statusLog = session('user_cpanel_migration.statusLog', []); + $this->analysisLog = session('user_cpanel_migration.analysisLog', []); + $this->isAnalyzing = session('user_cpanel_migration.isAnalyzing', false); + $this->step1Complete = session('user_cpanel_migration.step1Complete', false); + $this->pollCount = session('user_cpanel_migration.pollCount', 0); + } + + protected function clearSessionCredentials(): void + { + session()->forget([ + 'user_cpanel_migration.sourceType', + 'user_cpanel_migration.localBackupPath', + 'user_cpanel_migration.hostname', + 'user_cpanel_migration.username', + 'user_cpanel_migration.token', + 'user_cpanel_migration.port', + 'user_cpanel_migration.useSSL', + 'user_cpanel_migration.isConnected', + 'user_cpanel_migration.connectionInfo', + 'user_cpanel_migration.backupInitiated', + 'user_cpanel_migration.backupPid', + 'user_cpanel_migration.backupInProgress', + 'user_cpanel_migration.remoteBackupPath', + 'user_cpanel_migration.backupFilename', + 'user_cpanel_migration.backupPath', + 'user_cpanel_migration.backupSize', + 'user_cpanel_migration.downloadProgress', + 'user_cpanel_migration.discoveredData', + 'user_cpanel_migration.statusLog', + 'user_cpanel_migration.analysisLog', + 'user_cpanel_migration.isAnalyzing', + 'user_cpanel_migration.step1Complete', + 'user_cpanel_migration.pollCount', + 'user_cpanel_restore_job_id', + 'user_cpanel_restore_log_path', + 'user_cpanel_restore_processing', + ]); + } + + protected function restoreMigrationStateFromSession(): void + { + $this->restoreCredentialsFromSession(); + + if ($this->sourceType === 'local') { + $this->loadLocalBackups(); + } + + $this->restoreJobId = session('user_cpanel_restore_job_id'); + $this->restoreLogPath = session('user_cpanel_restore_log_path'); + $this->isProcessing = (bool) session('user_cpanel_restore_processing', false); + + if ($this->restoreJobId && $this->restoreLogPath) { + $this->migrationLog = $this->readMigrationLog($this->restoreLogPath); + + $status = Cache::get($this->getRestoreCacheKey()); + if (is_array($status)) { + $this->restoreStatus = $status['status'] ?? $this->restoreStatus; + } + } + } + + protected function getRestoreCacheKey(): string + { + return $this->restoreJobId ? 'cpanel_restore_status_'.$this->restoreJobId : ''; + } + + protected function getForms(): array + { + return ['migrationForm']; + } + + public function migrationForm(Schema $schema): Schema + { + return $schema->schema([ + Wizard::make([ + $this->getConnectStep(), + $this->getBackupStep(), + $this->getReviewStep(), + $this->getRestoreStep(), + ]) + ->nextAction(fn (Action $action) => $action->disabled(fn () => $this->isNextStepDisabled())) + ->persistStepInQueryString('cpanel-step'), + ]); + } + + protected function isNextStepDisabled(): bool + { + if (! $this->step1Complete) { + return ! $this->isConnected; + } + + if (! $this->backupPath || empty($this->discoveredData)) { + return true; + } + + return false; + } + + protected function getConnectStep(): Step + { + return Step::make(__('Connect')) + ->id('connect') + ->icon('heroicon-o-link') + ->description(__('Enter cPanel credentials')) + ->schema([ + Section::make(__('Migration Source')) + ->icon('heroicon-o-arrow-path') + ->schema([ + Radio::make('sourceType') + ->label(__('Source Type')) + ->options([ + 'remote' => __('Remote cPanel Server'), + 'local' => __('Local Backup File'), + ]) + ->default('remote') + ->inline() + ->live(), + ]), + + Section::make(__('cPanel Credentials')) + ->description(__('Enter the cPanel server connection details')) + ->icon('heroicon-o-link') + ->visible(fn () => $this->sourceType === 'remote') + ->schema([ + Grid::make(['default' => 1, 'sm' => 2])->schema([ + TextInput::make('hostname') + ->label(__('cPanel Hostname')) + ->placeholder('cpanel.example.com') + ->required() + ->helperText(__('Your cPanel server hostname or IP address')), + TextInput::make('port') + ->label(__('Port')) + ->numeric() + ->default(2083) + ->required() + ->helperText(__('Usually 2083 for SSL or 2082 without')), + ]), + Grid::make(['default' => 1, 'sm' => 2])->schema([ + TextInput::make('cpanelUsername') + ->label(__('cPanel Username')) + ->required() + ->helperText(__('Your cPanel account username')), + TextInput::make('apiToken') + ->label(__('API Token')) + ->password() + ->required() + ->revealable() + ->helperText(__('Generate from cPanel → Security → Manage API Tokens')), + ]), + Checkbox::make('useSSL') + ->label(__('Use SSL (HTTPS)')) + ->default(true) + ->helperText(__('Recommended. Disable only if your cPanel does not support SSL.')), + + FormActions::make([ + Action::make('testConnection') + ->label(__('Test Connection')) + ->icon('heroicon-o-signal') + ->color($this->isConnected ? 'success' : 'primary') + ->action('testConnection'), + ])->alignEnd(), + + Section::make(__('Connection Successful')) + ->icon('heroicon-o-check-circle') + ->iconColor('success') + ->visible(fn () => $this->isConnected) + ->schema([ + Text::make(__('You can proceed to the next step.')), + Grid::make(['default' => 2, 'sm' => 4])->schema([ + Section::make((string) ($this->connectionInfo['domains'] ?? 0)) + ->description(__('Domains')) + ->icon('heroicon-o-globe-alt') + ->iconColor('primary') + ->compact(), + Section::make((string) ($this->connectionInfo['databases'] ?? 0)) + ->description(__('Databases')) + ->icon('heroicon-o-circle-stack') + ->iconColor('warning') + ->compact(), + Section::make((string) ($this->connectionInfo['emails'] ?? 0)) + ->description(__('Mailboxes')) + ->icon('heroicon-o-envelope') + ->iconColor('success') + ->compact(), + Section::make((string) ($this->connectionInfo['ssl'] ?? 0)) + ->description(__('SSL Certs')) + ->icon('heroicon-o-lock-closed') + ->iconColor('info') + ->compact(), + ]), + ]), + ]), + + Section::make(__('Local Backup')) + ->description(__('Select a backup file from your home backups folder')) + ->icon('heroicon-o-folder-open') + ->visible(fn () => $this->sourceType === 'local') + ->headerActions([ + Action::make('refreshLocalBackups') + ->label(__('Refresh')) + ->icon('heroicon-o-arrow-path') + ->color('gray') + ->action('refreshLocalBackups'), + ]) + ->schema([ + Text::make(__('Folder: :path', ['path' => $this->getLocalBackupRoot()])), + Select::make('localBackupPath') + ->label(__('Backup File')) + ->options($this->getLocalBackupOptions()) + ->searchable() + ->placeholder(__('Select a backup file')) + ->required(), + Text::make(__('No backups found in the folder. Upload a cPanel backup file to continue.')) + ->color('gray') + ->visible(fn () => empty($this->availableBackups)), + ]), + ]) + ->afterValidation(function () { + if ($this->sourceType === 'local') { + if (! $this->backupPath) { + Notification::make() + ->title(__('Backup required')) + ->body(__('Please select a local backup file before proceeding')) + ->danger() + ->send(); + throw new Exception(__('Please select a local backup file')); + } + } elseif (! $this->isConnected) { + Notification::make() + ->title(__('Connection required')) + ->body(__('Please test the connection before proceeding')) + ->danger() + ->send(); + throw new Exception(__('Please test the connection first')); + } + + $this->step1Complete = true; + $this->storeCredentialsInSession(); + }); + } + + protected function getBackupStep(): Step + { + if ($this->sourceType === 'local') { + return Step::make(__('Backup')) + ->id('backup') + ->icon('heroicon-o-folder-open') + ->description(__('Analyze local backup')) + ->schema([ + Section::make(__('Local Backup')) + ->description(__('Analyze the selected backup file before restoring')) + ->icon('heroicon-o-folder-open') + ->iconColor('primary') + ->headerActions([ + Action::make('analyzeBackup') + ->label(__('Analyze Backup')) + ->icon('heroicon-o-magnifying-glass') + ->color('primary') + ->disabled(fn () => $this->isAnalyzing || ! $this->backupPath || ! empty($this->discoveredData)) + ->action('analyzeBackup'), + ]) + ->schema([ + Text::make(__('File: :name', [ + 'name' => $this->backupFilename ?? '-', + ])), + Text::make(__('Size: :size', [ + 'size' => $this->backupSize ? $this->formatBytes($this->backupSize) : '-', + ])), + ]), + + Section::make(__('Analysis Progress')) + ->icon($this->getAnalysisStatusIcon()) + ->iconColor($this->getAnalysisStatusColor()) + ->schema($this->getAnalysisStatusSchema()) + ->extraAttributes($this->isAnalyzing ? ['wire:poll.1s' => 'pollAnalysisStatus'] : []), + ]) + ->afterValidation(function () { + if (empty($this->discoveredData)) { + Notification::make() + ->title(__('Analysis required')) + ->body(__('Please analyze the backup file before proceeding')) + ->danger() + ->send(); + throw new Exception(__('Please analyze the backup file')); + } + }); + } + + return Step::make(__('Backup')) + ->id('backup') + ->icon('heroicon-o-cloud-arrow-down') + ->description(__('Create and download backup')) + ->schema([ + Section::make(__('Backup Transfer')) + ->description(__('Create a backup on cPanel and download it to this server')) + ->icon('heroicon-o-server') + ->schema([ + Text::make(__('Local Path: :path', ['path' => $this->getBackupDestPath()])), + Text::make(__('Note: Large accounts may take several minutes.'))->color('warning'), + ]), + + FormActions::make([ + Action::make('startBackup') + ->label(__('Start Backup')) + ->icon('heroicon-o-cloud-arrow-up') + ->color('success') + ->visible(fn () => ! $this->backupInitiated) + ->requiresConfirmation() + ->modalHeading(__('Start Backup')) + ->modalDescription(__('This will create a full backup on your cPanel account.')) + ->action('startBackup'), + + Action::make('checkBackup') + ->label(__('Check Status')) + ->icon('heroicon-o-arrow-path') + ->color('info') + ->visible(fn () => $this->backupInitiated && ! $this->remoteBackupPath && ! $this->backupFilename) + ->action('checkBackupStatus'), + + Action::make('downloadBackup') + ->label(__('Download Backup')) + ->icon('heroicon-o-cloud-arrow-down') + ->color('success') + ->visible(fn () => $this->remoteBackupPath && ! $this->backupFilename) + ->action('downloadBackup'), + + Action::make('analyzeBackup') + ->label(__('Analyze Backup')) + ->icon('heroicon-o-magnifying-glass') + ->color('primary') + ->visible(fn () => $this->backupFilename && empty($this->discoveredData)) + ->action('analyzeBackup'), + ])->alignEnd(), + + Section::make(__('Transfer Status')) + ->icon($this->backupPath ? 'heroicon-o-check-circle' : 'heroicon-o-clock') + ->iconColor($this->backupPath ? 'success' : ($this->backupInitiated ? 'warning' : 'gray')) + ->schema($this->getStatusLogSchema()) + ->extraAttributes($this->backupInitiated && ! $this->backupPath ? ['wire:poll.5s' => 'pollBackupStatus'] : []), + + Section::make(__('Analysis Progress')) + ->icon($this->getAnalysisStatusIcon()) + ->iconColor($this->getAnalysisStatusColor()) + ->visible(fn () => $this->backupFilename !== null || $this->isAnalyzing || ! empty($this->discoveredData)) + ->schema($this->getAnalysisStatusSchema()), + ]) + ->afterValidation(function () { + if (! $this->backupPath || empty($this->discoveredData)) { + Notification::make() + ->title(__('Backup required')) + ->body(__('Please complete the backup and analysis before proceeding')) + ->danger() + ->send(); + throw new Exception(__('Please complete the backup first')); + } + + $this->storeCredentialsInSession(); + }); + } + + protected function getReviewStep(): Step + { + return Step::make(__('Review')) + ->id('review') + ->icon('heroicon-o-clipboard-document-check') + ->description(__('Configure restore options')) + ->schema($this->getReviewStepSchema()); + } + + protected function getRestoreStep(): Step + { + return Step::make(__('Restore')) + ->id('restore') + ->icon('heroicon-o-play') + ->description(__('Migration progress')) + ->schema([ + FormActions::make([ + Action::make('startRestore') + ->label(__('Start Restore')) + ->icon('heroicon-o-play') + ->color('success') + ->visible(fn () => ! $this->isProcessing && empty($this->migrationLog)) + ->requiresConfirmation() + ->modalHeading(__('Start Restore')) + ->modalDescription(__('This will restore the selected items. Existing data may be overwritten.')) + ->action('startRestore'), + + Action::make('newMigration') + ->label(__('New Migration')) + ->icon('heroicon-o-plus') + ->color('primary') + ->visible(fn () => ! $this->isProcessing && ! empty($this->migrationLog)) + ->action('resetMigration'), + ])->alignEnd(), + + Section::make(__('Migration Progress')) + ->icon($this->isProcessing ? 'heroicon-o-arrow-path' : ($this->migrationLog ? 'heroicon-o-check-circle' : 'heroicon-o-clock')) + ->iconColor($this->isProcessing ? 'warning' : ($this->migrationLog ? 'success' : 'gray')) + ->schema($this->getMigrationLogSchema()) + ->extraAttributes($this->isProcessing ? ['wire:poll.1s' => 'pollMigrationLog'] : []), + ]); + } + + protected function getReviewStepSchema(): array + { + if (empty($this->discoveredData)) { + return [ + Section::make(__('Waiting for Backup')) + ->icon('heroicon-o-clock') + ->iconColor('warning') + ->schema([ + Text::make(__('Please complete the backup in the previous step.')), + ]), + ]; + } + + $domainCount = count($this->discoveredData['domains'] ?? []); + $dbCount = count($this->discoveredData['databases'] ?? []); + $mailCount = count($this->discoveredData['mailboxes'] ?? []); + $forwarderCount = count($this->discoveredData['forwarders'] ?? []); + $sslCount = count($this->discoveredData['ssl_certificates'] ?? []); + + return [ + Section::make(__('Migration Summary')) + ->icon('heroicon-o-clipboard-document-list') + ->iconColor('primary') + ->schema([ + Grid::make(['default' => 2, 'sm' => 5])->schema([ + Section::make((string) $domainCount) + ->description(__('Domains')) + ->icon('heroicon-o-globe-alt') + ->iconColor('primary') + ->compact(), + Section::make((string) $dbCount) + ->description(__('Databases')) + ->icon('heroicon-o-circle-stack') + ->iconColor('warning') + ->compact(), + Section::make((string) $mailCount) + ->description(__('Mailboxes')) + ->icon('heroicon-o-envelope') + ->iconColor('success') + ->compact(), + Section::make((string) $forwarderCount) + ->description(__('Forwarders')) + ->icon('heroicon-o-arrow-uturn-right') + ->iconColor('gray') + ->compact(), + Section::make((string) $sslCount) + ->description(__('SSL Certs')) + ->icon('heroicon-o-lock-closed') + ->iconColor('info') + ->compact(), + ]), + ]), + + Section::make(__('What to Restore')) + ->description(__('Select which parts of the backup to restore')) + ->icon('heroicon-o-check-circle') + ->schema([ + Grid::make(['default' => 1, 'sm' => 2])->schema([ + Checkbox::make('restoreFiles') + ->label(__('Website Files')) + ->helperText(__('Restore all website files to your domains folder')) + ->default(true), + Checkbox::make('restoreDatabases') + ->label(__('MySQL Databases')) + ->helperText(__('Restore databases with all data')) + ->default(true), + Checkbox::make('restoreEmails') + ->label(__('Email Mailboxes')) + ->helperText(__('Restore email accounts and messages')) + ->default(true), + Checkbox::make('restoreSsl') + ->label(__('SSL Certificates')) + ->helperText(__('Restore SSL certificates for domains')) + ->default(true), + ]), + ]), + + Section::make(__('Discovered Data')) + ->icon('heroicon-o-magnifying-glass') + ->schema([ + Tabs::make('DataTabs') + ->tabs([ + Tabs\Tab::make(__('Domains')) + ->icon('heroicon-o-globe-alt') + ->badge((string) $domainCount) + ->schema($this->getDomainsTabContent()), + Tabs\Tab::make(__('Databases')) + ->icon('heroicon-o-circle-stack') + ->badge((string) $dbCount) + ->schema($this->getDatabasesTabContent()), + Tabs\Tab::make(__('Mailboxes')) + ->icon('heroicon-o-envelope') + ->badge((string) $mailCount) + ->schema($this->getMailboxesTabContent()), + Tabs\Tab::make(__('Forwarders')) + ->icon('heroicon-o-arrow-uturn-right') + ->badge((string) $forwarderCount) + ->schema($this->getForwardersTabContent()), + Tabs\Tab::make(__('SSL')) + ->icon('heroicon-o-lock-closed') + ->badge((string) $sslCount) + ->schema($this->getSslTabContent()), + ]), + ]), + ]; + } + + protected function addStatusLog(string $message, string $status = 'info'): void + { + $this->statusLog[] = [ + 'message' => $message, + 'status' => $status, + 'time' => now()->format('H:i:s'), + ]; + } + + protected function getStatusLogSchema(): array + { + if (empty($this->statusLog)) { + return [ + Text::make(__('Click "Start Backup" to begin.'))->color('gray'), + ]; + } + + $items = []; + foreach ($this->statusLog as $entry) { + $color = match ($entry['status']) { + 'success' => 'success', + 'error' => 'danger', + 'pending' => 'warning', + default => 'gray', + }; + + $prefix = match ($entry['status']) { + 'success' => '✓ ', + 'error' => '✗ ', + 'pending' => '○ ', + default => '• ', + }; + + $items[] = Text::make($prefix.$entry['message']) + ->color($color); + } + + if ($this->backupPath) { + $items[] = Section::make(__('Backup Complete')) + ->icon('heroicon-o-check-circle') + ->iconColor('success') + ->schema([ + Text::make(__('File: :name', ['name' => $this->backupFilename ?? basename($this->backupPath)])), + Text::make(__('Size: :size', ['size' => $this->formatBytes($this->backupSize)])), + ]) + ->compact(); + } + + return $items; + } + + protected function addAnalysisLog(string $message, string $status = 'info'): void + { + $this->analysisLog[] = [ + 'message' => $message, + 'status' => $status, + 'time' => now()->format('H:i:s'), + ]; + } + + public function pollAnalysisStatus(): void + { + // UI polling refresh during backup analysis. + } + + protected function getAnalysisStatusIcon(): string + { + if (! empty($this->discoveredData)) { + return 'heroicon-o-check-circle'; + } + + if ($this->isAnalyzing) { + return 'heroicon-o-arrow-path'; + } + + return 'heroicon-o-clock'; + } + + protected function getAnalysisStatusColor(): string + { + if (! empty($this->discoveredData)) { + return 'success'; + } + + if ($this->isAnalyzing) { + return 'warning'; + } + + return 'gray'; + } + + protected function getAnalysisStatusSchema(): array + { + $items = []; + + if (! empty($this->analysisLog)) { + foreach ($this->analysisLog as $entry) { + $prefix = match ($entry['status']) { + 'success' => '✓ ', + 'error' => '✗ ', + 'pending' => '○ ', + default => '• ', + }; + $color = match ($entry['status']) { + 'success' => 'success', + 'error' => 'danger', + 'pending' => 'warning', + default => 'gray', + }; + + $items[] = Text::make($prefix.$entry['message'])->color($color); + } + } + + if (empty($this->analysisLog) && ! $this->isAnalyzing && empty($this->discoveredData)) { + return [ + Text::make(__('Click "Analyze Backup" to discover the backup contents.'))->color('gray'), + ]; + } + + if (! empty($this->discoveredData)) { + $domainCount = count($this->discoveredData['domains'] ?? []); + $dbCount = count($this->discoveredData['databases'] ?? []); + $mailCount = count($this->discoveredData['mailboxes'] ?? []); + $forwarderCount = count($this->discoveredData['forwarders'] ?? []); + $sslCount = count($this->discoveredData['ssl_certificates'] ?? []); + + $items[] = Grid::make(['default' => 2, 'sm' => 5])->schema([ + Section::make((string) $domainCount) + ->description(__('Domains')) + ->icon('heroicon-o-globe-alt') + ->iconColor('primary') + ->compact(), + Section::make((string) $dbCount) + ->description(__('Databases')) + ->icon('heroicon-o-circle-stack') + ->iconColor('warning') + ->compact(), + Section::make((string) $mailCount) + ->description(__('Mailboxes')) + ->icon('heroicon-o-envelope') + ->iconColor('success') + ->compact(), + Section::make((string) $forwarderCount) + ->description(__('Forwarders')) + ->icon('heroicon-o-arrow-uturn-right') + ->iconColor('gray') + ->compact(), + Section::make((string) $sslCount) + ->description(__('SSL Certs')) + ->icon('heroicon-o-lock-closed') + ->iconColor('info') + ->compact(), + ]); + } + + return $items; + } + + protected function getDomainsTabContent(): array + { + $domains = $this->discoveredData['domains'] ?? []; + if (empty($domains)) { + return [Text::make(__('No domains found in backup.'))]; + } + + $items = []; + foreach ($domains as $domain) { + $typePrefix = match ($domain['type'] ?? 'addon') { + 'main' => '★ ', + 'addon' => '● ', + 'sub' => '◦ ', + default => '• ', + }; + $typeColor = match ($domain['type'] ?? 'addon') { + 'main' => 'success', + 'addon' => 'primary', + 'sub' => 'warning', + default => 'gray', + }; + + $items[] = Text::make($typePrefix.$domain['name'].' ('.$domain['type'].')') + ->color($typeColor); + } + + return $items; + } + + protected function getDatabasesTabContent(): array + { + $databases = $this->discoveredData['databases'] ?? []; + if (empty($databases)) { + return [Text::make(__('No databases found in backup.'))]; + } + + $items = []; + foreach ($databases as $db) { + $items[] = Text::make('• '.$db['name']) + ->color('primary'); + } + + return $items; + } + + protected function getMailboxesTabContent(): array + { + $mailboxes = $this->discoveredData['mailboxes'] ?? []; + if (empty($mailboxes)) { + return [Text::make(__('No mailboxes found in backup.'))]; + } + + $items = []; + foreach ($mailboxes as $mailbox) { + $items[] = Text::make('✉ '.$mailbox['email']) + ->color('success'); + } + + return $items; + } + + protected function getForwardersTabContent(): array + { + $forwarders = $this->discoveredData['forwarders'] ?? []; + if (empty($forwarders)) { + return [Text::make(__('No forwarders found in backup.'))]; + } + + $items = []; + foreach ($forwarders as $forwarder) { + $email = $forwarder['email'] ?? ''; + $destinations = $forwarder['destinations'] ?? ''; + $destPreview = is_array($destinations) ? implode(', ', $destinations) : $destinations; + $destPreview = strlen($destPreview) > 40 ? substr($destPreview, 0, 37).'...' : $destPreview; + $items[] = Text::make('↪ '.$email.' → '.$destPreview) + ->color('gray'); + } + + return $items; + } + + protected function getSslTabContent(): array + { + $sslCerts = $this->discoveredData['ssl_certificates'] ?? []; + if (empty($sslCerts)) { + return [Text::make(__('No SSL certificates found in backup.'))]; + } + + $items = []; + foreach ($sslCerts as $cert) { + $hasComplete = ($cert['has_key'] ?? false) && ($cert['has_cert'] ?? false); + $prefix = $hasComplete ? '🔒 ' : '⚠ '; + $items[] = Text::make($prefix.$cert['domain']) + ->color($hasComplete ? 'success' : 'warning'); + } + + return $items; + } + + protected function getMigrationLogSchema(): array + { + if (empty($this->migrationLog)) { + return [ + Text::make(__('Click "Start Restore" to begin the migration.'))->color('gray'), + ]; + } + + $items = []; + foreach ($this->migrationLog as $entry) { + $status = $entry['status'] ?? 'info'; + $message = $entry['message'] ?? ''; + + $color = match ($status) { + 'success' => 'success', + 'error' => 'danger', + 'warning' => 'warning', + 'pending' => 'warning', + default => 'gray', + }; + + $prefix = match ($status) { + 'success' => '✓ ', + 'error' => '✗ ', + 'warning' => '• ', + 'pending' => '○ ', + default => '• ', + }; + + $items[] = Text::make($prefix.$message)->color($color); + } + + return $items; + } + + public function testConnection(): void + { + if (empty($this->hostname) || empty($this->cpanelUsername) || empty($this->apiToken)) { + Notification::make() + ->title(__('Missing credentials')) + ->body(__('Please fill in all required fields')) + ->danger() + ->send(); + + return; + } + + try { + $cpanel = $this->getCpanel(); + $result = $cpanel->testConnection(); + + if ($result['success']) { + $this->isConnected = true; + + $summary = $cpanel->getMigrationSummary(); + $this->connectionInfo = [ + 'domains' => $this->countDomains($summary['domains'] ?? []), + 'emails' => count($summary['email_accounts'] ?? []), + 'databases' => count($summary['databases'] ?? []), + 'ssl' => count($summary['ssl_certificates'] ?? []), + ]; + + Notification::make() + ->title(__('Connection successful')) + ->body(__('Found :domains domains, :emails email accounts, :dbs databases', [ + 'domains' => $this->connectionInfo['domains'], + 'emails' => $this->connectionInfo['emails'], + 'dbs' => $this->connectionInfo['databases'], + ])) + ->success() + ->send(); + + $this->storeCredentialsInSession(); + } else { + throw new Exception($result['message'] ?? __('Connection failed')); + } + } catch (Exception $e) { + $this->isConnected = false; + Log::error('cPanel connection failed', ['error' => $e->getMessage()]); + + Notification::make() + ->title(__('Connection failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + protected function countDomains(array $domains): int + { + $count = 0; + if (! empty($domains['main'])) { + $count++; + } + $count += count($domains['addon'] ?? []); + $count += count($domains['sub'] ?? []); + + return $count; + } + + public function startBackup(): void + { + $user = $this->getUser(); + $destPath = $this->getBackupDestPath(); + + try { + $this->statusLog = []; + $this->analysisLog = []; + $this->discoveredData = []; + $this->remoteBackupPath = null; + $this->backupFilename = null; + $this->backupPath = null; + $this->backupSize = 0; + $this->downloadProgress = 0; + $this->addStatusLog(__('Starting backup on cPanel...'), 'pending'); + + $this->getAgent()->send('file.mkdir', [ + 'path' => $destPath, + 'username' => $user->username, + ]); + + $cpanel = $this->getCpanel(); + $result = $cpanel->createBackup(); + + if ($result['success']) { + $this->backupInitiated = true; + $this->backupInProgress = true; + $this->backupPid = $result['pid'] ?? null; + $this->pollCount = 0; + $this->addStatusLog(__('Backup initiated on cPanel'), 'success'); + $this->addStatusLog(__('Waiting for backup to complete on cPanel...'), 'pending'); + + Notification::make() + ->title(__('Backup started')) + ->body(__('cPanel is creating a full backup. This may take several minutes.')) + ->success() + ->send(); + $this->storeCredentialsInSession(); + } else { + throw new Exception($result['message'] ?? __('Failed to start backup')); + } + } catch (Exception $e) { + Log::error('cPanel backup initiation failed', ['error' => $e->getMessage()]); + $this->addStatusLog(__('Backup failed: :message', ['message' => $e->getMessage()]), 'error'); + + Notification::make() + ->title(__('Backup failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function pollBackupStatus(): void + { + $this->checkBackupStatus(true); + } + + public function checkBackupStatus(bool $quiet = false): void + { + $this->pollCount++; + + try { + $cpanel = $this->getCpanel(); + $result = $cpanel->getBackupStatus(); + + if ($result['success'] ?? false) { + $backups = $result['backups'] ?? []; + + if (! empty($backups)) { + $latestBackup = $backups[0]; + $this->remoteBackupPath = $latestBackup['path']; + $this->backupInProgress = false; + $this->addStatusLog(__('Backup ready on cPanel: :name', ['name' => $latestBackup['name']]), 'success'); + + if (! $quiet) { + Notification::make() + ->title(__('Backup ready')) + ->body(__('Backup file found: :name. Click "Download Backup" to continue.', [ + 'name' => $latestBackup['name'], + ])) + ->success() + ->send(); + } + + $this->storeCredentialsInSession(); + + return; + } + + if ($result['in_progress'] ?? false) { + $this->backupInProgress = true; + $this->addStatusLog(__('Backup still in progress on cPanel...'), 'pending'); + + if (! $quiet) { + Notification::make() + ->title(__('Backup in progress')) + ->body(__('cPanel is still creating the backup. Please wait and check again.')) + ->info() + ->send(); + } + + $this->storeCredentialsInSession(); + + return; + } + } + + $this->addStatusLog(__('Backup not ready yet'), 'pending'); + + if (! $quiet) { + Notification::make() + ->title(__('Backup not ready')) + ->body(__('No backup files found yet. Please wait and check again.')) + ->info() + ->send(); + } + $this->storeCredentialsInSession(); + } catch (Exception $e) { + $this->addStatusLog(__('Error checking backup: :message', ['message' => $e->getMessage()]), 'error'); + if (! $quiet) { + Notification::make() + ->title(__('Error checking backup')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + } + + public function downloadBackup(): void + { + if (! $this->remoteBackupPath) { + Notification::make() + ->title(__('No backup to download')) + ->body(__('Please wait for the backup to be created first.')) + ->warning() + ->send(); + + return; + } + + $user = $this->getUser(); + $destPath = $this->getBackupDestPath(); + $filename = basename($this->remoteBackupPath); + $localPath = $destPath.'/'.$filename; + + try { + $this->addStatusLog(__('Downloading backup...'), 'pending'); + Notification::make() + ->title(__('Download started')) + ->body(__('Downloading backup from cPanel. This may take several minutes.')) + ->info() + ->send(); + + $cpanel = $this->getCpanel(); + + $result = $cpanel->downloadFileToPath( + $this->remoteBackupPath, + $localPath, + function ($downloaded, $total) { + if ($total > 0) { + $this->downloadProgress = (int) (($downloaded / $total) * 100); + } + } + ); + + if ($result['success'] ?? false) { + $this->backupFilename = $filename; + $this->backupPath = $localPath; + $this->backupSize = (int) ($result['size'] ?? 0); + $this->downloadProgress = 100; + $this->backupInProgress = false; + + $this->getAgent()->send('file.chown', [ + 'path' => $localPath, + 'username' => $user->username, + ]); + + $this->addStatusLog(__('Backup downloaded: :name (:size)', [ + 'name' => $filename, + 'size' => $this->formatBytes($this->backupSize), + ]), 'success'); + + Notification::make() + ->title(__('Download completed')) + ->body(__('Backup file :name (:size) is ready for analysis', [ + 'name' => $filename, + 'size' => $this->formatBytes($this->backupSize), + ])) + ->success() + ->send(); + $this->storeCredentialsInSession(); + } else { + throw new Exception($result['message'] ?? __('Failed to download backup')); + } + } catch (Exception $e) { + Log::error('cPanel backup download failed', ['error' => $e->getMessage()]); + $this->addStatusLog(__('Download failed: :message', ['message' => $e->getMessage()]), 'error'); + + Notification::make() + ->title(__('Download failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function analyzeBackup(): void + { + if (! $this->backupPath) { + return; + } + + try { + $this->isAnalyzing = true; + $this->analysisLog = []; + $this->addAnalysisLog(__('Analyzing backup contents...'), 'pending'); + + $result = $this->getAgent()->send('cpanel.analyze_backup', [ + 'backup_path' => $this->backupPath, + ]); + + if ($result['success'] ?? false) { + $this->discoveredData = $result['data'] ?? []; + $this->addAnalysisLog(__('Backup analyzed successfully'), 'success'); + $this->addAnalysisLog(__('Found :domains domains, :dbs databases, :mailboxes mailboxes', [ + 'domains' => count($this->discoveredData['domains'] ?? []), + 'dbs' => count($this->discoveredData['databases'] ?? []), + 'mailboxes' => count($this->discoveredData['mailboxes'] ?? []), + ]), 'info'); + + Notification::make() + ->title(__('Backup analyzed')) + ->body(__('Found :domains domains, :dbs databases, :mailboxes mailboxes', [ + 'domains' => count($this->discoveredData['domains'] ?? []), + 'dbs' => count($this->discoveredData['databases'] ?? []), + 'mailboxes' => count($this->discoveredData['mailboxes'] ?? []), + ])) + ->success() + ->send(); + $this->storeCredentialsInSession(); + } else { + throw new Exception($result['error'] ?? __('Failed to analyze backup')); + } + } catch (Exception $e) { + Log::error('Backup analysis failed', ['error' => $e->getMessage()]); + $this->addAnalysisLog(__('Analysis failed: :message', ['message' => $e->getMessage()]), 'error'); + + Notification::make() + ->title(__('Analysis failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } finally { + $this->isAnalyzing = false; + } + } + + public function startRestore(): void + { + if (! $this->backupPath) { + return; + } + + $this->isProcessing = true; + $this->migrationLog = []; + $this->restoreStatus = 'queued'; + + $user = $this->getUser(); + + $this->enqueueRestore($user); + } + + protected function enqueueRestore(User $user): void + { + $this->restoreJobId = (string) Str::uuid(); + $logDir = storage_path('app/migrations/cpanel'); + File::ensureDirectoryExists($logDir); + $this->restoreLogPath = $logDir.'/'.$this->restoreJobId.'.log'; + File::put($this->restoreLogPath, ''); + @chmod($this->restoreLogPath, 0644); + + $this->appendMigrationLog(__('Restore queued for user: :user', ['user' => $user->username]), 'pending'); + + Cache::put($this->getRestoreCacheKey(), ['status' => 'queued'], now()->addHours(2)); + session()->put('user_cpanel_restore_job_id', $this->restoreJobId); + session()->put('user_cpanel_restore_log_path', $this->restoreLogPath); + session()->put('user_cpanel_restore_processing', true); + session()->save(); + + RunCpanelRestore::dispatch( + jobId: $this->restoreJobId, + logPath: $this->restoreLogPath, + backupPath: $this->backupPath, + username: $user->username, + restoreFiles: $this->restoreFiles, + restoreDatabases: $this->restoreDatabases, + restoreEmails: $this->restoreEmails, + restoreSsl: $this->restoreSsl, + discoveredData: ! empty($this->discoveredData) ? $this->discoveredData : null, + ); + } + + public function pollMigrationLog(): void + { + if (! $this->restoreJobId || ! $this->restoreLogPath) { + return; + } + + $this->migrationLog = $this->readMigrationLog($this->restoreLogPath); + + $status = Cache::get($this->getRestoreCacheKey()); + if (is_array($status)) { + $this->restoreStatus = $status['status'] ?? $this->restoreStatus; + } + + if (in_array($this->restoreStatus, ['completed', 'failed'], true)) { + $this->isProcessing = false; + session()->forget(['user_cpanel_restore_job_id', 'user_cpanel_restore_log_path', 'user_cpanel_restore_processing']); + session()->save(); + } + } + + protected function readMigrationLog(string $path): array + { + if (! file_exists($path)) { + return []; + } + + $entries = []; + $lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + foreach ($lines as $line) { + $decoded = json_decode($line, true); + if (is_array($decoded) && isset($decoded['message'], $decoded['status'])) { + $entries[] = [ + 'message' => $decoded['message'], + 'status' => $decoded['status'], + 'time' => $decoded['time'] ?? now()->format('H:i:s'), + ]; + } + } + + return $entries; + } + + protected function appendMigrationLog(string $message, string $status): void + { + $entry = [ + 'message' => $message, + 'status' => $status, + 'time' => now()->format('H:i:s'), + ]; + + $this->migrationLog[] = $entry; + + if ($this->restoreLogPath) { + file_put_contents( + $this->restoreLogPath, + json_encode($entry).PHP_EOL, + FILE_APPEND | LOCK_EX + ); + @chmod($this->restoreLogPath, 0644); + } + } + + public function resetMigration(): void + { + $this->hostname = null; + $this->cpanelUsername = null; + $this->apiToken = null; + $this->port = 2083; + $this->useSSL = true; + $this->sourceType = 'remote'; + $this->localBackupPath = null; + $this->availableBackups = []; + $this->isConnected = false; + $this->backupInitiated = false; + $this->backupPid = null; + $this->backupInProgress = false; + $this->remoteBackupPath = null; + $this->backupFilename = null; + $this->backupPath = null; + $this->backupSize = 0; + $this->downloadProgress = 0; + $this->discoveredData = []; + $this->restoreFiles = true; + $this->restoreDatabases = true; + $this->restoreEmails = true; + $this->restoreSsl = true; + $this->isProcessing = false; + $this->migrationLog = []; + $this->pollCount = 0; + $this->cpanel = null; + $this->connectionInfo = []; + $this->statusLog = []; + $this->analysisLog = []; + $this->isAnalyzing = false; + $this->step1Complete = false; + $this->restoreJobId = null; + $this->restoreLogPath = null; + $this->restoreStatus = null; + $this->wizardStep = null; + + $this->clearSessionCredentials(); + + $this->redirect(static::getUrl()); + } + + protected 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]; + } +} diff --git a/app/Filament/Jabali/Pages/CronJobs.php b/app/Filament/Jabali/Pages/CronJobs.php new file mode 100644 index 0000000..242a379 --- /dev/null +++ b/app/Filament/Jabali/Pages/CronJobs.php @@ -0,0 +1,508 @@ +agent === null) { + $this->agent = new AgentClient; + } + + return $this->agent; + } + + public function getUsername(): string + { + return Auth::user()->username; + } + + public function mount(): void + { + $this->loadWordPressDomains(); + } + + public function loadWordPressDomains(): void + { + try { + // Get WordPress sites from the agent + $result = $this->getAgent()->wpList($this->getUsername()); + $sites = $result['sites'] ?? []; + + // Build options array: domain_id => domain name (with path if subdirectory install) + $this->wordPressDomains = []; + foreach ($sites as $site) { + $domain = Domain::where('user_id', Auth::id()) + ->where('domain', $site['domain']) + ->first(); + + if ($domain) { + $label = $site['domain']; + if (! empty($site['path'])) { + $label .= '/'.$site['path']; + } + $this->wordPressDomains[$domain->id] = $label; + } + } + } catch (Exception $e) { + $this->wordPressDomains = []; + } + } + + public function table(Table $table): Table + { + return $table + ->query(CronJob::query()->where('user_id', Auth::id())->orderBy('created_at', 'desc')) + ->columns([ + TextColumn::make('name') + ->label(__('Job Name')) + ->icon('heroicon-o-clock') + ->iconColor('primary') + ->description(fn (CronJob $record) => $record->command) + ->searchable() + ->sortable(), + TextColumn::make('schedule') + ->label(__('Schedule')) + ->fontFamily('mono') + ->description(fn (CronJob $record) => $record->schedule_human), + TextColumn::make('type') + ->label(__('Type')) + ->badge() + ->color(fn (string $state) => $state === 'wordpress' ? 'info' : 'gray') + ->formatStateUsing(fn (string $state) => $state === 'wordpress' ? __('WordPress') : __('Custom')), + IconColumn::make('is_active') + ->label(__('Status')) + ->boolean() + ->trueIcon('heroicon-o-check-circle') + ->falseIcon('heroicon-o-pause-circle') + ->trueColor('success') + ->falseColor('gray'), + TextColumn::make('last_run_at') + ->label(__('Last Run')) + ->since() + ->description(fn (CronJob $record) => $record->last_run_status ? ($record->last_run_status === 'success' ? __('Success') : __('Failed')) : null) + ->placeholder(__('Never')) + ->sortable(), + ]) + ->recordActions([ + Action::make('run') + ->label(__('Run Now')) + ->icon('heroicon-o-play') + ->color('info') + ->action(fn (CronJob $record) => $this->runCronJob($record->id)), + Action::make('viewOutput') + ->label(__('View Output')) + ->icon('heroicon-o-document-text') + ->color('gray') + ->visible(fn (CronJob $record) => $record->last_run_at !== null) + ->modalHeading(__('Last Run Output')) + ->modalDescription(fn (CronJob $record) => __('Last run: :time - Status: :status', [ + 'time' => $record->last_run_at?->diffForHumans(), + 'status' => $record->last_run_status === 'success' ? __('Success') : __('Failed'), + ])) + ->modalIcon(fn (CronJob $record) => $record->last_run_status === 'success' ? 'heroicon-o-check-circle' : 'heroicon-o-x-circle') + ->modalIconColor(fn (CronJob $record) => $record->last_run_status === 'success' ? 'success' : 'danger') + ->modalContent(fn (CronJob $record) => view('filament.jabali.components.cron-output', ['output' => $record->last_run_output])) + ->modalSubmitAction(false) + ->modalCancelActionLabel(__('Close')), + Action::make('toggle') + ->label(fn (CronJob $record) => $record->is_active ? __('Disable') : __('Enable')) + ->icon(fn (CronJob $record) => $record->is_active ? 'heroicon-o-pause' : 'heroicon-o-play-circle') + ->color('warning') + ->action(fn (CronJob $record) => $this->toggleCronJob($record->id)), + Action::make('edit') + ->label(__('Edit')) + ->icon('heroicon-o-pencil') + ->color('gray') + ->visible(fn (CronJob $record) => $record->type === 'custom') + ->modalHeading(__('Edit Cron Job')) + ->modalDescription(fn (CronJob $record) => $record->name) + ->modalIcon('heroicon-o-pencil') + ->modalIconColor('gray') + ->modalSubmitActionLabel(__('Save Changes')) + ->fillForm(fn (CronJob $record) => [ + 'name' => $record->name, + 'schedule' => $record->schedule, + 'command' => $record->command, + ]) + ->form([ + TextInput::make('name') + ->label(__('Job Name')) + ->required() + ->maxLength(255) + ->helperText(__('A friendly name to identify this cron job')), + Select::make('schedule') + ->label(__('Schedule')) + ->options(CronJob::scheduleOptions()) + ->required() + ->searchable() + ->helperText(__('How often the command should run')), + Textarea::make('command') + ->label(__('Command')) + ->required() + ->rows(3) + ->helperText(__('The command will run as your user account')), + ]) + ->action(fn (CronJob $record, array $data) => $this->updateCronJob($record, $data)), + Action::make('delete') + ->label(__('Delete')) + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->modalHeading(__('Delete Cron Job')) + ->modalDescription(fn (CronJob $record) => __('Are you sure you want to delete')." '{$record->name}'?") + ->modalIcon('heroicon-o-trash') + ->modalIconColor('danger') + ->modalSubmitActionLabel(__('Delete Cron Job')) + ->action(fn (CronJob $record) => $this->deleteCronJob($record->id)), + ]) + ->emptyStateHeading(__('No cron jobs')) + ->emptyStateDescription(__('Get started by creating a new cron job or setting up WordPress cron.')) + ->emptyStateIcon('heroicon-o-clock') + ->striped(); + } + + public function createCronJobAction(): Action + { + return Action::make('createCronJob') + ->label(__('Add Cron Job')) + ->icon('heroicon-o-plus') + ->modalHeading(__('Create Cron Job')) + ->modalDescription(__('Schedule a command to run automatically at specified intervals')) + ->modalIcon('heroicon-o-clock') + ->modalIconColor('primary') + ->modalSubmitActionLabel(__('Create Cron Job')) + ->modalWidth('lg') + ->form([ + TextInput::make('name') + ->label(__('Job Name')) + ->required() + ->maxLength(255) + ->placeholder(__('My scheduled task')) + ->helperText(__('A friendly name to identify this cron job')), + Select::make('schedule') + ->label(__('Schedule')) + ->options(CronJob::scheduleOptions()) + ->required() + ->searchable() + ->helperText(__('How often the command should run')), + Textarea::make('command') + ->label(__('Command')) + ->required() + ->rows(3) + ->placeholder(__('php /home/user/script.php')) + ->helperText(__('The command will run as your user account')), + Placeholder::make('warning') + ->content(new HtmlString(' +
+ '.__('Warning:').' '.__('Cron jobs run automatically at scheduled times. Misconfigured jobs can:').' +
    +
  • '.__('Consume excessive server resources').'
  • +
  • '.__('Send spam emails if configured incorrectly').'
  • +
  • '.__('Cause database locks or corruption').'
  • +
  • '.__('Fill up disk space with log files').'
  • +
+

'.__('Only create cron jobs if you understand what the command does.').'

+
+ ')), + ]) + ->action(function (array $data): void { + try { + // Create in database - Laravel scheduler will handle execution + CronJob::create([ + 'user_id' => Auth::id(), + 'name' => $data['name'], + 'schedule' => $data['schedule'], + 'command' => $data['command'], + 'type' => 'custom', + 'is_active' => true, + ]); + + Notification::make() + ->title(__('Cron job created')) + ->body(__('The job will run according to its schedule.')) + ->success() + ->send(); + } catch (Exception $e) { + Notification::make() + ->title(__('Error creating cron job')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + }); + } + + public function setupWordPressCronAction(): Action + { + return Action::make('setupWordPressCron') + ->label(__('Setup WordPress Cron')) + ->icon('heroicon-o-bolt') + ->color('info') + ->modalHeading(__('Setup WordPress Cron')) + ->modalDescription(__('Replace WordPress\'s built-in cron with a real server cron job for better reliability and performance')) + ->modalIcon('heroicon-o-bolt') + ->modalIconColor('info') + ->modalSubmitActionLabel(__('Setup WordPress Cron')) + ->modalWidth('lg') + ->form([ + Select::make('domain_id') + ->label(__('WordPress Site')) + ->options($this->wordPressDomains) + ->required() + ->searchable() + ->placeholder(__('Select a WordPress site')) + ->helperText(__('Select the WordPress site to enable server-side cron for')), + Select::make('schedule') + ->label(__('Run Frequency')) + ->options([ + '*/5 * * * *' => __('Every 5 minutes (Recommended)'), + '*/10 * * * *' => __('Every 10 minutes'), + '*/15 * * * *' => __('Every 15 minutes'), + '*/30 * * * *' => __('Every 30 minutes'), + '0 * * * *' => __('Every hour'), + ]) + ->default('*/5 * * * *') + ->required() + ->helperText(__('How often WordPress scheduled tasks should run')), + Placeholder::make('info') + ->content(new HtmlString(' +
+ '.__('What this does:').' +
    +
  • '.__('Creates a server cron job to run').' wp-cron.php
  • +
  • '.__('Adds').' define(\'DISABLE_WP_CRON\', true); '.__('to your').' wp-config.php
  • +
  • '.__('Improves scheduled task reliability (posts, backups, updates)').'
  • +
  • '.__('Reduces page load times by removing cron checks from visitors').'
  • +
+
+ ')), + ]) + ->action(function (array $data): void { + try { + $domain = Domain::findOrFail($data['domain_id']); + $username = $this->getUsername(); + + // Add DISABLE_WP_CRON to wp-config.php + $result = $this->getAgent()->cronWordPressSetup( + $username, + $domain->domain, + $data['schedule'], + false // enable - adds DISABLE_WP_CRON to wp-config + ); + + if (! $result['success']) { + throw new Exception($result['error'] ?? __('Failed to setup WordPress cron')); + } + + // Save to database - Laravel scheduler will handle execution + $command = "cd /home/{$username}/domains/{$domain->domain}/public_html && /usr/bin/php wp-cron.php"; + + CronJob::updateOrCreate( + [ + 'user_id' => Auth::id(), + 'type' => 'wordpress', + 'metadata->domain_id' => $domain->id, + ], + [ + 'name' => "WordPress Cron - {$domain->domain}", + 'schedule' => $data['schedule'], + 'command' => $command, + 'is_active' => true, + 'metadata' => ['domain_id' => $domain->id, 'domain' => $domain->domain], + ] + ); + + Notification::make() + ->title(__('WordPress cron enabled')) + ->body(__('Server cron is now handling scheduled tasks for :domain', ['domain' => $domain->domain])) + ->success() + ->send(); + } catch (Exception $e) { + Notification::make() + ->title(__('Error setting up WordPress cron')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + }); + } + + public function deleteCronJob(int $id): void + { + try { + $cronJob = CronJob::where('user_id', Auth::id())->findOrFail($id); + + // If it's a WordPress cron, remove the wp-config DISABLE_WP_CRON constant + if ($cronJob->type === 'wordpress' && isset($cronJob->metadata['domain'])) { + $this->getAgent()->cronWordPressSetup( + $this->getUsername(), + $cronJob->metadata['domain'], + $cronJob->schedule, + true // disable - removes DISABLE_WP_CRON from wp-config + ); + } + + // Delete from database - Laravel scheduler will stop running it + $cronJob->delete(); + + Notification::make() + ->title(__('Cron job deleted')) + ->success() + ->send(); + } catch (Exception $e) { + Notification::make() + ->title(__('Error deleting cron job')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function updateCronJob(CronJob $cronJob, array $data): void + { + try { + // Update database - Laravel scheduler uses these values + $cronJob->update([ + 'name' => $data['name'], + 'schedule' => $data['schedule'], + 'command' => $data['command'], + ]); + + Notification::make() + ->title(__('Cron job updated')) + ->success() + ->send(); + } catch (Exception $e) { + Notification::make() + ->title(__('Error updating cron job')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function toggleCronJob(int $id): void + { + try { + $cronJob = CronJob::where('user_id', Auth::id())->findOrFail($id); + $newState = ! $cronJob->is_active; + + // Update database - Laravel scheduler checks is_active + $cronJob->update(['is_active' => $newState]); + + Notification::make() + ->title($newState ? __('Cron job enabled') : __('Cron job disabled')) + ->success() + ->send(); + } catch (Exception $e) { + Notification::make() + ->title(__('Error toggling cron job')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function runCronJob(int $id): void + { + try { + $cronJob = CronJob::where('user_id', Auth::id())->findOrFail($id); + + $result = $this->getAgent()->cronRun( + $this->getUsername(), + $cronJob->command + ); + + // Update last run info + $cronJob->update([ + 'last_run_at' => now(), + 'last_run_status' => $result['success'] ? 'success' : 'failed', + 'last_run_output' => $result['output'] ?? null, + ]); + + if ($result['success']) { + Notification::make() + ->title(__('Cron job executed')) + ->body(__('Command completed successfully')) + ->success() + ->send(); + } else { + Notification::make() + ->title(__('Cron job failed')) + ->body($result['output'] ?? __('Command failed')) + ->warning() + ->send(); + } + } catch (Exception $e) { + Notification::make() + ->title(__('Error running cron job')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + protected function getHeaderActions(): array + { + return [ + $this->getTourAction(), + $this->createCronJobAction(), + $this->setupWordPressCronAction(), + ]; + } +} diff --git a/app/Filament/Jabali/Pages/Dashboard.php b/app/Filament/Jabali/Pages/Dashboard.php new file mode 100644 index 0000000..1730343 --- /dev/null +++ b/app/Filament/Jabali/Pages/Dashboard.php @@ -0,0 +1,62 @@ + 1, + 'sm' => 1, + 'md' => 2, + 'lg' => 2, + 'xl' => 2, + ]; + } + + public function getSubheading(): ?string + { + $user = Auth::user(); + + return __('Welcome back, :name! Here is an overview of your hosting account.', [ + 'name' => $user->name, + ]); + } + + public static function getNavigationLabel(): string + { + return __('Dashboard'); + } + + public function getTitle(): string + { + return __('Dashboard'); + } +} diff --git a/app/Filament/Jabali/Pages/Databases.php b/app/Filament/Jabali/Pages/Databases.php new file mode 100644 index 0000000..474c5fa --- /dev/null +++ b/app/Filament/Jabali/Pages/Databases.php @@ -0,0 +1,966 @@ +agent === null) { + $this->agent = new AgentClient; + } + + return $this->agent; + } + + public function getUsername(): string + { + return Auth::user()->username; + } + + public function mount(): void + { + $this->ensureAdminUserExists(); + $this->loadData(); + } + + /** + * Ensure the master admin MySQL user exists for this user. + * This user has access to all {username}_* databases and is used for phpMyAdmin SSO. + */ + protected function ensureAdminUserExists(): void + { + $adminUsername = $this->getUsername().'_admin'; + + // Check if we already have stored credentials for the admin user + $credential = MysqlCredential::where('user_id', Auth::id()) + ->where('mysql_username', $adminUsername) + ->first(); + + if ($credential) { + return; // Admin user credentials exist + } + + // Generate secure password + $password = $this->generateSecurePassword(24); + + try { + // Try to create the admin user + $this->getAgent()->mysqlCreateUser($this->getUsername(), $adminUsername, $password); + } catch (Exception $e) { + // User might already exist, try to change password instead + try { + $this->getAgent()->mysqlChangePassword($this->getUsername(), $adminUsername, $password); + } catch (Exception $e2) { + // Can't create or update user + return; + } + } + + try { + // Grant privileges on all user's databases (using wildcard pattern) + $wildcardDb = $this->getUsername().'_%'; + $this->getAgent()->mysqlGrantPrivileges($this->getUsername(), $adminUsername, $wildcardDb, ['ALL']); + + // Store credentials + MysqlCredential::updateOrCreate( + [ + 'user_id' => Auth::id(), + 'mysql_username' => $adminUsername, + ], + [ + 'mysql_password_encrypted' => Crypt::encryptString($password), + ] + ); + } catch (Exception $e) { + // Grant failed + } + } + + public function loadData(): void + { + try { + $result = $this->getAgent()->mysqlListDatabases($this->getUsername()); + $this->databases = $result['databases'] ?? []; + } catch (Exception $e) { + $this->databases = []; + Notification::make() + ->title(__('Error loading databases')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + + try { + $result = $this->getAgent()->mysqlListUsers($this->getUsername()); + $this->users = $result['users'] ?? []; + + // Filter out the master admin user from display + $this->users = array_filter($this->users, function ($user) { + return $user['user'] !== $this->getUsername().'_admin'; + }); + + $this->userGrants = []; + foreach ($this->users as $user) { + $this->loadUserGrants($user['user'], $user['host']); + } + } catch (Exception $e) { + $this->users = []; + } + } + + protected function loadUserGrants(string $user, string $host): void + { + try { + $result = $this->getAgent()->mysqlGetPrivileges($this->getUsername(), $user, $host); + $this->userGrants["$user@$host"] = $result['parsed'] ?? []; + } catch (Exception $e) { + $this->userGrants["$user@$host"] = []; + } + } + + public function getUserGrantsForDisplay(string $user, string $host): array + { + return $this->userGrants["$user@$host"] ?? []; + } + + public function table(Table $table): Table + { + return $table + ->records(fn () => $this->databases) + ->columns([ + TextColumn::make('name') + ->label(__('Database Name')) + ->icon('heroicon-o-circle-stack') + ->iconColor('warning') + ->weight('medium') + ->searchable(), + TextColumn::make('size_human') + ->label(__('Size')) + ->badge() + ->color(fn (array $record): string => match (true) { + ($record['size_bytes'] ?? 0) > 1073741824 => 'danger', // > 1GB + ($record['size_bytes'] ?? 0) > 104857600 => 'warning', // > 100MB + default => 'gray', + }) + ->sortable(query: fn ($query, $direction) => $query), + ]) + ->recordActions([ + Action::make('phpMyAdmin') + ->label(__('phpMyAdmin')) + ->icon('heroicon-o-circle-stack') + ->color('info') + ->action(function (array $record): void { + $url = $this->getPhpMyAdminUrl($record['name']); + if ($url) { + $this->js("window.open('".addslashes($url)."', '_blank')"); + } else { + Notification::make() + ->title(__('Cannot open phpMyAdmin')) + ->body(__('No database credentials found. Create a user first.')) + ->warning() + ->send(); + } + }), + Action::make('backup') + ->label(__('Backup')) + ->icon('heroicon-o-arrow-down-tray') + ->color('success') + ->modalHeading(__('Backup Database')) + ->modalDescription(fn (array $record): string => __("Create a backup of ':database'", ['database' => $record['name']])) + ->modalIcon('heroicon-o-arrow-down-tray') + ->modalIconColor('success') + ->modalSubmitActionLabel(__('Create Backup')) + ->form([ + Radio::make('format') + ->label(__('Backup Format')) + ->options([ + 'gz' => __('Gzip (.sql.gz) - Recommended'), + 'zip' => __('Zip (.zip)'), + 'none' => __('Plain SQL (.sql)'), + ]) + ->default('gz') + ->required(), + ]) + ->action(function (array $record, array $data): void { + $this->backupDatabase($record['name'], $data['format'] ?? 'gz'); + }), + Action::make('restore') + ->label(__('Restore')) + ->icon('heroicon-o-arrow-up-tray') + ->color('warning') + ->requiresConfirmation() + ->modalHeading(__('Restore Database')) + ->modalDescription(fn (array $record): string => __("This will overwrite all data in ':database'. Make sure you have a backup.", ['database' => $record['name']])) + ->modalIcon('heroicon-o-exclamation-triangle') + ->modalIconColor('warning') + ->modalSubmitActionLabel(__('Restore')) + ->form([ + FileUpload::make('sql_file') + ->label(__('Backup File')) + ->required() + ->maxSize(512000) // 500MB (compressed files can be larger) + ->disk('local') + ->directory('temp/sql-uploads') + ->helperText(__('Supported formats: .sql, .sql.gz, .gz, .zip (max 500MB)')), + ]) + ->action(function (array $record, array $data): void { + $this->restoreDatabase($record['name'], $data['sql_file']); + }), + Action::make('delete') + ->label(__('Delete')) + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->modalHeading(__('Delete Database')) + ->modalDescription(fn (array $record): string => __("Delete ':database'? All data will be permanently lost.", ['database' => $record['name']])) + ->modalIcon('heroicon-o-trash') + ->modalIconColor('danger') + ->modalSubmitActionLabel(__('Delete Database')) + ->action(function (array $record): void { + try { + $this->getAgent()->mysqlDeleteDatabase($this->getUsername(), $record['name']); + Notification::make()->title(__('Database deleted'))->success()->send(); + $this->loadData(); + $this->resetTable(); + } catch (Exception $e) { + Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); + } + }), + ]) + ->emptyStateHeading(__('No databases yet')) + ->emptyStateDescription(__('Click "Quick Setup" or "New Database" to create one')) + ->emptyStateIcon('heroicon-o-circle-stack') + ->striped(); + } + + public function getTableRecordKey(Model|array $record): string + { + return is_array($record) ? $record['name'] : $record->getKey(); + } + + public function generateSecurePassword(int $length = 16): string + { + $lowercase = 'abcdefghijklmnopqrstuvwxyz'; + $uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + $numbers = '0123456789'; + $special = '!@#$%^&*'; + + // Ensure at least one of each required type + $password = $lowercase[random_int(0, strlen($lowercase) - 1)] + .$uppercase[random_int(0, strlen($uppercase) - 1)] + .$numbers[random_int(0, strlen($numbers) - 1)] + .$special[random_int(0, strlen($special) - 1)]; + + // Fill the rest with random characters from all types + $allChars = $lowercase.$uppercase.$numbers.$special; + for ($i = strlen($password); $i < $length; $i++) { + $password .= $allChars[random_int(0, strlen($allChars) - 1)]; + } + + // Shuffle the password to randomize position of required characters + return str_shuffle($password); + } + + /** + * Generate phpMyAdmin URL for a specific database + */ + public function getPhpMyAdminUrl(string $database): ?string + { + try { + $adminUsername = $this->getUsername().'_admin'; + + // Get the master admin user credential + $credential = MysqlCredential::where('user_id', Auth::id()) + ->where('mysql_username', $adminUsername) + ->first(); + + // Fallback to any credential if admin not found + if (! $credential) { + $credential = MysqlCredential::where('user_id', Auth::id())->first(); + } + + if (! $credential) { + // Try to create the admin user if it doesn't exist + $this->ensureAdminUserExists(); + $credential = MysqlCredential::where('user_id', Auth::id()) + ->where('mysql_username', $adminUsername) + ->first(); + } + + if (! $credential) { + return null; + } + + // Generate token + $token = bin2hex(random_bytes(32)); + + // Store token data in cache for 5 minutes + Cache::put('phpmyadmin_token_'.$token, [ + 'username' => $credential->mysql_username, + 'password' => Crypt::decryptString($credential->mysql_password_encrypted), + 'database' => $database, + ], now()->addMinutes(5)); + + return request()->getSchemeAndHttpHost().'/phpmyadmin/jabali-signon.php?token='.$token.'&db='.urlencode($database); + + } catch (Exception $e) { + return null; + } + } + + protected function getHeaderActions(): array + { + return [ + $this->getTourAction(), + $this->quickSetupAction(), + $this->createDatabaseAction(), + $this->createUserAction(), + $this->showCredentialsAction(), + ]; + } + + protected function showCredentialsAction(): Action + { + return Action::make('showCredentials') + ->label(__('Credentials')) + ->hidden() + ->modalHeading(__('Database Credentials')) + ->modalDescription(__('Save these credentials! The password won\'t be shown again.')) + ->modalIcon('heroicon-o-check-circle') + ->modalIconColor('success') + ->modalSubmitAction(false) + ->modalCancelActionLabel(__('Done')) + ->infolist([ + Section::make(__('Database')) + ->hidden(fn () => empty($this->credDatabase)) + ->schema([ + TextEntry::make('database') + ->hiddenLabel() + ->state(fn () => $this->credDatabase) + ->copyable() + ->fontFamily('mono'), + ]), + Section::make(__('Username')) + ->hidden(fn () => empty($this->credUser)) + ->schema([ + TextEntry::make('username') + ->hiddenLabel() + ->state(fn () => $this->credUser) + ->copyable() + ->fontFamily('mono'), + ]), + Section::make(__('Password')) + ->schema([ + TextEntry::make('password') + ->hiddenLabel() + ->state(fn () => $this->credPassword) + ->copyable() + ->fontFamily('mono'), + ]), + ]); + } + + protected function quickSetupAction(): Action + { + return Action::make('quickSetup') + ->label(__('Quick Setup')) + ->icon('heroicon-o-bolt') + ->color('warning') + ->modalHeading(__('Quick Database Setup')) + ->modalDescription(__('Create a database and user with full access in one step')) + ->modalIcon('heroicon-o-bolt') + ->modalIconColor('warning') + ->modalSubmitActionLabel(__('Create Database & User')) + ->form([ + TextInput::make('name') + ->label(__('Database & User Name')) + ->required() + ->alphaNum() + ->maxLength(20) + ->prefix($this->getUsername().'_') + ->helperText(__('This name will be used for both the database and user')), + ]) + ->action(function (array $data): void { + $name = $this->getUsername().'_'.$data['name']; + $password = $this->generateSecurePassword(); + + try { + // Create database + $this->getAgent()->mysqlCreateDatabase($this->getUsername(), $name); + + // Create user with same name + $result = $this->getAgent()->mysqlCreateUser($this->getUsername(), $name, $password); + + // Grant all privileges + $this->getAgent()->mysqlGrantPrivileges($this->getUsername(), $name, $name, ['ALL']); + + // Store credentials + MysqlCredential::updateOrCreate( + [ + 'user_id' => Auth::id(), + 'mysql_username' => $name, + ], + [ + 'mysql_password_encrypted' => Crypt::encryptString($password), + ] + ); + + $this->credDatabase = $name; + $this->credUser = $name; + $this->credPassword = $password; + + Notification::make()->title(__('Database & User Created!'))->success()->send(); + $this->loadData(); + $this->resetTable(); + $this->dispatch('refresh-database-users'); + + $this->mountAction('showCredentials'); + } catch (Exception $e) { + Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); + } + }); + } + + protected function createDatabaseAction(): Action + { + return Action::make('createDatabase') + ->label(__('New Database')) + ->icon('heroicon-o-plus-circle') + ->color('success') + ->modalHeading(__('Create New Database')) + ->modalDescription(__('Create a new MySQL database')) + ->modalIcon('heroicon-o-circle-stack') + ->modalIconColor('success') + ->modalSubmitActionLabel(__('Create Database')) + ->form([ + TextInput::make('name') + ->label(__('Database Name')) + ->required() + ->alphaNum() + ->maxLength(32) + ->prefix($this->getUsername().'_') + ->helperText(__('Only alphanumeric characters allowed')), + ]) + ->action(function (array $data): void { + $name = $this->getUsername().'_'.$data['name']; + try { + $this->getAgent()->mysqlCreateDatabase($this->getUsername(), $name); + Notification::make()->title(__('Database created'))->success()->send(); + $this->loadData(); + $this->resetTable(); + $this->dispatch('refresh-database-users'); + } catch (Exception $e) { + Notification::make()->title(__('Error creating database'))->body($e->getMessage())->danger()->send(); + } + }); + } + + protected function createUserAction(): Action + { + return Action::make('createUser') + ->label(__('New User')) + ->icon('heroicon-o-user-plus') + ->color('primary') + ->modalHeading(__('Create New Database User')) + ->modalDescription(__('Create a new MySQL user for database access')) + ->modalIcon('heroicon-o-user-plus') + ->modalIconColor('primary') + ->modalSubmitActionLabel(__('Create User')) + ->form([ + TextInput::make('username') + ->label(__('Username')) + ->required() + ->alphaNum() + ->maxLength(20) + ->prefix($this->getUsername().'_') + ->helperText(__('Only alphanumeric characters allowed')), + TextInput::make('password') + ->label(__('Password')) + ->password() + ->revealable() + ->required() + ->minLength(8) + ->rules([ + 'regex:/[a-z]/', // lowercase + 'regex:/[A-Z]/', // uppercase + 'regex:/[0-9]/', // number + ]) + ->default(fn () => $this->generateSecurePassword()) + ->suffixActions([ + Action::make('generatePassword') + ->icon('heroicon-o-arrow-path') + ->tooltip(__('Generate secure password')) + ->action(fn ($set) => $set('password', $this->generateSecurePassword())), + Action::make('copyPassword') + ->icon('heroicon-o-clipboard-document') + ->tooltip(__('Copy to clipboard')) + ->action(function ($state, $livewire) { + if ($state) { + $escaped = addslashes($state); + $livewire->js("navigator.clipboard.writeText('{$escaped}')"); + Notification::make() + ->title(__('Copied to clipboard')) + ->success() + ->duration(2000) + ->send(); + } + }), + ]) + ->helperText(__('Minimum 8 characters with uppercase, lowercase, and numbers')), + ]) + ->action(function (array $data): void { + try { + $result = $this->getAgent()->mysqlCreateUser( + $this->getUsername(), + $data['username'], + $data['password'] + ); + + // Store credentials + MysqlCredential::updateOrCreate( + [ + 'user_id' => Auth::id(), + 'mysql_username' => $result['db_user'], + ], + [ + 'mysql_password_encrypted' => Crypt::encryptString($data['password']), + ] + ); + + $this->credDatabase = ''; + $this->credUser = $result['db_user']; + $this->credPassword = $data['password']; + + Notification::make()->title(__('User created'))->success()->send(); + $this->loadData(); + $this->resetTable(); + $this->dispatch('refresh-database-users'); + + $this->mountAction('showCredentials'); + } catch (Exception $e) { + Notification::make()->title(__('Error creating user'))->body($e->getMessage())->danger()->send(); + } + }); + } + + public function deleteUser(string $user, string $host): void + { + $this->selectedUser = "$user@$host"; + $this->mountAction('deleteUserAction'); + } + + public function deleteUserAction(): Action + { + return Action::make('deleteUserAction') + ->requiresConfirmation() + ->modalHeading(__('Delete User')) + ->modalDescription(fn () => __("Delete user ':user'? This action cannot be undone.", ['user' => $this->selectedUser])) + ->modalIcon('heroicon-o-trash') + ->modalIconColor('danger') + ->modalSubmitActionLabel(__('Delete User')) + ->color('danger') + ->action(function (): void { + [$user, $host] = explode('@', $this->selectedUser); + try { + $this->getAgent()->mysqlDeleteUser($this->getUsername(), $user, $host); + + // Delete stored credentials + MysqlCredential::where('user_id', Auth::id()) + ->where('mysql_username', $user) + ->delete(); + + Notification::make()->title(__('User deleted'))->success()->send(); + $this->loadData(); + } catch (Exception $e) { + Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); + } + }); + } + + public function changePassword(string $user, string $host): void + { + $this->selectedUser = "$user@$host"; + $this->mountAction('changePasswordAction'); + } + + public function changePasswordAction(): Action + { + return Action::make('changePasswordAction') + ->modalHeading(__('Change Password')) + ->modalDescription(fn () => $this->selectedUser) + ->modalIcon('heroicon-o-key') + ->modalIconColor('warning') + ->modalSubmitActionLabel(__('Change Password')) + ->form([ + TextInput::make('password') + ->label(__('New Password')) + ->password() + ->revealable() + ->required() + ->minLength(8) + ->rules([ + 'regex:/[a-z]/', // lowercase + 'regex:/[A-Z]/', // uppercase + 'regex:/[0-9]/', // number + ]) + ->default(fn () => $this->generateSecurePassword()) + ->suffixActions([ + Action::make('generatePassword') + ->icon('heroicon-o-arrow-path') + ->tooltip(__('Generate secure password')) + ->action(fn ($set) => $set('password', $this->generateSecurePassword())), + Action::make('copyPassword') + ->icon('heroicon-o-clipboard-document') + ->tooltip(__('Copy to clipboard')) + ->action(function ($state, $livewire) { + if ($state) { + $escaped = addslashes($state); + $livewire->js("navigator.clipboard.writeText('{$escaped}')"); + Notification::make() + ->title(__('Copied to clipboard')) + ->success() + ->duration(2000) + ->send(); + } + }), + ]) + ->helperText(__('Minimum 8 characters with uppercase, lowercase, and numbers')), + ]) + ->action(function (array $data): void { + [$user, $host] = explode('@', $this->selectedUser); + try { + $this->getAgent()->mysqlChangePassword($this->getUsername(), $user, $data['password'], $host); + + // Update stored MySQL credentials + MysqlCredential::updateOrCreate( + [ + 'user_id' => Auth::id(), + 'mysql_username' => $user, + ], + [ + 'mysql_password_encrypted' => Crypt::encryptString($data['password']), + ] + ); + + $this->credDatabase = ''; + $this->credUser = $user; + $this->credPassword = $data['password']; + + Notification::make()->title(__('Password changed'))->success()->send(); + + $this->mountAction('showCredentials'); + } catch (Exception $e) { + Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); + } + }); + } + + public function addPrivileges(string $user, string $host): void + { + $this->selectedUser = "$user@$host"; + $this->mountAction('addPrivilegesAction'); + } + + public function addPrivilegesAction(): Action + { + $dbOptions = []; + foreach ($this->databases as $db) { + $dbOptions[$db['name']] = $db['name']; + } + + return Action::make('addPrivilegesAction') + ->modalHeading(__('Add Database Access')) + ->modalDescription(fn () => __('Grant privileges to :user', ['user' => $this->selectedUser])) + ->modalIcon('heroicon-o-shield-check') + ->modalIconColor('success') + ->modalWidth('lg') + ->modalSubmitActionLabel(__('Grant Access')) + ->form([ + Select::make('database') + ->label(__('Database')) + ->options($dbOptions) + ->required() + ->searchable() + ->placeholder(__('Select a database...')) + ->helperText(__('Choose which database to grant access to')) + ->live(), + + Radio::make('privilege_type') + ->label(__('Privilege Type')) + ->options([ + 'all' => __('ALL PRIVILEGES (full access)'), + 'specific' => __('Specific privileges'), + ]) + ->default('all') + ->required() + ->live() + ->disabled(fn (callable $get): bool => empty($get('database'))) + ->helperText(__('ALL PRIVILEGES grants complete control over the database')), + + CheckboxList::make('specific_privileges') + ->label(__('Select Privileges')) + ->options([ + 'SELECT' => __('SELECT - Read data'), + 'INSERT' => __('INSERT - Add new data'), + 'UPDATE' => __('UPDATE - Modify existing data'), + 'DELETE' => __('DELETE - Remove data'), + 'CREATE' => __('CREATE - Create tables'), + 'DROP' => __('DROP - Delete tables'), + 'INDEX' => __('INDEX - Manage indexes'), + 'ALTER' => __('ALTER - Modify table structure'), + ]) + ->columns(2) + ->visible(fn (callable $get): bool => $get('privilege_type') === 'specific' && ! empty($get('database'))), + ]) + ->action(function (array $data): void { + [$user, $host] = explode('@', $this->selectedUser); + + $privilegeType = $data['privilege_type'] ?? 'all'; + + if ($privilegeType === 'specific' && ! empty($data['specific_privileges'])) { + $privs = $data['specific_privileges']; + } else { + $privs = ['ALL']; + } + + try { + $this->getAgent()->mysqlGrantPrivileges($this->getUsername(), $user, $data['database'], $privs, $host); + + $privDisplay = ($privilegeType === 'all') ? __('ALL PRIVILEGES') : implode(', ', $privs); + Notification::make() + ->title(__('Privileges granted')) + ->body(__('Granted :privileges on :database', ['privileges' => $privDisplay, 'database' => $data['database']])) + ->success() + ->send(); + $this->loadData(); + } catch (Exception $e) { + Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); + } + }); + } + + public function revokePrivileges(string $user, string $host, string $database): void + { + try { + $this->getAgent()->mysqlRevokePrivileges($this->getUsername(), $user, $database, $host); + Notification::make()->title(__('Access revoked'))->success()->send(); + $this->loadData(); + } catch (Exception $e) { + Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); + } + } + + public function backupDatabase(string $database, string $compress = 'gz'): void + { + try { + // Determine file extension based on compression type + $extension = match ($compress) { + 'gz' => '.sql.gz', + 'zip' => '.zip', + default => '.sql', + }; + + $filename = $database.'_'.date('Y-m-d_His').$extension; + $outputPath = '/home/'.$this->getUsername().'/backups/'.$filename; + + $result = $this->getAgent()->mysqlExportDatabase($this->getUsername(), $database, $outputPath, $compress); + + if ($result['success'] ?? false) { + // Store the backup path for download + $this->lastBackupPath = 'backups/'.$filename; + + Notification::make() + ->title(__('Backup created')) + ->body(__('File: backups/:filename', ['filename' => $filename])) + ->success() + ->actions([ + \Filament\Actions\Action::make('download') + ->label(__('Download')) + ->icon('heroicon-o-arrow-down-tray') + ->button() + ->dispatch('download-backup', ['path' => 'backups/'.$filename]), + \Filament\Actions\Action::make('view_files') + ->label(__('Open in Files')) + ->icon('heroicon-o-folder-open') + ->url(route('filament.jabali.pages.files').'?path=backups') + ->openUrlInNewTab(), + ]) + ->persistent() + ->send(); + } else { + throw new Exception($result['error'] ?? __('Unknown error')); + } + } catch (Exception $e) { + Notification::make() + ->title(__('Backup failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public string $lastBackupPath = ''; + + #[\Livewire\Attributes\On('download-backup')] + public function onDownloadBackup(string $path): void + { + $this->downloadBackup($path); + } + + public function downloadBackup(string $path): void + { + try { + $result = $this->getAgent()->fileRead($this->getUsername(), $path); + $this->dispatch('download-backup-file', + content: $result['content'], + filename: basename($path) + ); + } catch (Exception $e) { + Notification::make() + ->title(__('Download failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function restoreDatabase(string $database, $uploadedFile): void + { + try { + // Handle array or string file path from FileUpload + $relativePath = is_array($uploadedFile) ? ($uploadedFile[0] ?? '') : $uploadedFile; + + if (empty($relativePath)) { + throw new Exception(__('No file uploaded')); + } + + // Get the full path using Storage facade (handles Laravel 11+ private storage) + $storage = \Illuminate\Support\Facades\Storage::disk('local'); + + if ($storage->exists($relativePath)) { + $filePath = $storage->path($relativePath); + } else { + // Try direct path + $filePath = storage_path('app/'.$relativePath); + if (! file_exists($filePath)) { + $filePath = storage_path('app/private/'.$relativePath); + } + } + + if (! file_exists($filePath)) { + throw new Exception(__('Uploaded file not found')); + } + + // Validate file extension - allow .sql, .sql.gz, .gz, .zip + $lowerPath = strtolower($relativePath); + $validExtensions = ['.sql', '.sql.gz', '.gz', '.zip']; + $isValid = false; + foreach ($validExtensions as $ext) { + if (str_ends_with($lowerPath, $ext)) { + $isValid = true; + break; + } + } + + if (! $isValid) { + $storage->delete($relativePath); + throw new Exception(__('Invalid file type. Supported: .sql, .sql.gz, .gz, .zip')); + } + + $result = $this->getAgent()->mysqlImportDatabase($this->getUsername(), $database, $filePath); + + // Clean up the uploaded file + $storage->delete($relativePath); + + if ($result['success'] ?? false) { + Notification::make() + ->title(__('Database restored')) + ->body(__('Successfully restored :database', ['database' => $database])) + ->success() + ->send(); + + $this->loadData(); + $this->resetTable(); + } else { + throw new Exception($result['error'] ?? __('Unknown error')); + } + } catch (Exception $e) { + Notification::make() + ->title(__('Restore failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } +} diff --git a/app/Filament/Jabali/Pages/DnsRecords.php b/app/Filament/Jabali/Pages/DnsRecords.php new file mode 100644 index 0000000..e3e5498 --- /dev/null +++ b/app/Filament/Jabali/Pages/DnsRecords.php @@ -0,0 +1,799 @@ +agent ??= new AgentClient; + } + + public function mount(): void + { + $this->selectedDomainId = null; + } + + public function form(Schema $form): Schema + { + return $form + ->schema([ + Select::make('selectedDomainId') + ->label(__('Select Domain')) + ->options(fn () => Domain::where('user_id', Auth::id())->orderBy('domain')->pluck('domain', 'id')->toArray()) + ->searchable() + ->preload() + ->live() + ->afterStateUpdated(fn () => $this->onDomainChange()) + ->placeholder(__('Select a domain to manage DNS records')), + ]); + } + + public function content(Schema $schema): Schema + { + return $schema->schema([ + Section::make(__('Select Domain')) + ->description(__('Choose a domain to manage its DNS records.')) + ->schema([ + EmbeddedSchema::make('form'), + ]) + ->visible(fn () => $this->hasDomains()), + Section::make(__('New Records to Add')) + ->description(__('These records will be created when you save changes.')) + ->icon('heroicon-o-plus-circle') + ->iconColor('success') + ->collapsible() + ->schema([ + EmbeddedTable::make(DnsPendingAddsTable::class, fn () => [ + 'records' => $this->pendingAdds, + ]), + ]) + ->headerActions([ + Action::make('clearPending') + ->label(__('Clear All')) + ->icon('heroicon-o-trash') + ->color('danger') + ->size('sm') + ->requiresConfirmation() + ->action(fn () => $this->clearPendingAdds()), + ]) + ->visible(fn () => $this->selectedDomainId !== null && count($this->pendingAdds) > 0), + Section::make(__('Important')) + ->description(__('Incorrect DNS changes can make your website or email unreachable. Changes may take up to 48 hours to propagate globally.')) + ->icon('heroicon-o-exclamation-triangle') + ->iconColor('warning') + ->compact() + ->visible(fn () => $this->selectedDomainId !== null), + Section::make(__('Unsaved Changes')) + ->description(fn () => __('You have :count pending change(s). Click Save to apply them.', ['count' => $this->getPendingChangesCount()])) + ->icon('heroicon-o-clock') + ->iconColor('info') + ->compact() + ->visible(fn () => $this->selectedDomainId !== null && $this->hasPendingChanges()), + EmbeddedTable::make() + ->visible(fn () => $this->selectedDomainId !== null), + EmptyState::make(__('No Domain Selected')) + ->description(__('Select a domain from the dropdown above to view and manage its DNS records.')) + ->icon('heroicon-o-cursor-arrow-rays') + ->iconColor('gray') + ->visible(fn () => $this->hasDomains() && $this->selectedDomainId === null), + EmptyState::make(__('No Domains Found')) + ->description(__('You need to add a domain before you can manage DNS records.')) + ->icon('heroicon-o-globe-alt') + ->iconColor('gray') + ->footer([ + Action::make('addDomain') + ->label(__('Add Domain')) + ->icon('heroicon-o-plus') + ->url(route('filament.jabali.pages.domains')), + ]) + ->visible(fn () => ! $this->hasDomains()), + ]); + } + + public function onDomainChange(): void + { + $this->pendingEdits = []; + $this->pendingDeletes = []; + $this->pendingAdds = []; + $this->resetTable(); + } + + public function updatedSelectedDomainId(): void + { + $this->onDomainChange(); + } + + public function hasPendingChanges(): bool + { + return count($this->pendingEdits) > 0 || count($this->pendingDeletes) > 0 || count($this->pendingAdds) > 0; + } + + public function getPendingChangesCount(): int + { + return count($this->pendingEdits) + count($this->pendingDeletes) + count($this->pendingAdds); + } + + public function isRecordPendingDelete(int $recordId): bool + { + return in_array($recordId, $this->pendingDeletes); + } + + public function isRecordPendingEdit(int $recordId): bool + { + return isset($this->pendingEdits[$recordId]); + } + + public function clearPendingAdds(): void + { + $this->pendingAdds = []; + Notification::make()->title(__('Pending records cleared'))->success()->send(); + } + + #[On('dns-pending-add-remove')] + public function removePendingAddFromTable(string $key): void + { + $this->removePendingAdd($key); + } + + public function removePendingAdd(int|string $identifier): void + { + if (is_int($identifier)) { + unset($this->pendingAdds[$identifier]); + $this->pendingAdds = array_values($this->pendingAdds); + Notification::make()->title(__('Pending record removed'))->success()->send(); + + return; + } + + $this->pendingAdds = array_values(array_filter( + $this->pendingAdds, + fn (array $record): bool => ($record['key'] ?? null) !== $identifier + )); + Notification::make()->title(__('Pending record removed'))->success()->send(); + } + + protected function queuePendingAdd(array $record): void + { + $record['key'] ??= (string) Str::uuid(); + $this->pendingAdds[] = $record; + } + + protected function sanitizePendingAdd(array $record): array + { + unset($record['key']); + + return $record; + } + + protected function hasDomains(): bool + { + return Domain::query()->where('user_id', Auth::id())->exists(); + } + + public function table(Table $table): Table + { + return $table + ->query( + DnsRecord::query() + ->whereHas('domain', fn (Builder $query) => $query->where('user_id', Auth::id())) + ->when($this->selectedDomainId, fn (Builder $query) => $query->where('domain_id', $this->selectedDomainId)) + ->orderByRaw("CASE type + WHEN 'NS' THEN 1 + WHEN 'A' THEN 2 + WHEN 'AAAA' THEN 3 + WHEN 'CNAME' THEN 4 + WHEN 'MX' THEN 5 + WHEN 'TXT' THEN 6 + WHEN 'SRV' THEN 7 + WHEN 'CAA' THEN 8 + ELSE 9 END") + ->orderBy('name') + ) + ->columns([ + TextColumn::make('type') + ->label(__('Type')) + ->badge() + ->color(fn (DnsRecord $record) => $this->isRecordPendingDelete($record->id) ? 'danger' : match ($record->type) { + 'A', 'AAAA' => 'info', + 'CNAME' => 'primary', + 'MX' => 'warning', + 'TXT' => 'success', + 'NS' => 'danger', + 'SRV' => 'primary', + 'CAA' => 'warning', + default => 'gray', + }) + ->sortable(), + TextColumn::make('name') + ->label(__('Name')) + ->fontFamily(FontFamily::Mono) + ->color(fn (DnsRecord $record) => $this->isRecordPendingDelete($record->id) ? 'danger' : null) + ->searchable(), + TextColumn::make('content') + ->label(__('Content')) + ->fontFamily(FontFamily::Mono) + ->limit(50) + ->tooltip(fn ($record) => $record->content) + ->color(fn (DnsRecord $record) => $this->isRecordPendingDelete($record->id) ? 'danger' : ($this->isRecordPendingEdit($record->id) ? 'warning' : null)) + ->searchable(), + TextColumn::make('ttl') + ->label(__('TTL')) + ->color(fn (DnsRecord $record) => $this->isRecordPendingDelete($record->id) ? 'danger' : null) + ->sortable(), + TextColumn::make('priority') + ->label(__('Priority')) + ->placeholder('-') + ->color(fn (DnsRecord $record) => $this->isRecordPendingDelete($record->id) ? 'danger' : null) + ->sortable(), + ]) + ->filters([]) + ->headerActions([ + TableAction::make('resetToDefaults') + ->label(__('Reset to Defaults')) + ->icon('heroicon-o-arrow-path') + ->color('gray') + ->requiresConfirmation() + ->modalHeading(__('Reset DNS Records')) + ->modalDescription(__('This will delete all existing DNS records and create default records. This action cannot be undone.')) + ->modalIcon('heroicon-o-exclamation-triangle') + ->modalIconColor('warning') + ->action(fn () => $this->resetToDefaults()), + TableAction::make('discardChanges') + ->label(__('Discard')) + ->icon('heroicon-o-x-mark') + ->color('gray') + ->visible(fn () => $this->hasPendingChanges()) + ->action(fn () => $this->discardChanges()), + TableAction::make('saveChanges') + ->label(fn () => $this->hasPendingChanges() + ? __('Save (:count changes)', ['count' => $this->getPendingChangesCount()]) + : __('Save')) + ->icon('heroicon-o-check') + ->color('primary') + ->action(fn () => $this->saveChanges()), + ]) + ->actions([ + ActionGroup::make([ + TableAction::make('edit') + ->label(__('Edit')) + ->icon('heroicon-o-pencil') + ->color(fn (DnsRecord $record) => $this->isRecordPendingEdit($record->id) ? 'warning' : 'gray') + ->hidden(fn (DnsRecord $record) => $this->isRecordPendingDelete($record->id)) + ->modalHeading(__('Edit DNS Record')) + ->modalDescription(__('Changes will be queued until you click Save.')) + ->modalIcon('heroicon-o-pencil-square') + ->modalIconColor('primary') + ->modalSubmitActionLabel(__('Queue Changes')) + ->fillForm(fn (DnsRecord $record) => [ + 'type' => $this->pendingEdits[$record->id]['type'] ?? $record->type, + 'name' => $this->pendingEdits[$record->id]['name'] ?? $record->name, + 'content' => $this->pendingEdits[$record->id]['content'] ?? $record->content, + 'ttl' => $this->pendingEdits[$record->id]['ttl'] ?? $record->ttl, + 'priority' => $this->pendingEdits[$record->id]['priority'] ?? $record->priority, + ]) + ->form($this->getRecordFormSchema()) + ->action(function (DnsRecord $record, array $data): void { + if ($record->domain->user_id !== Auth::id()) { + Notification::make()->title(__('Access denied'))->danger()->send(); + + return; + } + $this->pendingEdits[$record->id] = [ + 'type' => $data['type'], + 'name' => $data['name'], + 'content' => $data['content'], + 'ttl' => $data['ttl'] ?? 3600, + 'priority' => $data['priority'] ?? null, + ]; + Notification::make() + ->title(__('Edit queued')) + ->body(__('Click Save to apply.')) + ->info() + ->send(); + }), + TableAction::make('undoEdit') + ->label(__('Undo Edit')) + ->icon('heroicon-o-arrow-uturn-left') + ->color('warning') + ->visible(fn (DnsRecord $record) => $this->isRecordPendingEdit($record->id)) + ->action(function (DnsRecord $record): void { + unset($this->pendingEdits[$record->id]); + Notification::make()->title(__('Edit undone'))->success()->send(); + }), + TableAction::make('undoDelete') + ->label(__('Undo Delete')) + ->icon('heroicon-o-arrow-uturn-left') + ->color('success') + ->visible(fn (DnsRecord $record) => $this->isRecordPendingDelete($record->id)) + ->action(function (DnsRecord $record): void { + $this->pendingDeletes = array_values(array_diff($this->pendingDeletes, [$record->id])); + Notification::make()->title(__('Delete undone'))->success()->send(); + }), + TableAction::make('delete') + ->label(__('Delete')) + ->icon('heroicon-o-trash') + ->color('danger') + ->hidden(fn (DnsRecord $record) => $this->isRecordPendingDelete($record->id)) + ->requiresConfirmation() + ->modalHeading(__('Delete Record')) + ->modalDescription(fn (DnsRecord $record) => __('Delete the :type record for :name?', ['type' => $record->type, 'name' => $record->name])) + ->modalIcon('heroicon-o-trash') + ->modalIconColor('danger') + ->modalSubmitActionLabel(__('Queue Delete')) + ->action(function (DnsRecord $record): void { + if ($record->domain->user_id !== Auth::id()) { + Notification::make()->title(__('Access denied'))->danger()->send(); + + return; + } + if (! in_array($record->id, $this->pendingDeletes)) { + $this->pendingDeletes[] = $record->id; + } + unset($this->pendingEdits[$record->id]); + Notification::make() + ->title(__('Delete queued')) + ->body(__('Click Save to apply.')) + ->warning() + ->send(); + }), + ]), + ]) + ->emptyStateHeading(__('No DNS records')) + ->emptyStateDescription(__('Add DNS records to manage your domain\'s DNS configuration.')) + ->emptyStateIcon('heroicon-o-server-stack') + ->striped(); + } + + protected function getRecordFormSchema(): array + { + return [ + Select::make('type') + ->label(__('Record Type')) + ->options([ + 'A' => __('A - IPv4 Address'), + 'AAAA' => __('AAAA - IPv6 Address'), + 'CNAME' => __('CNAME - Canonical Name'), + 'MX' => __('MX - Mail Exchange'), + 'TXT' => __('TXT - Text Record'), + 'NS' => __('NS - Nameserver'), + 'SRV' => __('SRV - Service'), + 'CAA' => __('CAA - Certificate Authority'), + ]) + ->required() + ->live(), + TextInput::make('name') + ->label(__('Name')) + ->placeholder(__('@ for root, or subdomain (e.g., www, mail)')) + ->required() + ->maxLength(255), + TextInput::make('content') + ->label(__('Content')) + ->required() + ->maxLength(1024), + TextInput::make('ttl') + ->label(__('TTL (seconds)')) + ->numeric() + ->default(3600) + ->minValue(60) + ->maxValue(86400), + TextInput::make('priority') + ->label(__('Priority')) + ->numeric() + ->visible(fn ($get) => in_array($get('type'), ['MX', 'SRV'])) + ->default(10), + ]; + } + + public function getSelectedDomainName(): ?string + { + return Domain::find($this->selectedDomainId)?->domain; + } + + public function addRecordAction(): Action + { + return Action::make('addRecord') + ->label(__('Add Record')) + ->icon('heroicon-o-plus') + ->color('primary') + ->modalHeading(__('Add DNS Record')) + ->modalDescription(__('The record will be queued until you click Save.')) + ->modalIcon('heroicon-o-plus-circle') + ->modalIconColor('primary') + ->modalSubmitActionLabel(__('Queue Record')) + ->modalWidth('lg') + ->form($this->getRecordFormSchema()) + ->action(function (array $data) { + $this->queuePendingAdd([ + 'type' => $data['type'], + 'name' => $data['name'], + 'content' => $data['content'], + 'ttl' => $data['ttl'] ?? 3600, + 'priority' => $data['priority'] ?? null, + ]); + Notification::make() + ->title(__('Record queued')) + ->body(__('Click Save to apply.')) + ->info() + ->send(); + }); + } + + public function saveChanges(bool $notify = true): void + { + if (! $this->hasPendingChanges()) { + if ($notify) { + Notification::make()->title(__('No changes to save'))->warning()->send(); + } + + return; + } + + $domain = Domain::find($this->selectedDomainId); + if (! $domain || $domain->user_id !== Auth::id()) { + Notification::make()->title(__('Access denied'))->danger()->send(); + + return; + } + + try { + foreach ($this->pendingDeletes as $recordId) { + DnsRecord::where('id', $recordId) + ->whereHas('domain', fn ($q) => $q->where('user_id', Auth::id())) + ->delete(); + } + + foreach ($this->pendingEdits as $recordId => $data) { + $record = DnsRecord::find($recordId); + if ($record && $record->domain->user_id === Auth::id()) { + $record->update($data); + } + } + + foreach ($this->pendingAdds as $data) { + DnsRecord::create(array_merge(['domain_id' => $this->selectedDomainId], $this->sanitizePendingAdd($data))); + } + + $this->syncZoneFile($domain->domain); + + $this->pendingEdits = []; + $this->pendingDeletes = []; + $this->pendingAdds = []; + + $this->resetTable(); + + if ($notify) { + Notification::make() + ->title(__('Changes saved')) + ->body(__('DNS records updated. Changes may take up to 48 hours to propagate.')) + ->success() + ->send(); + } + + } catch (Exception $e) { + Notification::make() + ->title(__('Failed to save changes')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function discardChanges(): void + { + $this->pendingEdits = []; + $this->pendingDeletes = []; + $this->pendingAdds = []; + Notification::make()->title(__('Changes discarded'))->success()->send(); + } + + public function resetToDefaults(): void + { + $domain = Domain::find($this->selectedDomainId); + if (! $domain || $domain->user_id !== Auth::id()) { + Notification::make()->title(__('Access denied'))->danger()->send(); + + return; + } + + try { + DnsRecord::where('domain_id', $this->selectedDomainId)->delete(); + + $settings = DnsSetting::getAll(); + $serverIp = $domain->ip_address ?: ($settings['default_ip'] ?? trim(shell_exec("hostname -I | awk '{print $1}'") ?? '') ?: '127.0.0.1'); + $serverIpv6 = $domain->ipv6_address ?: ($settings['default_ipv6'] ?? null); + $ns1 = $settings['ns1'] ?? 'ns1.'.$domain->domain; + $ns2 = $settings['ns2'] ?? 'ns2.'.$domain->domain; + + $defaultRecords = [ + ['name' => '@', 'type' => 'NS', 'content' => $ns1, 'ttl' => 3600, 'priority' => null], + ['name' => '@', 'type' => 'NS', 'content' => $ns2, 'ttl' => 3600, 'priority' => null], + ['name' => '@', 'type' => 'A', 'content' => $serverIp, 'ttl' => 3600, 'priority' => null], + ['name' => 'www', 'type' => 'A', 'content' => $serverIp, 'ttl' => 3600, 'priority' => null], + ['name' => 'mail', 'type' => 'A', 'content' => $serverIp, 'ttl' => 3600, 'priority' => null], + ['name' => '@', 'type' => 'MX', 'content' => 'mail.'.$domain->domain, 'ttl' => 3600, 'priority' => 10], + ['name' => '@', 'type' => 'TXT', 'content' => 'v=spf1 mx a ~all', 'ttl' => 3600, 'priority' => null], + ]; + + if (! empty($serverIpv6)) { + $defaultRecords[] = ['name' => '@', 'type' => 'AAAA', 'content' => $serverIpv6, 'ttl' => 3600, 'priority' => null]; + $defaultRecords[] = ['name' => 'www', 'type' => 'AAAA', 'content' => $serverIpv6, 'ttl' => 3600, 'priority' => null]; + $defaultRecords[] = ['name' => 'mail', 'type' => 'AAAA', 'content' => $serverIpv6, 'ttl' => 3600, 'priority' => null]; + } + + foreach ($defaultRecords as $record) { + DnsRecord::create(array_merge(['domain_id' => $this->selectedDomainId], $record)); + } + + $this->syncZoneFile($domain->domain); + + $this->pendingEdits = []; + $this->pendingDeletes = []; + $this->pendingAdds = []; + + $this->resetTable(); + + Notification::make() + ->title(__('DNS records reset')) + ->body(__('Default records have been created for :domain', ['domain' => $domain->domain])) + ->success() + ->send(); + + } catch (Exception $e) { + Notification::make() + ->title(__('Failed to reset records')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function applyTemplateAction(): Action + { + return Action::make('applyTemplate') + ->label(__('Apply Template')) + ->icon('heroicon-o-document-duplicate') + ->color('gray') + ->modalHeading(__('Apply Email Template')) + ->modalDescription(__('This will apply the selected email DNS records immediately.')) + ->modalIcon('heroicon-o-envelope') + ->modalIconColor('warning') + ->modalSubmitActionLabel(__('Apply Template')) + ->modalWidth('lg') + ->form([ + Select::make('template') + ->label(__('Email Provider')) + ->options([ + 'google' => __('Google Workspace (Gmail)'), + 'microsoft' => __('Microsoft 365 (Outlook)'), + 'zoho' => __('Zoho Mail'), + 'protonmail' => __('ProtonMail'), + 'fastmail' => __('Fastmail'), + 'local' => __('Local Mail Server (This Server)'), + ]) + ->required() + ->live(), + TextInput::make('verification_code') + ->label(__('Domain Verification Code (optional)')) + ->placeholder(__('e.g., google-site-verification=xxx')), + ]) + ->action(function (array $data) { + $domain = Domain::find($this->selectedDomainId); + if (! $domain || $domain->user_id !== Auth::id()) { + Notification::make()->title(__('Access denied'))->danger()->send(); + + return; + } + + $domainName = $domain->domain; + $template = $data['template']; + $verificationCode = $data['verification_code'] ?? null; + + $recordsToDelete = DnsRecord::where('domain_id', $this->selectedDomainId) + ->where(function ($query) { + $query->where('type', 'MX') + ->orWhere(function ($q) { + $q->where('type', 'A')->where('name', 'mail'); + }) + ->orWhere(function ($q) { + $q->where('type', 'CNAME')->where('name', 'autodiscover'); + }) + ->orWhere(function ($q) { + $q->where('type', 'TXT') + ->where(function ($inner) { + $inner->where('content', 'like', '%spf%') + ->orWhere('content', 'like', '%v=spf1%') + ->orWhere('content', 'like', '%google-site-verification%') + ->orWhere('content', 'like', '%MS=%') + ->orWhere('content', 'like', '%zoho-verification%') + ->orWhere('content', 'like', '%protonmail-verification%') + ->orWhere('name', 'like', '%_domainkey%'); + }); + }); + }) + ->pluck('id') + ->toArray(); + + foreach ($recordsToDelete as $id) { + if (! in_array($id, $this->pendingDeletes)) { + $this->pendingDeletes[] = $id; + } + unset($this->pendingEdits[$id]); + } + + $records = $this->getTemplateRecords($template, $domain, $verificationCode); + foreach ($records as $record) { + $this->queuePendingAdd($record); + } + + if (! $this->hasPendingChanges()) { + Notification::make() + ->title(__('No changes to apply')) + ->warning() + ->send(); + + return; + } + + $this->saveChanges(false); + + Notification::make() + ->title(__('Template applied')) + ->body(__('Email records for :provider have been applied. Changes may take up to 48 hours to propagate.', ['provider' => ucfirst($template)])) + ->success() + ->send(); + }); + } + + protected function getTemplateRecords(string $template, Domain $domain, ?string $verificationCode): array + { + $settings = DnsSetting::getAll(); + $serverIp = $domain->ip_address ?: ($settings['default_ip'] ?? trim(shell_exec("hostname -I | awk '{print $1}'") ?? '') ?: '127.0.0.1'); + $serverIpv6 = $domain->ipv6_address ?: ($settings['default_ipv6'] ?? null); + $domainName = $domain->domain; + + $records = match ($template) { + 'google' => [ + ['name' => '@', 'type' => 'MX', 'content' => 'aspmx.l.google.com', 'ttl' => 3600, 'priority' => 1], + ['name' => '@', 'type' => 'MX', 'content' => 'alt1.aspmx.l.google.com', 'ttl' => 3600, 'priority' => 5], + ['name' => '@', 'type' => 'MX', 'content' => 'alt2.aspmx.l.google.com', 'ttl' => 3600, 'priority' => 5], + ['name' => '@', 'type' => 'MX', 'content' => 'alt3.aspmx.l.google.com', 'ttl' => 3600, 'priority' => 10], + ['name' => '@', 'type' => 'MX', 'content' => 'alt4.aspmx.l.google.com', 'ttl' => 3600, 'priority' => 10], + ['name' => '@', 'type' => 'TXT', 'content' => 'v=spf1 include:_spf.google.com ~all', 'ttl' => 3600, 'priority' => null], + ], + 'microsoft' => [ + ['name' => '@', 'type' => 'MX', 'content' => str_replace('.', '-', $domainName).'.mail.protection.outlook.com', 'ttl' => 3600, 'priority' => 0], + ['name' => '@', 'type' => 'TXT', 'content' => 'v=spf1 include:spf.protection.outlook.com ~all', 'ttl' => 3600, 'priority' => null], + ['name' => 'autodiscover', 'type' => 'CNAME', 'content' => 'autodiscover.outlook.com', 'ttl' => 3600, 'priority' => null], + ], + 'zoho' => [ + ['name' => '@', 'type' => 'MX', 'content' => 'mx.zoho.com', 'ttl' => 3600, 'priority' => 10], + ['name' => '@', 'type' => 'MX', 'content' => 'mx2.zoho.com', 'ttl' => 3600, 'priority' => 20], + ['name' => '@', 'type' => 'MX', 'content' => 'mx3.zoho.com', 'ttl' => 3600, 'priority' => 50], + ['name' => '@', 'type' => 'TXT', 'content' => 'v=spf1 include:zoho.com ~all', 'ttl' => 3600, 'priority' => null], + ], + 'protonmail' => [ + ['name' => '@', 'type' => 'MX', 'content' => 'mail.protonmail.ch', 'ttl' => 3600, 'priority' => 10], + ['name' => '@', 'type' => 'MX', 'content' => 'mailsec.protonmail.ch', 'ttl' => 3600, 'priority' => 20], + ['name' => '@', 'type' => 'TXT', 'content' => 'v=spf1 include:_spf.protonmail.ch mx ~all', 'ttl' => 3600, 'priority' => null], + ], + 'fastmail' => [ + ['name' => '@', 'type' => 'MX', 'content' => 'in1-smtp.messagingengine.com', 'ttl' => 3600, 'priority' => 10], + ['name' => '@', 'type' => 'MX', 'content' => 'in2-smtp.messagingengine.com', 'ttl' => 3600, 'priority' => 20], + ['name' => '@', 'type' => 'TXT', 'content' => 'v=spf1 include:spf.messagingengine.com ~all', 'ttl' => 3600, 'priority' => null], + ], + 'local' => [ + ['name' => '@', 'type' => 'MX', 'content' => 'mail.'.$domainName, 'ttl' => 3600, 'priority' => 10], + ['name' => 'mail', 'type' => 'A', 'content' => $serverIp, 'ttl' => 3600, 'priority' => null], + ['name' => '@', 'type' => 'TXT', 'content' => 'v=spf1 mx a ~all', 'ttl' => 3600, 'priority' => null], + ], + default => [], + }; + + if ($template === 'local' && ! empty($serverIpv6)) { + $records[] = ['name' => 'mail', 'type' => 'AAAA', 'content' => $serverIpv6, 'ttl' => 3600, 'priority' => null]; + } + + if ($verificationCode) { + $records[] = ['name' => '@', 'type' => 'TXT', 'content' => $verificationCode, 'ttl' => 3600, 'priority' => null]; + } + + return $records; + } + + protected function syncZoneFile(string $domain): void + { + try { + $records = DnsRecord::whereHas('domain', fn ($q) => $q->where('domain', $domain))->get(); + $settings = DnsSetting::getAll(); + $this->getAgent()->send('dns.sync_zone', [ + 'domain' => $domain, + 'records' => $records->toArray(), + 'ns1' => $settings['ns1'] ?? 'ns1.example.com', + 'ns2' => $settings['ns2'] ?? 'ns2.example.com', + 'admin_email' => $settings['admin_email'] ?? 'admin.example.com', + 'default_ttl' => $settings['default_ttl'] ?? 3600, + ]); + } catch (Exception $e) { + Notification::make()->title(__('Warning: Zone file sync failed'))->body($e->getMessage())->warning()->send(); + } + } + + protected function getHeaderActions(): array + { + return [ + $this->getTourAction(), + $this->applyTemplateAction() + ->visible(fn () => $this->selectedDomainId !== null), + $this->addRecordAction() + ->visible(fn () => $this->selectedDomainId !== null), + ]; + } +} diff --git a/app/Filament/Jabali/Pages/Domains.php b/app/Filament/Jabali/Pages/Domains.php new file mode 100644 index 0000000..8504df1 --- /dev/null +++ b/app/Filament/Jabali/Pages/Domains.php @@ -0,0 +1,795 @@ +agent === null) { + $this->agent = new AgentClient(); + } + return $this->agent; + } + + public function getUsername(): string + { + return Auth::user()->username; + } + + public function table(Table $table): Table + { + return $table + ->query(Domain::query()->where('user_id', Auth::id())) + ->columns([ + TextColumn::make('domain') + ->label(__('Domain')) + ->icon('heroicon-o-globe-alt') + ->iconColor('primary') + ->description(fn (Domain $record) => $record->document_root) + ->url(fn (Domain $record) => 'http://' . $record->domain, shouldOpenInNewTab: true) + ->searchable() + ->sortable(), + IconColumn::make('is_active') + ->label(__('Status')) + ->boolean() + ->trueIcon('heroicon-o-check-circle') + ->falseIcon('heroicon-o-x-circle') + ->trueColor('success') + ->falseColor('danger'), + IconColumn::make('ssl_enabled') + ->label(__('SSL')) + ->boolean() + ->trueIcon('heroicon-m-lock-closed') + ->falseIcon('heroicon-m-lock-open') + ->trueColor('success') + ->falseColor('warning'), + IconColumn::make('page_cache_enabled') + ->label(__('Page Cache')) + ->boolean() + ->trueIcon('heroicon-o-bolt') + ->falseIcon('heroicon-o-bolt-slash') + ->trueColor('success') + ->falseColor('gray'), + TextColumn::make('redirects_count') + ->label(__('Redirects')) + ->counts('redirects') + ->badge() + ->color('info'), + TextColumn::make('created_at') + ->label(__('Created')) + ->date('M d, Y') + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->recordActions([ + ActionGroup::make([ + Action::make('files') + ->label(__('Files')) + ->icon('heroicon-o-folder') + ->color('info') + ->action(fn (Domain $record) => $this->openFileManager($record)), + Action::make('redirects') + ->label(__('Redirects')) + ->icon('heroicon-o-arrow-right-circle') + ->color('warning') + ->modalHeading(fn (Domain $record) => __('Redirects for :domain', ['domain' => $record->domain])) + ->modalDescription(__('Redirect this domain to another domain or set up page redirects')) + ->modalWidth(Width::FourExtraLarge) + ->modalSubmitActionLabel(__('Save Redirects')) + ->form(fn (Domain $record) => $this->getRedirectsForm($record)) + ->fillForm(fn (Domain $record) => $this->getRedirectsFormData($record)) + ->action(fn (Domain $record, array $data) => $this->saveRedirects($record, $data)), + Action::make('hotlink') + ->label(__('Hotlink Protection')) + ->icon('heroicon-o-shield-check') + ->color('success') + ->modalHeading(fn (Domain $record) => __('Hotlink Protection for :domain', ['domain' => $record->domain])) + ->modalDescription(__('Prevent other websites from directly linking to your files')) + ->modalWidth(Width::TwoExtraLarge) + ->form($this->getHotlinkForm()) + ->fillForm(fn (Domain $record) => $this->getHotlinkFormData($record)) + ->action(fn (Domain $record, array $data) => $this->saveHotlinkSettings($record, $data)), + Action::make('index') + ->label(__('Index Manager')) + ->icon('heroicon-o-document-text') + ->color('gray') + ->modalHeading(fn (Domain $record) => __('Index Manager for :domain', ['domain' => $record->domain])) + ->modalDescription(__('Set the default directory index files')) + ->modalWidth(Width::Medium) + ->form($this->getIndexForm()) + ->fillForm(fn (Domain $record) => ['directory_index' => $record->directory_index]) + ->action(fn (Domain $record, array $data) => $this->saveIndexSettings($record, $data)), + ]) + ->label(__('Settings')) + ->icon('heroicon-o-cog-6-tooth') + ->color('gray') + ->button(), + Action::make('toggle') + ->label(fn (Domain $record) => $record->is_active ? __('Disable') : __('Enable')) + ->icon(fn (Domain $record) => $record->is_active ? 'heroicon-o-no-symbol' : 'heroicon-o-check') + ->color(fn (Domain $record) => $record->is_active ? 'warning' : 'success') + ->action(fn (Domain $record) => $this->toggleDomain($record)), + Action::make('delete') + ->label(__('Delete')) + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->modalHeading(__('Delete Domain')) + ->modalDescription(fn (Domain $record) => __('Are you sure you want to delete') . " '{$record->domain}'? " . __('This will also delete the following associated data:')) + ->modalIcon('heroicon-o-trash') + ->modalIconColor('danger') + ->modalSubmitActionLabel(__('Delete Domain')) + ->modalWidth(Width::Large) + ->form(fn (Domain $record): array => [ + Toggle::make('delete_files') + ->label(__('Delete all domain files')) + ->helperText(__('Permanently delete all files in the domain folder')) + ->default(true), + Toggle::make('delete_dns') + ->label(__('Delete DNS records') . ' (' . $record->dnsRecords()->count() . ')') + ->helperText(__('Remove all DNS records for this domain')) + ->default(true) + ->visible(fn () => $record->dnsRecords()->exists()), + Toggle::make('delete_email') + ->label(__('Delete email accounts') . ' (' . ($record->emailDomain?->mailboxes()->count() ?? 0) . ')') + ->helperText(__('Remove all mailboxes and email configuration')) + ->default(true) + ->visible(fn () => $record->emailDomain()->exists()), + Toggle::make('delete_ssl') + ->label(__('Delete SSL certificate')) + ->helperText(__('Remove SSL certificate for this domain')) + ->default(true) + ->visible(fn () => $record->sslCertificate()->exists()), + Toggle::make('delete_wordpress') + ->label(__('Delete WordPress sites')) + ->helperText(__('Remove all WordPress installations on this domain')) + ->default(true), + ]) + ->action(fn (Domain $record, array $data) => $this->deleteDomain($record, $data)), + ]) + ->emptyStateHeading(__('No domains yet')) + ->emptyStateDescription(__('Click "Add Domain" to add your first domain')) + ->emptyStateIcon('heroicon-o-globe-alt') + ->striped() + ->defaultSort('created_at', 'desc'); + } + + protected function getRedirectsForm(Domain $record): array + { + return [ + // Domain-wide redirect + Toggle::make('domain_redirect_enabled') + ->label(__('Redirect Entire Domain')) + ->helperText(__('Redirect all traffic from this domain to another domain')) + ->live() + ->columnSpanFull(), + + Grid::make() + ->schema([ + TextInput::make('domain_redirect_url') + ->label(__('Redirect To')) + ->placeholder('https://newdomain.com') + ->helperText(__('All requests to this domain will be redirected to this URL')) + ->url() + ->required(fn ($get) => $get('domain_redirect_enabled')) + ->columnSpan(['default' => 2, 'md' => 1]), + Select::make('domain_redirect_type') + ->label(__('Redirect Type')) + ->options([ + '301' => __('Permanent (301) - SEO friendly'), + '302' => __('Temporary (302)'), + ]) + ->default('301') + ->required(fn ($get) => $get('domain_redirect_enabled')) + ->columnSpan(['default' => 2, 'md' => 1]), + ]) + ->columns(['default' => 2, 'md' => 2]) + ->visible(fn ($get) => $get('domain_redirect_enabled')), + + // Page redirects + Repeater::make('redirects') + ->label(__('Page Redirects')) + ->helperText(__('Redirect specific paths to other URLs')) + ->schema([ + Grid::make() + ->schema([ + TextInput::make('source_path') + ->label(__('Source Path')) + ->placeholder('/old-page') + ->helperText(__('Path to redirect from (e.g., /old-page)')) + ->required() + ->columnSpan(['default' => 2, 'md' => 1]), + TextInput::make('destination_url') + ->label(__('Destination URL')) + ->placeholder('https://example.com/new-page') + ->helperText(__('Full URL to redirect to')) + ->required() + ->url() + ->columnSpan(['default' => 2, 'md' => 1]), + ]) + ->columns(['default' => 2, 'md' => 2]), + Grid::make() + ->schema([ + Select::make('redirect_type') + ->label(__('Type')) + ->options([ + '301' => __('Permanent (301)'), + '302' => __('Temporary (302)'), + ]) + ->default('301') + ->required() + ->columnSpan(['default' => 2, 'sm' => 1]), + Toggle::make('is_wildcard') + ->label(__('Wildcard')) + ->helperText(__('Match all paths starting with source')) + ->columnSpan(['default' => 2, 'sm' => 1]), + Toggle::make('is_active') + ->label(__('Active')) + ->default(true) + ->columnSpan(['default' => 2, 'sm' => 1]), + ]) + ->columns(['default' => 2, 'sm' => 3]), + ]) + ->itemLabel(fn (array $state): ?string => ($state['source_path'] ?? '') . ' → ' . ($state['redirect_type'] ?? '301')) + ->collapsible() + ->collapsed(fn () => $record->redirects()->count() > 3) + ->addActionLabel(__('Add Page Redirect')) + ->reorderable() + ->defaultItems(0) + ->visible(fn ($get) => !$get('domain_redirect_enabled')), + ]; + } + + protected function getRedirectsFormData(Domain $record): array + { + // Check if there's a domain-wide redirect (source_path = '/*' or '*') + $domainRedirect = $record->redirects() + ->whereIn('source_path', ['/*', '*', '/']) + ->where('is_wildcard', true) + ->first(); + + return [ + 'domain_redirect_enabled' => $domainRedirect !== null, + 'domain_redirect_url' => $domainRedirect?->destination_url ?? '', + 'domain_redirect_type' => $domainRedirect?->redirect_type ?? '301', + 'redirects' => $record->redirects() + ->whereNotIn('source_path', ['/*', '*', '/']) + ->orWhere('is_wildcard', false) + ->get() + ->map(fn ($r) => [ + 'id' => $r->id, + 'source_path' => $r->source_path, + 'destination_url' => $r->destination_url, + 'redirect_type' => $r->redirect_type, + 'is_wildcard' => $r->is_wildcard, + 'is_active' => $r->is_active, + ])->toArray(), + ]; + } + + protected function getHotlinkForm(): array + { + return [ + Toggle::make('is_enabled') + ->label(__('Enable Hotlink Protection')) + ->helperText(__('Block other websites from directly linking to your images and files')) + ->live(), + Grid::make() + ->schema([ + Textarea::make('allowed_domains') + ->label(__('Allowed Domains')) + ->helperText(__('One domain per line that can link to your files (your own domain is always allowed)')) + ->placeholder("example.com\ntrusted-site.com") + ->rows(4) + ->columnSpan(['default' => 2, 'md' => 1]), + TextInput::make('protected_extensions') + ->label(__('Protected File Extensions')) + ->helperText(__('Comma-separated list of file extensions to protect')) + ->placeholder('jpg,jpeg,png,gif,webp,svg,mp4,mp3,pdf') + ->default(DomainHotlinkSetting::getDefaultExtensions()) + ->columnSpan(['default' => 2, 'md' => 1]), + ]) + ->columns(['default' => 2, 'md' => 2]) + ->visible(fn ($get) => $get('is_enabled')), + Grid::make() + ->schema([ + Toggle::make('block_blank_referrer') + ->label(__('Block Blank Referrer')) + ->helperText(__('Block requests with no referrer header')) + ->default(true) + ->columnSpan(['default' => 2, 'md' => 1]), + TextInput::make('redirect_url') + ->label(__('Redirect URL (Optional)')) + ->helperText(__('Redirect blocked requests to this URL instead of showing an error')) + ->placeholder('https://example.com/hotlink-blocked.png') + ->url() + ->columnSpan(['default' => 2, 'md' => 1]), + ]) + ->columns(['default' => 2, 'md' => 2]) + ->visible(fn ($get) => $get('is_enabled')), + ]; + } + + protected function getHotlinkFormData(Domain $record): array + { + $setting = $record->hotlinkSetting; + if (!$setting) { + return [ + 'is_enabled' => false, + 'allowed_domains' => '', + 'block_blank_referrer' => true, + 'protected_extensions' => DomainHotlinkSetting::getDefaultExtensions(), + 'redirect_url' => '', + ]; + } + return [ + 'is_enabled' => $setting->is_enabled, + 'allowed_domains' => $setting->allowed_domains, + 'block_blank_referrer' => $setting->block_blank_referrer, + 'protected_extensions' => $setting->protected_extensions, + 'redirect_url' => $setting->redirect_url ?? '', + ]; + } + + protected function getIndexForm(): array + { + return [ + Radio::make('directory_index') + ->label(__('Directory Index Priority')) + ->helperText(__('Choose which file should be served as the default index')) + ->options([ + 'index.php index.html' => __('PHP first (index.php, then index.html)'), + 'index.html index.php' => __('HTML first (index.html, then index.php)'), + 'index.php' => __('PHP only (index.php)'), + 'index.html' => __('HTML only (index.html)'), + 'index.php index.html index.htm' => __('PHP, HTML, HTM (full support)'), + ]) + ->default('index.php index.html') + ->required(), + ]; + } + + protected function getHeaderActions(): array + { + return [ + $this->getTourAction(), + $this->createDomainAction(), + ]; + } + + protected function createDomainAction(): Action + { + return Action::make('createDomain') + ->label(__('Add Domain')) + ->icon('heroicon-o-plus-circle') + ->color('primary') + ->modalHeading(__('Add Domain')) + ->modalDescription(__('Add a new domain to your hosting account')) + ->modalIcon('heroicon-o-globe-alt') + ->modalIconColor('primary') + ->modalSubmitActionLabel(__('Add Domain')) + ->form([ + TextInput::make('domain') + ->label(__('Domain Name')) + ->placeholder(__('example.com')) + ->required() + ->regex('/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/') + ->helperText(__('Enter the domain name without http:// or www')), + ]) + ->action(function (array $data): void { + try { + $result = $this->getAgent()->domainCreate($this->getUsername(), $data['domain']); + + if ($result['success'] ?? false) { + Domain::create([ + 'user_id' => Auth::id(), + 'domain' => $data['domain'], + 'document_root' => '/home/' . $this->getUsername() . '/domains/' . $data['domain'] . '/public_html', + 'is_active' => true, + 'ssl_enabled' => false, + 'directory_index' => 'index.php index.html', + 'page_cache_enabled' => false, + ]); + + Notification::make() + ->title(__('Domain created!')) + ->body(__('Your domain is now active.')) + ->success() + ->send(); + } else { + throw new Exception($result['error'] ?? 'Unknown error'); + } + } catch (Exception $e) { + Notification::make() + ->title(__('Error creating domain')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + }); + } + + public function saveRedirects(Domain $domain, array $data): void + { + try { + $existingIds = []; + + // Handle domain-wide redirect + if ($data['domain_redirect_enabled'] ?? false) { + // Delete all existing redirects and create a single domain-wide redirect + $domain->redirects()->delete(); + + $redirect = $domain->redirects()->create([ + 'source_path' => '/*', + 'destination_url' => $data['domain_redirect_url'], + 'redirect_type' => $data['domain_redirect_type'] ?? '301', + 'is_wildcard' => true, + 'is_active' => true, + ]); + $existingIds[] = $redirect->id; + } else { + // Delete any domain-wide redirects + $domain->redirects() + ->whereIn('source_path', ['/*', '*', '/']) + ->where('is_wildcard', true) + ->delete(); + + // Handle page redirects + $redirectsData = $data['redirects'] ?? []; + + foreach ($redirectsData as $redirectData) { + if (!empty($redirectData['id'])) { + $redirect = DomainRedirect::find($redirectData['id']); + if ($redirect && $redirect->domain_id === $domain->id) { + $redirect->update([ + 'source_path' => $redirectData['source_path'], + 'destination_url' => $redirectData['destination_url'], + 'redirect_type' => $redirectData['redirect_type'], + 'is_wildcard' => $redirectData['is_wildcard'] ?? false, + 'is_active' => $redirectData['is_active'] ?? true, + ]); + $existingIds[] = $redirect->id; + } + } else { + $redirect = $domain->redirects()->create([ + 'source_path' => $redirectData['source_path'], + 'destination_url' => $redirectData['destination_url'], + 'redirect_type' => $redirectData['redirect_type'], + 'is_wildcard' => $redirectData['is_wildcard'] ?? false, + 'is_active' => $redirectData['is_active'] ?? true, + ]); + $existingIds[] = $redirect->id; + } + } + + // Delete removed page redirects (but not domain-wide ones which we already handled) + if (!empty($existingIds)) { + $domain->redirects() + ->whereNotIn('id', $existingIds) + ->whereNotIn('source_path', ['/*', '*', '/']) + ->delete(); + } + } + + // Apply redirects via agent + $this->applyRedirects($domain); + + Notification::make() + ->title(__('Redirects saved!')) + ->body(__('Your redirect rules have been updated.')) + ->success() + ->send(); + } catch (Exception $e) { + Notification::make() + ->title(__('Error saving redirects')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + protected function applyRedirects(Domain $domain): void + { + $redirects = $domain->redirects()->where('is_active', true)->get()->map(fn ($r) => [ + 'source' => $r->source_path, + 'destination' => $r->destination_url, + 'type' => $r->redirect_type, + 'wildcard' => $r->is_wildcard, + ])->toArray(); + + $this->getAgent()->send('domain.set_redirects', [ + 'username' => $this->getUsername(), + 'domain' => $domain->domain, + 'redirects' => $redirects, + ]); + } + + public function saveHotlinkSettings(Domain $domain, array $data): void + { + try { + $setting = $domain->hotlinkSetting; + if (!$setting) { + $setting = new DomainHotlinkSetting(['domain_id' => $domain->id]); + } + + $setting->fill([ + 'is_enabled' => $data['is_enabled'] ?? false, + 'allowed_domains' => $data['allowed_domains'] ?? '', + 'block_blank_referrer' => $data['block_blank_referrer'] ?? true, + 'protected_extensions' => $data['protected_extensions'] ?? DomainHotlinkSetting::getDefaultExtensions(), + 'redirect_url' => $data['redirect_url'] ?? null, + ]); + $setting->save(); + + // Apply hotlink protection via agent + $this->getAgent()->send('domain.set_hotlink_protection', [ + 'username' => $this->getUsername(), + 'domain' => $domain->domain, + 'enabled' => $setting->is_enabled, + 'allowed_domains' => $setting->getAllowedDomainsArray(), + 'block_blank_referrer' => $setting->block_blank_referrer, + 'protected_extensions' => $setting->getProtectedExtensionsArray(), + 'redirect_url' => $setting->redirect_url, + ]); + + Notification::make() + ->title(__('Hotlink protection updated!')) + ->body($setting->is_enabled ? __('Protection is now active.') : __('Protection has been disabled.')) + ->success() + ->send(); + } catch (Exception $e) { + Notification::make() + ->title(__('Error saving hotlink settings')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function saveIndexSettings(Domain $domain, array $data): void + { + try { + $domain->update(['directory_index' => $data['directory_index']]); + + // Apply index settings via agent + $this->getAgent()->send('domain.set_directory_index', [ + 'username' => $this->getUsername(), + 'domain' => $domain->domain, + 'directory_index' => $data['directory_index'], + ]); + + Notification::make() + ->title(__('Index settings updated!')) + ->body(__('Directory index priority has been changed.')) + ->success() + ->send(); + } catch (Exception $e) { + Notification::make() + ->title(__('Error saving index settings')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function toggleDomain(Domain $domain): void + { + try { + $newStatus = !$domain->is_active; + $result = $this->getAgent()->domainToggle($this->getUsername(), $domain->domain, $newStatus); + + if ($result['success'] ?? false) { + $domain->update(['is_active' => $newStatus]); + + $status = $newStatus ? __('Enabled') : __('Disabled'); + Notification::make() + ->title(__('Domain') . " {$status}") + ->success() + ->send(); + } else { + throw new Exception($result['error'] ?? 'Unknown error'); + } + } catch (Exception $e) { + Notification::make() + ->title(__('Error toggling domain')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function deleteDomain(Domain $domain, array $options): void + { + $deletedItems = []; + $errors = []; + + try { + // Delete WordPress sites first (via Agent) + if ($options['delete_wordpress'] ?? false) { + try { + $wpResult = $this->getAgent()->send('wp.list', [ + 'username' => $this->getUsername(), + ]); + + foreach ($wpResult['sites'] ?? [] as $site) { + if (($site['domain'] ?? '') === $domain->domain) { + $this->getAgent()->send('wp.delete', [ + 'username' => $this->getUsername(), + 'site_id' => $site['id'], + 'delete_files' => true, + 'delete_database' => true, + ]); + $deletedItems[] = __('WordPress site'); + } + } + } catch (Exception $e) { + $errors[] = __('WordPress: ') . $e->getMessage(); + } + } + + // Delete SSL certificate + if ($options['delete_ssl'] ?? false) { + if ($domain->sslCertificate) { + try { + $this->getAgent()->send('ssl.delete', [ + 'username' => $this->getUsername(), + 'domain' => $domain->domain, + ]); + $domain->sslCertificate->delete(); + $deletedItems[] = __('SSL certificate'); + } catch (Exception $e) { + $errors[] = __('SSL: ') . $e->getMessage(); + } + } + } + + // Delete email accounts + if ($options['delete_email'] ?? false) { + if ($domain->emailDomain) { + try { + foreach ($domain->emailDomain->mailboxes as $mailbox) { + $this->getAgent()->send('email.mailbox_delete', [ + 'username' => $this->getUsername(), + 'email' => $mailbox->email, + 'delete_files' => true, + 'maildir_path' => $mailbox->maildir_path, + ]); + } + $this->getAgent()->send('email.disable_domain', [ + 'username' => $this->getUsername(), + 'domain' => $domain->domain, + ]); + + $mailboxCount = $domain->emailDomain->mailboxes()->count(); + $domain->emailDomain->mailboxes()->delete(); + $domain->emailDomain->delete(); + $deletedItems[] = __(':count email account(s)', ['count' => $mailboxCount]); + } catch (Exception $e) { + $errors[] = __('Email: ') . $e->getMessage(); + } + } + } + + // Delete DNS records + if ($options['delete_dns'] ?? false) { + try { + $dnsCount = $domain->dnsRecords()->count(); + if ($dnsCount > 0) { + $this->getAgent()->send('dns.delete_zone', [ + 'domain' => $domain->domain, + ]); + $domain->dnsRecords()->delete(); + $deletedItems[] = __(':count DNS record(s)', ['count' => $dnsCount]); + } + } catch (Exception $e) { + $errors[] = __('DNS: ') . $e->getMessage(); + } + } + + // Delete redirects and hotlink settings (cascade should handle this, but be explicit) + $domain->redirects()->delete(); + $domain->hotlinkSetting?->delete(); + + // Delete domain files and configuration via Agent + $result = $this->getAgent()->domainDelete( + $this->getUsername(), + $domain->domain, + $options['delete_files'] ?? false + ); + + if ($result['success'] ?? false) { + $domain->delete(); + + $message = __('Domain deleted successfully.'); + if (!empty($deletedItems)) { + $message .= ' ' . __('Also deleted: ') . implode(', ', $deletedItems); + } + + Notification::make() + ->title(__('Domain deleted')) + ->body($message) + ->success() + ->send(); + + if (!empty($errors)) { + Notification::make() + ->title(__('Some items had warnings')) + ->body(implode("\n", $errors)) + ->warning() + ->send(); + } + } else { + throw new Exception($result['error'] ?? 'Unknown error'); + } + } catch (Exception $e) { + Notification::make() + ->title(__('Error deleting domain')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function openFileManager(Domain $domain): void + { + $path = str_replace('/home/' . $this->getUsername() . '/', '', $domain->document_root); + $this->redirect(route('filament.jabali.pages.files', ['path' => $path])); + } +} diff --git a/app/Filament/Jabali/Pages/Email.php b/app/Filament/Jabali/Pages/Email.php new file mode 100644 index 0000000..6cbcd2e --- /dev/null +++ b/app/Filament/Jabali/Pages/Email.php @@ -0,0 +1,1447 @@ +activeTab = $this->normalizeTabName($this->activeTab); + } + + public function updatedActiveTab(): void + { + $this->activeTab = $this->normalizeTabName($this->activeTab); + $this->resetTable(); + } + + protected function normalizeTabName(?string $tab): string + { + // Handle Filament's tab format "tabname::tab" or just "tabname" + $tab = $tab ?? 'mailboxes'; + if (str_contains($tab, '::')) { + $tab = explode('::', $tab)[0]; + } + + // Map to valid tab names + return match ($tab) { + 'mailboxes', 'Mailboxes' => 'mailboxes', + 'forwarders', 'Forwarders' => 'forwarders', + 'autoresponders', 'Autoresponders' => 'autoresponders', + 'catchall', 'catch-all', 'Catch-All' => 'catchall', + 'logs', 'Logs' => 'logs', + default => 'mailboxes', + }; + } + + protected function getActiveTabIndex(): int + { + return match ($this->activeTab) { + 'mailboxes' => 1, + 'forwarders' => 2, + 'autoresponders' => 3, + 'catchall' => 4, + 'logs' => 5, + default => 1, + }; + } + + protected function getForms(): array + { + return ['emailForm']; + } + + public function emailForm(Schema $schema): Schema + { + return $schema->schema([ + View::make('filament.jabali.components.email-tabs-nav'), + ]); + } + + public function setTab(string $tab): void + { + $this->activeTab = $this->normalizeTabName($tab); + $this->resetTable(); + } + + public function getAgent(): AgentClient + { + if ($this->agent === null) { + $this->agent = new AgentClient(); + } + return $this->agent; + } + + public function getUsername(): string + { + return Auth::user()->username; + } + + public function generateSecurePassword(int $length = 16): string + { + $lowercase = 'abcdefghijklmnopqrstuvwxyz'; + $uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + $numbers = '0123456789'; + $special = '!@#$%^&*'; + + // Ensure at least one of each required type + $password = $lowercase[random_int(0, strlen($lowercase) - 1)] + . $uppercase[random_int(0, strlen($uppercase) - 1)] + . $numbers[random_int(0, strlen($numbers) - 1)] + . $special[random_int(0, strlen($special) - 1)]; + + // Fill the rest with random characters from all types + $allChars = $lowercase . $uppercase . $numbers . $special; + for ($i = strlen($password); $i < $length; $i++) { + $password .= $allChars[random_int(0, strlen($allChars) - 1)]; + } + + // Shuffle the password to randomize position of required characters + return str_shuffle($password); + } + + public function table(Table $table): Table + { + return match($this->activeTab) { + 'mailboxes' => $this->mailboxesTable($table), + 'forwarders' => $this->forwardersTable($table), + 'autoresponders' => $this->autorespondersTable($table), + 'catchall' => $this->catchAllTable($table), + 'logs' => $this->emailLogsTable($table), + default => $this->mailboxesTable($table), + }; + } + + protected function mailboxesTable(Table $table): Table + { + return $table + ->query( + Mailbox::query() + ->whereHas('emailDomain.domain', fn (Builder $q) => $q->where('user_id', Auth::id())) + ->with('emailDomain.domain') + ) + ->columns([ + TextColumn::make('email') + ->label(__('Email Address')) + ->icon('heroicon-o-envelope') + ->iconColor('primary') + ->description(fn (Mailbox $record) => $record->name) + ->searchable() + ->sortable(), + TextColumn::make('quota_display') + ->label(__('Quota')) + ->getStateUsing(fn (Mailbox $record) => $record->quota_used_formatted . ' / ' . $record->quota_formatted) + ->description(fn (Mailbox $record) => $record->quota_percent . '% ' . __('used')) + ->color(fn (Mailbox $record) => match(true) { + $record->quota_percent >= 90 => 'danger', + $record->quota_percent >= 80 => 'warning', + default => 'gray', + }), + TextColumn::make('is_active') + ->label(__('Status')) + ->badge() + ->formatStateUsing(fn (bool $state) => $state ? __('Active') : __('Suspended')) + ->color(fn (bool $state) => $state ? 'success' : 'danger'), + TextColumn::make('last_login_at') + ->label(__('Last Login')) + ->since() + ->placeholder(__('Never')) + ->sortable(), + ]) + ->recordActions([ + Action::make('webmail') + ->label(__('Webmail')) + ->icon('heroicon-o-envelope-open') + ->color('success') + ->url(fn (Mailbox $record) => route('webmail.sso', $record)) + ->openUrlInNewTab(), + Action::make('info') + ->label(__('Info')) + ->icon('heroicon-o-information-circle') + ->color('info') + ->modalHeading(fn (Mailbox $record) => __('Connection Settings')) + ->modalDescription(fn (Mailbox $record) => $record->email) + ->modalSubmitAction(false) + ->modalCancelActionLabel(__('Close')) + ->infolist(function (Mailbox $record): array { + $serverHostname = \App\Models\Setting::get('mail_hostname') ?: request()->getHost(); + + return [ + Section::make(__('IMAP Settings')) + ->description(__('For receiving email in mail clients')) + ->icon('heroicon-o-inbox-arrow-down') + ->columns(3) + ->schema([ + TextEntry::make('imap_server') + ->label(__('Server')) + ->state($serverHostname) + ->copyable(), + TextEntry::make('imap_port') + ->label(__('Port')) + ->state('993') + ->copyable(), + TextEntry::make('imap_security') + ->label(__('Security')) + ->state('SSL/TLS') + ->badge() + ->color('success'), + ]), + Section::make(__('POP3 Settings')) + ->description(__('Alternative for receiving email')) + ->icon('heroicon-o-arrow-down-tray') + ->columns(3) + ->collapsed() + ->schema([ + TextEntry::make('pop3_server') + ->label(__('Server')) + ->state($serverHostname) + ->copyable(), + TextEntry::make('pop3_port') + ->label(__('Port')) + ->state('995') + ->copyable(), + TextEntry::make('pop3_security') + ->label(__('Security')) + ->state('SSL/TLS') + ->badge() + ->color('success'), + ]), + Section::make(__('SMTP Settings')) + ->description(__('For sending email')) + ->icon('heroicon-o-paper-airplane') + ->columns(3) + ->schema([ + TextEntry::make('smtp_server') + ->label(__('Server')) + ->state($serverHostname) + ->copyable(), + TextEntry::make('smtp_port') + ->label(__('Port')) + ->state('587') + ->copyable(), + TextEntry::make('smtp_security') + ->label(__('Security')) + ->state('STARTTLS') + ->badge() + ->color('warning'), + ]), + Section::make(__('Credentials')) + ->description(__('Use your email address and password')) + ->icon('heroicon-o-key') + ->columns(2) + ->schema([ + TextEntry::make('username') + ->label(__('Username')) + ->state($record->email) + ->copyable(), + TextEntry::make('password_hint') + ->label(__('Password')) + ->state(__('Your mailbox password')), + ]), + ]; + }), + Action::make('password') + ->label(__('Password')) + ->icon('heroicon-o-key') + ->color('warning') + ->modalHeading(__('Change Password')) + ->modalDescription(fn (Mailbox $record) => $record->email) + ->modalIcon('heroicon-o-key') + ->modalIconColor('warning') + ->modalSubmitActionLabel(__('Change Password')) + ->form([ + TextInput::make('password') + ->label(__('New Password')) + ->password() + ->revealable() + ->required() + ->minLength(8) + ->rules([ + 'regex:/[a-z]/', // lowercase + 'regex:/[A-Z]/', // uppercase + 'regex:/[0-9]/', // number + ]) + ->default(fn () => $this->generateSecurePassword()) + ->suffixActions([ + Action::make('generatePassword') + ->icon('heroicon-o-arrow-path') + ->tooltip(__('Generate secure password')) + ->action(fn ($set) => $set('password', $this->generateSecurePassword())), + Action::make('copyPassword') + ->icon('heroicon-o-clipboard-document') + ->tooltip(__('Copy to clipboard')) + ->action(function ($state, $livewire) { + if ($state) { + $escaped = addslashes($state); + $livewire->js("navigator.clipboard.writeText('{$escaped}')"); + Notification::make() + ->title(__('Copied to clipboard')) + ->success() + ->duration(2000) + ->send(); + } + }), + ]) + ->helperText(__('Minimum 8 characters with uppercase, lowercase, and numbers')), + ]) + ->action(fn (Mailbox $record, array $data) => $this->changeMailboxPasswordDirect($record, $data['password'])), + Action::make('toggle') + ->label(fn (Mailbox $record) => $record->is_active ? __('Suspend') : __('Enable')) + ->icon(fn (Mailbox $record) => $record->is_active ? 'heroicon-o-pause' : 'heroicon-o-play') + ->color('gray') + ->action(fn (Mailbox $record) => $this->toggleMailbox($record->id)), + Action::make('delete') + ->label(__('Delete')) + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->modalHeading(__('Delete Mailbox')) + ->modalDescription(fn (Mailbox $record) => __("Delete ':email'? All emails will be lost.", ['email' => $record->email])) + ->modalIcon('heroicon-o-trash') + ->modalIconColor('danger') + ->modalSubmitActionLabel(__('Delete Mailbox')) + ->form([ + Toggle::make('delete_files') + ->label(__('Also delete all email files')) + ->default(false) + ->helperText(__('Warning: This cannot be undone')), + ]) + ->action(fn (Mailbox $record, array $data) => $this->deleteMailboxDirect($record, $data['delete_files'] ?? false)), + ]) + ->emptyStateHeading(__('No mailboxes yet')) + ->emptyStateDescription(__('Enable email for a domain first, then create a mailbox.')) + ->emptyStateIcon('heroicon-o-envelope') + ->striped(); + } + + protected function forwardersTable(Table $table): Table + { + return $table + ->query( + EmailForwarder::query() + ->whereHas('emailDomain.domain', fn (Builder $q) => $q->where('user_id', Auth::id())) + ->with('emailDomain.domain') + ) + ->columns([ + TextColumn::make('email') + ->label(__('From')) + ->icon('heroicon-o-arrow-right') + ->iconColor('primary') + ->searchable() + ->sortable(), + TextColumn::make('destinations') + ->label(__('Forward To')) + ->badge() + ->separator(',') + ->color('gray'), + TextColumn::make('is_active') + ->label(__('Status')) + ->badge() + ->formatStateUsing(fn (bool $state) => $state ? __('Active') : __('Disabled')) + ->color(fn (bool $state) => $state ? 'success' : 'danger'), + ]) + ->recordActions([ + Action::make('edit') + ->label(__('Edit')) + ->icon('heroicon-o-pencil') + ->color('info') + ->modalHeading(__('Edit Forwarder')) + ->modalDescription(fn (EmailForwarder $record) => $record->email) + ->modalIcon('heroicon-o-pencil') + ->modalIconColor('info') + ->modalSubmitActionLabel(__('Save Changes')) + ->fillForm(fn (EmailForwarder $record) => [ + 'destinations' => implode(', ', $record->destinations ?? []), + ]) + ->form([ + TextInput::make('destinations') + ->label(__('Forward To')) + ->required() + ->helperText(__('Comma-separated email addresses')), + ]) + ->action(fn (EmailForwarder $record, array $data) => $this->updateForwarderDirect($record, $data['destinations'])), + Action::make('toggle') + ->label(fn (EmailForwarder $record) => $record->is_active ? __('Disable') : __('Enable')) + ->icon(fn (EmailForwarder $record) => $record->is_active ? 'heroicon-o-pause' : 'heroicon-o-play') + ->color('gray') + ->action(fn (EmailForwarder $record) => $this->toggleForwarder($record->id)), + Action::make('delete') + ->label(__('Delete')) + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->modalHeading(__('Delete Forwarder')) + ->modalDescription(fn (EmailForwarder $record) => __("Delete forwarder ':email'?", ['email' => $record->email])) + ->modalIcon('heroicon-o-trash') + ->modalIconColor('danger') + ->modalSubmitActionLabel(__('Delete Forwarder')) + ->action(fn (EmailForwarder $record) => $this->deleteForwarderDirect($record)), + ]) + ->emptyStateHeading(__('No forwarders yet')) + ->emptyStateDescription(__('Create a forwarder to redirect emails to another address.')) + ->emptyStateIcon('heroicon-o-arrow-right') + ->striped(); + } + + protected function autorespondersTable(Table $table): Table + { + return $table + ->query( + Autoresponder::query() + ->whereHas('mailbox.emailDomain.domain', fn (Builder $q) => $q->where('user_id', Auth::id())) + ->with('mailbox.emailDomain.domain') + ) + ->columns([ + TextColumn::make('mailbox.email') + ->label(__('Email')) + ->icon('heroicon-o-envelope') + ->iconColor('primary') + ->searchable() + ->sortable(), + TextColumn::make('subject') + ->label(__('Subject')) + ->limit(30) + ->searchable(), + TextColumn::make('status') + ->label(__('Status')) + ->badge() + ->getStateUsing(function (Autoresponder $record): string { + if (!$record->is_active) { + return __('Disabled'); + } + if ($record->isCurrentlyActive()) { + return __('Active'); + } + if ($record->start_date && now()->lt($record->start_date)) { + return __('Scheduled'); + } + return __('Expired'); + }) + ->color(function (Autoresponder $record): string { + if (!$record->is_active) { + return 'gray'; + } + if ($record->isCurrentlyActive()) { + return 'success'; + } + if ($record->start_date && now()->lt($record->start_date)) { + return 'warning'; + } + return 'danger'; + }), + TextColumn::make('start_date') + ->label(__('From')) + ->date('M d, Y') + ->placeholder(__('No start date')), + TextColumn::make('end_date') + ->label(__('Until')) + ->date('M d, Y') + ->placeholder(__('No end date')), + ]) + ->recordActions([ + Action::make('edit') + ->label(__('Edit')) + ->icon('heroicon-o-pencil') + ->color('info') + ->modalHeading(__('Edit Autoresponder')) + ->modalDescription(fn (Autoresponder $record) => $record->mailbox->email) + ->modalIcon('heroicon-o-clock') + ->modalIconColor('info') + ->modalSubmitActionLabel(__('Save Changes')) + ->fillForm(fn (Autoresponder $record) => [ + 'subject' => $record->subject, + 'message' => $record->message, + 'start_date' => $record->start_date?->format('Y-m-d'), + 'end_date' => $record->end_date?->format('Y-m-d'), + 'is_active' => $record->is_active, + ]) + ->form([ + TextInput::make('subject') + ->label(__('Subject')) + ->required() + ->maxLength(255), + Textarea::make('message') + ->label(__('Message')) + ->required() + ->rows(5) + ->helperText(__('The automatic reply message')), + DatePicker::make('start_date') + ->label(__('Start Date')) + ->helperText(__('Leave empty to start immediately')), + DatePicker::make('end_date') + ->label(__('End Date')) + ->helperText(__('Leave empty for no end date')), + Toggle::make('is_active') + ->label(__('Active')) + ->default(true), + ]) + ->action(fn (Autoresponder $record, array $data) => $this->updateAutoresponder($record, $data)), + Action::make('toggle') + ->label(fn (Autoresponder $record) => $record->is_active ? __('Disable') : __('Enable')) + ->icon(fn (Autoresponder $record) => $record->is_active ? 'heroicon-o-pause' : 'heroicon-o-play') + ->color('gray') + ->action(fn (Autoresponder $record) => $this->toggleAutoresponder($record)), + Action::make('delete') + ->label(__('Delete')) + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->modalHeading(__('Delete Autoresponder')) + ->modalDescription(fn (Autoresponder $record) => __("Delete autoresponder for ':email'?", ['email' => $record->mailbox->email])) + ->modalIcon('heroicon-o-trash') + ->modalIconColor('danger') + ->modalSubmitActionLabel(__('Delete')) + ->action(fn (Autoresponder $record) => $this->deleteAutoresponder($record)), + ]) + ->emptyStateHeading(__('No autoresponders')) + ->emptyStateDescription(__('Set up vacation messages for your mailboxes.')) + ->emptyStateIcon('heroicon-o-clock') + ->striped(); + } + + protected function catchAllTable(Table $table): Table + { + return $table + ->query( + EmailDomain::query() + ->whereHas('domain', fn (Builder $q) => $q->where('user_id', Auth::id())) + ->with('domain') + ) + ->columns([ + TextColumn::make('domain.domain') + ->label(__('Domain')) + ->icon('heroicon-o-globe-alt') + ->iconColor('primary') + ->searchable() + ->sortable(), + TextColumn::make('catch_all_enabled') + ->label(__('Status')) + ->badge() + ->formatStateUsing(fn (bool $state) => $state ? __('Enabled') : __('Disabled')) + ->color(fn (bool $state) => $state ? 'success' : 'gray'), + TextColumn::make('catch_all_address') + ->label(__('Forward To')) + ->placeholder(__('Not configured')) + ->icon('heroicon-o-envelope') + ->iconColor('info'), + ]) + ->recordActions([ + Action::make('configure') + ->label(__('Configure')) + ->icon('heroicon-o-cog-6-tooth') + ->color('info') + ->modalHeading(__('Configure Catch-All')) + ->modalDescription(fn (EmailDomain $record) => $record->domain->domain) + ->modalIcon('heroicon-o-inbox-stack') + ->modalIconColor('info') + ->modalSubmitActionLabel(__('Save')) + ->fillForm(fn (EmailDomain $record) => [ + 'enabled' => $record->catch_all_enabled, + 'address' => $record->catch_all_address, + ]) + ->form([ + Toggle::make('enabled') + ->label(__('Enable Catch-All')) + ->helperText(__('Receive emails sent to any non-existent address on this domain')), + Select::make('address') + ->label(__('Deliver To')) + ->options(function (EmailDomain $record) { + return Mailbox::where('email_domain_id', $record->id) + ->pluck('local_part') + ->mapWithKeys(fn ($local) => [ + $local . '@' . $record->domain->domain => $local . '@' . $record->domain->domain + ]) + ->toArray(); + }) + ->searchable() + ->helperText(__('Select a mailbox to receive catch-all emails')), + ]) + ->action(fn (EmailDomain $record, array $data) => $this->updateCatchAll($record, $data)), + ]) + ->emptyStateHeading(__('No email domains')) + ->emptyStateDescription(__('Create a mailbox first to enable email for a domain.')) + ->emptyStateIcon('heroicon-o-inbox-stack') + ->striped(); + } + + protected function emailLogsTable(Table $table): Table + { + // Read mail logs (last 100 entries) + $logs = $this->getEmailLogs(); + + return $table + ->records(fn () => $logs) + ->columns([ + TextColumn::make('timestamp') + ->label(__('Time')) + ->dateTime('M d, H:i:s') + ->sortable(), + TextColumn::make('status') + ->label(__('Status')) + ->badge() + ->color(fn (array $record) => match($record['status'] ?? '') { + 'sent', 'delivered' => 'success', + 'deferred' => 'warning', + 'bounced', 'rejected', 'failed' => 'danger', + default => 'gray', + }), + TextColumn::make('from') + ->label(__('From')) + ->limit(30) + ->searchable(), + TextColumn::make('to') + ->label(__('To')) + ->limit(30) + ->searchable(), + TextColumn::make('subject') + ->label(__('Subject')) + ->limit(40) + ->placeholder(__('(no subject)')), + ]) + ->recordActions([ + Action::make('details') + ->label(__('Details')) + ->icon('heroicon-o-information-circle') + ->color('gray') + ->modalHeading(__('Email Details')) + ->modalSubmitAction(false) + ->modalCancelActionLabel(__('Close')) + ->infolist(fn (array $record): array => [ + Section::make(__('Message Info')) + ->columns(2) + ->schema([ + TextEntry::make('from') + ->label(__('From')) + ->state($record['from'] ?? '-') + ->copyable(), + TextEntry::make('to') + ->label(__('To')) + ->state($record['to'] ?? '-') + ->copyable(), + TextEntry::make('subject') + ->label(__('Subject')) + ->state($record['subject'] ?? '-'), + TextEntry::make('timestamp') + ->label(__('Time')) + ->state(isset($record['timestamp']) ? date('Y-m-d H:i:s', $record['timestamp']) : '-'), + ]), + Section::make(__('Delivery Status')) + ->schema([ + TextEntry::make('status') + ->label(__('Status')) + ->state($record['status'] ?? '-') + ->badge() + ->color(match($record['status'] ?? '') { + 'sent', 'delivered' => 'success', + 'deferred' => 'warning', + 'bounced', 'rejected', 'failed' => 'danger', + default => 'gray', + }), + TextEntry::make('message') + ->label(__('Message')) + ->state($record['message'] ?? '-'), + ]), + ]), + ]) + ->emptyStateHeading(__('No email logs')) + ->emptyStateDescription(__('Email activity will appear here once emails are sent or received.')) + ->emptyStateIcon('heroicon-o-document-text') + ->striped() + ->defaultSort('timestamp', 'desc'); + } + + protected function getHeaderActions(): array + { + return [ + $this->getTourAction(), + $this->createMailboxAction(), + $this->createForwarderAction(), + $this->createAutoresponderAction(), + $this->showCredentialsAction(), + ]; + } + + protected function showCredentialsAction(): Action + { + return Action::make('showCredentials') + ->label(__('Credentials')) + ->hidden() + ->modalHeading(__('Mailbox Credentials')) + ->modalDescription(__('Save these credentials! The password won\'t be shown again.')) + ->modalIcon('heroicon-o-check-circle') + ->modalIconColor('success') + ->modalSubmitAction(false) + ->modalCancelActionLabel(__('Done')) + ->infolist([ + Section::make(__('Email Address')) + ->schema([ + TextEntry::make('email') + ->hiddenLabel() + ->state(fn () => $this->credEmail) + ->copyable() + ->fontFamily('mono'), + ]), + Section::make(__('Password')) + ->schema([ + TextEntry::make('password') + ->hiddenLabel() + ->state(fn () => $this->credPassword) + ->copyable() + ->fontFamily('mono'), + ]), + ]); + } + + protected function getOrCreateEmailDomain(Domain $domain): EmailDomain + { + $emailDomain = $domain->emailDomain; + + if (!$emailDomain) { + // Enable email for this domain on the server + $this->getAgent()->emailEnableDomain($this->getUsername(), $domain->domain); + + // Create EmailDomain record + $emailDomain = EmailDomain::create([ + 'domain_id' => $domain->id, + 'is_active' => true, + ]); + + // Generate DKIM + try { + $dkimResult = $this->getAgent()->emailGenerateDkim($this->getUsername(), $domain->domain); + if (isset($dkimResult['public_key'])) { + $selector = $dkimResult['selector'] ?? 'default'; + $publicKey = $dkimResult['public_key']; + + $emailDomain->update([ + 'dkim_selector' => $selector, + 'dkim_public_key' => $publicKey, + 'dkim_private_key' => $dkimResult['private_key'] ?? null, + ]); + + // Add DKIM record to DNS + $dkimRecord = DnsRecord::where('domain_id', $domain->id) + ->where('name', "{$selector}._domainkey") + ->where('type', 'TXT') + ->first(); + + // Format the DKIM public key (remove headers and newlines) + $cleanKey = str_replace([ + '-----BEGIN PUBLIC KEY-----', + '-----END PUBLIC KEY-----', + "\n", + "\r", + ], '', $publicKey); + + $dkimContent = "v=DKIM1; k=rsa; p={$cleanKey}"; + + if (!$dkimRecord) { + DnsRecord::create([ + 'domain_id' => $domain->id, + 'name' => "{$selector}._domainkey", + 'type' => 'TXT', + 'content' => $dkimContent, + 'ttl' => 3600, + ]); + } else { + $dkimRecord->update(['content' => $dkimContent]); + } + + // Regenerate DNS zone to include the new DKIM record + $this->regenerateDnsZone($domain); + } + } catch (Exception $e) { + // DKIM generation failed, but email can still work + } + } + + return $emailDomain; + } + + protected function regenerateDnsZone(Domain $domain): void + { + try { + $records = DnsRecord::where('domain_id', $domain->id)->get()->toArray(); + $settings = \App\Models\DnsSetting::getAll(); + $hostname = gethostname() ?: 'localhost'; + $serverIp = trim(shell_exec("hostname -I | awk '{print \$1}'") ?? '') ?: '127.0.0.1'; + + $this->getAgent()->send('dns.sync_zone', [ + 'domain' => $domain->domain, + 'records' => $records, + 'ns1' => $settings['ns1'] ?? "ns1.{$hostname}", + 'ns2' => $settings['ns2'] ?? "ns2.{$hostname}", + 'admin_email' => $settings['admin_email'] ?? "admin.{$hostname}", + 'default_ip' => $settings['default_ip'] ?? $serverIp, + 'default_ttl' => $settings['default_ttl'] ?? 3600, + ]); + } catch (Exception $e) { + // Log but don't fail - DNS zone regeneration is not critical + } + } + + // Mailbox Actions + protected function createMailboxAction(): Action + { + return Action::make('createMailbox') + ->label(__('New Mailbox')) + ->icon('heroicon-o-plus-circle') + ->color('success') + ->visible(fn () => Domain::where('user_id', Auth::id())->exists()) + ->modalHeading(__('Create New Mailbox')) + ->modalDescription(__('Create an email account for one of your domains')) + ->modalIcon('heroicon-o-envelope') + ->modalIconColor('success') + ->modalSubmitActionLabel(__('Create Mailbox')) + ->form([ + Select::make('domain_id') + ->label(__('Domain')) + ->options(fn () => Domain::where('user_id', Auth::id())->pluck('domain', 'id')->toArray()) + ->required() + ->searchable(), + TextInput::make('local_part') + ->label(__('Email Address')) + ->required() + ->regex('/^[a-zA-Z0-9._%+-]+$/') + ->maxLength(64) + ->helperText(__('The part before the @ symbol')), + TextInput::make('name') + ->label(__('Display Name')) + ->maxLength(255), + TextInput::make('password') + ->label(__('Password')) + ->password() + ->revealable() + ->required() + ->minLength(8) + ->rules([ + 'regex:/[a-z]/', // lowercase + 'regex:/[A-Z]/', // uppercase + 'regex:/[0-9]/', // number + ]) + ->default(fn () => $this->generateSecurePassword()) + ->suffixActions([ + Action::make('generatePassword') + ->icon('heroicon-o-arrow-path') + ->tooltip(__('Generate secure password')) + ->action(fn ($set) => $set('password', $this->generateSecurePassword())), + Action::make('copyPassword') + ->icon('heroicon-o-clipboard-document') + ->tooltip(__('Copy to clipboard')) + ->action(function ($state, $livewire) { + if ($state) { + $escaped = addslashes($state); + $livewire->js("navigator.clipboard.writeText('{$escaped}')"); + Notification::make() + ->title(__('Copied to clipboard')) + ->success() + ->duration(2000) + ->send(); + } + }), + ]) + ->helperText(__('Minimum 8 characters with uppercase, lowercase, and numbers')), + TextInput::make('quota_mb') + ->label(__('Quota (MB)')) + ->numeric() + ->default(1024) + ->minValue(100) + ->maxValue(10240) + ->helperText(__('Storage limit in megabytes')), + ]) + ->action(function (array $data): void { + $domain = Domain::where('user_id', Auth::id())->find($data['domain_id']); + if (!$domain) { + Notification::make()->title(__('Domain not found'))->danger()->send(); + return; + } + + try { + // Get or create EmailDomain (enables email on server if needed) + $emailDomain = $this->getOrCreateEmailDomain($domain); + + $email = $data['local_part'] . '@' . $domain->domain; + $quotaBytes = (int)$data['quota_mb'] * 1024 * 1024; + + if (Mailbox::where('email_domain_id', $emailDomain->id)->where('local_part', $data['local_part'])->exists()) { + Notification::make()->title(__('Mailbox already exists'))->danger()->send(); + return; + } + + $result = $this->getAgent()->mailboxCreate( + $this->getUsername(), + $email, + $data['password'], + $quotaBytes + ); + + Mailbox::create([ + 'email_domain_id' => $emailDomain->id, + 'user_id' => Auth::id(), + 'local_part' => $data['local_part'], + 'password_hash' => $result['password_hash'] ?? '', + 'password_encrypted' => Crypt::encryptString($data['password']), + 'maildir_path' => $result['maildir_path'] ?? null, + 'system_uid' => $result['uid'] ?? null, + 'system_gid' => $result['gid'] ?? null, + 'name' => $data['name'], + 'quota_bytes' => $quotaBytes, + 'is_active' => true, + ]); + + $this->credEmail = $email; + $this->credPassword = $data['password']; + + Notification::make()->title(__('Mailbox created'))->success()->send(); + + $this->mountAction('showCredentials'); + } catch (Exception $e) { + Notification::make()->title(__('Error creating mailbox'))->body($e->getMessage())->danger()->send(); + } + }); + } + + public function changeMailboxPasswordDirect(Mailbox $mailbox, string $password): void + { + try { + $result = $this->getAgent()->mailboxChangePassword( + $this->getUsername(), + $mailbox->email, + $password + ); + + $mailbox->update([ + 'password_hash' => $result['password_hash'] ?? '', + 'password_encrypted' => Crypt::encryptString($password), + ]); + + $this->credEmail = $mailbox->email; + $this->credPassword = $password; + + Notification::make()->title(__('Password changed'))->success()->send(); + + $this->mountAction('showCredentials'); + } catch (Exception $e) { + Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); + } + } + + public function toggleMailbox(int $mailboxId): void + { + $mailbox = Mailbox::with('emailDomain.domain')->find($mailboxId); + if (!$mailbox) { + Notification::make()->title(__('Mailbox not found'))->danger()->send(); + return; + } + + try { + $newStatus = !$mailbox->is_active; + $this->getAgent()->mailboxToggle($this->getUsername(), $mailbox->email, $newStatus); + $mailbox->update(['is_active' => $newStatus]); + + Notification::make() + ->title($newStatus ? __('Mailbox enabled') : __('Mailbox disabled')) + ->success() + ->send(); + } catch (Exception $e) { + Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); + } + } + + public function deleteMailboxDirect(Mailbox $mailbox, bool $deleteFiles): void + { + try { + $this->getAgent()->mailboxDelete( + $this->getUsername(), + $mailbox->email, + $deleteFiles, + $mailbox->maildir_path + ); + + $mailbox->delete(); + + Notification::make()->title(__('Mailbox deleted'))->success()->send(); + } catch (Exception $e) { + Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); + } + } + + // Forwarder Actions + protected function createForwarderAction(): Action + { + return Action::make('createForwarder') + ->label(__('New Forwarder')) + ->icon('heroicon-o-arrow-right-circle') + ->color('info') + ->visible(fn () => Domain::where('user_id', Auth::id())->exists()) + ->modalHeading(__('Create New Forwarder')) + ->modalDescription(__('Redirect emails from one address to another')) + ->modalIcon('heroicon-o-arrow-right') + ->modalIconColor('info') + ->modalSubmitActionLabel(__('Create Forwarder')) + ->form([ + Select::make('domain_id') + ->label(__('Domain')) + ->options(fn () => Domain::where('user_id', Auth::id())->pluck('domain', 'id')->toArray()) + ->required() + ->searchable(), + TextInput::make('local_part') + ->label(__('Email Address')) + ->required() + ->regex('/^[a-zA-Z0-9._%+-]+$/') + ->maxLength(64) + ->helperText(__('The part before the @ symbol')), + TextInput::make('destinations') + ->label(__('Forward To')) + ->required() + ->helperText(__('Comma-separated email addresses to forward to')), + ]) + ->action(function (array $data): void { + $domain = Domain::where('user_id', Auth::id())->find($data['domain_id']); + if (!$domain) { + Notification::make()->title(__('Domain not found'))->danger()->send(); + return; + } + + $destinations = array_map('trim', explode(',', $data['destinations'])); + $destinations = array_filter($destinations, fn ($d) => filter_var($d, FILTER_VALIDATE_EMAIL)); + + if (empty($destinations)) { + Notification::make()->title(__('Invalid destination emails'))->danger()->send(); + return; + } + + try { + // Get or create EmailDomain (enables email on server if needed) + $emailDomain = $this->getOrCreateEmailDomain($domain); + + $email = $data['local_part'] . '@' . $domain->domain; + + if (EmailForwarder::where('email_domain_id', $emailDomain->id)->where('local_part', $data['local_part'])->exists()) { + Notification::make()->title(__('Forwarder already exists'))->danger()->send(); + return; + } + + if (Mailbox::where('email_domain_id', $emailDomain->id)->where('local_part', $data['local_part'])->exists()) { + Notification::make()->title(__('A mailbox with this address already exists'))->danger()->send(); + return; + } + + $this->getAgent()->send('email.forwarder_create', [ + 'username' => $this->getUsername(), + 'email' => $email, + 'destinations' => $destinations, + ]); + + EmailForwarder::create([ + 'email_domain_id' => $emailDomain->id, + 'user_id' => Auth::id(), + 'local_part' => $data['local_part'], + 'destinations' => $destinations, + 'is_active' => true, + ]); + + Notification::make()->title(__('Forwarder created'))->success()->send(); + } catch (Exception $e) { + Notification::make()->title(__('Error creating forwarder'))->body($e->getMessage())->danger()->send(); + } + }); + } + + public function updateForwarderDirect(EmailForwarder $forwarder, string $destinationsString): void + { + $destinations = array_map('trim', explode(',', $destinationsString)); + $destinations = array_filter($destinations, fn ($d) => filter_var($d, FILTER_VALIDATE_EMAIL)); + + if (empty($destinations)) { + Notification::make()->title(__('Invalid destination emails'))->danger()->send(); + return; + } + + try { + $this->getAgent()->send('email.forwarder_update', [ + 'username' => $this->getUsername(), + 'email' => $forwarder->email, + 'destinations' => $destinations, + ]); + + $forwarder->update(['destinations' => $destinations]); + + Notification::make()->title(__('Forwarder updated'))->success()->send(); + } catch (Exception $e) { + Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); + } + } + + public function toggleForwarder(int $forwarderId): void + { + $forwarder = EmailForwarder::with('emailDomain.domain')->find($forwarderId); + if (!$forwarder) { + Notification::make()->title(__('Forwarder not found'))->danger()->send(); + return; + } + + try { + $newStatus = !$forwarder->is_active; + $this->getAgent()->send('email.forwarder_toggle', [ + 'username' => $this->getUsername(), + 'email' => $forwarder->email, + 'active' => $newStatus, + ]); + $forwarder->update(['is_active' => $newStatus]); + + Notification::make() + ->title($newStatus ? __('Forwarder enabled') : __('Forwarder disabled')) + ->success() + ->send(); + } catch (Exception $e) { + Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); + } + } + + public function deleteForwarderDirect(EmailForwarder $forwarder): void + { + try { + $this->getAgent()->send('email.forwarder_delete', [ + 'username' => $this->getUsername(), + 'email' => $forwarder->email, + ]); + + $forwarder->delete(); + + Notification::make()->title(__('Forwarder deleted'))->success()->send(); + } catch (Exception $e) { + Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); + } + } + + // Autoresponder Actions + protected function createAutoresponderAction(): Action + { + return Action::make('createAutoresponder') + ->label(__('New Autoresponder')) + ->icon('heroicon-o-clock') + ->color('warning') + ->visible(fn () => Mailbox::whereHas('emailDomain.domain', fn ($q) => $q->where('user_id', Auth::id()))->exists()) + ->modalHeading(__('Create Autoresponder')) + ->modalDescription(__('Set up an automatic vacation reply')) + ->modalIcon('heroicon-o-clock') + ->modalIconColor('warning') + ->modalSubmitActionLabel(__('Create')) + ->form([ + Select::make('mailbox_id') + ->label(__('Mailbox')) + ->options(fn () => Mailbox::whereHas('emailDomain.domain', fn ($q) => $q->where('user_id', Auth::id())) + ->with('emailDomain.domain') + ->get() + ->mapWithKeys(fn ($m) => [$m->id => $m->email]) + ->toArray()) + ->required() + ->searchable(), + TextInput::make('subject') + ->label(__('Subject')) + ->required() + ->default(__('Out of Office')) + ->maxLength(255), + Textarea::make('message') + ->label(__('Message')) + ->required() + ->rows(5) + ->default(__("Thank you for your email. I am currently out of the office and will respond to your message upon my return.\n\nBest regards")) + ->helperText(__('The automatic reply message')), + DatePicker::make('start_date') + ->label(__('Start Date')) + ->helperText(__('Leave empty to start immediately')), + DatePicker::make('end_date') + ->label(__('End Date')) + ->helperText(__('Leave empty for no end date')), + ]) + ->action(function (array $data): void { + $mailbox = Mailbox::whereHas('emailDomain.domain', fn ($q) => $q->where('user_id', Auth::id())) + ->find($data['mailbox_id']); + + if (!$mailbox) { + Notification::make()->title(__('Mailbox not found'))->danger()->send(); + return; + } + + // Check if autoresponder already exists for this mailbox + if (Autoresponder::where('mailbox_id', $mailbox->id)->exists()) { + Notification::make() + ->title(__('Autoresponder already exists')) + ->body(__('Edit the existing autoresponder instead.')) + ->danger() + ->send(); + return; + } + + try { + // Create autoresponder in database + $autoresponder = Autoresponder::create([ + 'mailbox_id' => $mailbox->id, + 'subject' => $data['subject'], + 'message' => $data['message'], + 'start_date' => $data['start_date'] ?? null, + 'end_date' => $data['end_date'] ?? null, + 'is_active' => true, + ]); + + // Configure on mail server via agent + $this->getAgent()->send('email.autoresponder_set', [ + 'username' => $this->getUsername(), + 'email' => $mailbox->email, + 'subject' => $data['subject'], + 'message' => $data['message'], + 'start_date' => $data['start_date'] ?? null, + 'end_date' => $data['end_date'] ?? null, + 'active' => true, + ]); + + Notification::make()->title(__('Autoresponder created'))->success()->send(); + + $this->setTab('autoresponders'); + } catch (Exception $e) { + Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); + } + }); + } + + public function updateAutoresponder(Autoresponder $autoresponder, array $data): void + { + try { + $autoresponder->update([ + 'subject' => $data['subject'], + 'message' => $data['message'], + 'start_date' => $data['start_date'] ?? null, + 'end_date' => $data['end_date'] ?? null, + 'is_active' => $data['is_active'] ?? true, + ]); + + // Update on mail server + $this->getAgent()->send('email.autoresponder_set', [ + 'username' => $this->getUsername(), + 'email' => $autoresponder->mailbox->email, + 'subject' => $data['subject'], + 'message' => $data['message'], + 'start_date' => $data['start_date'] ?? null, + 'end_date' => $data['end_date'] ?? null, + 'active' => $data['is_active'] ?? true, + ]); + + Notification::make()->title(__('Autoresponder updated'))->success()->send(); + } catch (Exception $e) { + Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); + } + } + + public function toggleAutoresponder(Autoresponder $autoresponder): void + { + try { + $newStatus = !$autoresponder->is_active; + $autoresponder->update(['is_active' => $newStatus]); + + // Update on mail server + $this->getAgent()->send('email.autoresponder_toggle', [ + 'username' => $this->getUsername(), + 'email' => $autoresponder->mailbox->email, + 'active' => $newStatus, + ]); + + Notification::make() + ->title($newStatus ? __('Autoresponder enabled') : __('Autoresponder disabled')) + ->success() + ->send(); + } catch (Exception $e) { + Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); + } + } + + public function deleteAutoresponder(Autoresponder $autoresponder): void + { + try { + // Remove from mail server + $this->getAgent()->send('email.autoresponder_delete', [ + 'username' => $this->getUsername(), + 'email' => $autoresponder->mailbox->email, + ]); + + $autoresponder->delete(); + + Notification::make()->title(__('Autoresponder deleted'))->success()->send(); + } catch (Exception $e) { + Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); + } + } + + // Helper methods for counts + public function getMailboxesCount(): int + { + return Mailbox::whereHas('emailDomain.domain', fn ($q) => $q->where('user_id', Auth::id()))->count(); + } + + public function getForwardersCount(): int + { + return EmailForwarder::whereHas('emailDomain.domain', fn ($q) => $q->where('user_id', Auth::id()))->count(); + } + + public function getCatchAllCount(): int + { + return EmailDomain::whereHas('domain', fn ($q) => $q->where('user_id', Auth::id()))->count(); + } + + // Catch-all methods + public function updateCatchAll(EmailDomain $emailDomain, array $data): void + { + try { + $enabled = $data['enabled'] ?? false; + $address = $data['address'] ?? null; + + if ($enabled && empty($address)) { + Notification::make() + ->title(__('Error')) + ->body(__('Please select a mailbox to receive catch-all emails')) + ->danger() + ->send(); + return; + } + + // Update in Postfix virtual alias maps + $this->getAgent()->send('email.catchall_update', [ + 'username' => $this->getUsername(), + 'domain' => $emailDomain->domain->domain, + 'enabled' => $enabled, + 'address' => $address, + ]); + + $emailDomain->update([ + 'catch_all_enabled' => $enabled, + 'catch_all_address' => $enabled ? $address : null, + ]); + + Notification::make() + ->title($enabled ? __('Catch-all enabled') : __('Catch-all disabled')) + ->success() + ->send(); + } catch (Exception $e) { + Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); + } + } + + // Email Logs + public function getEmailLogs(): array + { + try { + $result = $this->getAgent()->send('email.get_logs', [ + 'username' => $this->getUsername(), + 'limit' => 100, + ]); + + return $result['logs'] ?? []; + } catch (Exception $e) { + return []; + } + } + + // Email Usage Stats + public function getEmailUsageStats(): array + { + $domains = EmailDomain::whereHas('domain', fn ($q) => $q->where('user_id', Auth::id())) + ->with(['mailboxes', 'domain']) + ->get(); + + $totalMailboxes = 0; + $totalUsed = 0; + $totalQuota = 0; + + foreach ($domains as $domain) { + $totalMailboxes += $domain->mailboxes->count(); + $totalUsed += $domain->mailboxes->sum('quota_used_bytes'); + $totalQuota += $domain->mailboxes->sum('quota_bytes'); + } + + return [ + 'domains' => $domains->count(), + 'mailboxes' => $totalMailboxes, + 'used_bytes' => $totalUsed, + 'quota_bytes' => $totalQuota, + 'used_formatted' => $this->formatBytes($totalUsed), + 'quota_formatted' => $this->formatBytes($totalQuota), + 'percent' => $totalQuota > 0 ? round(($totalUsed / $totalQuota) * 100, 1) : 0, + ]; + } + + protected function formatBytes(int $bytes): string + { + if ($bytes < 1024) return $bytes . ' B'; + if ($bytes < 1048576) return round($bytes / 1024, 1) . ' KB'; + if ($bytes < 1073741824) return round($bytes / 1048576, 1) . ' MB'; + return round($bytes / 1073741824, 1) . ' GB'; + } +} diff --git a/app/Filament/Jabali/Pages/Files.php b/app/Filament/Jabali/Pages/Files.php new file mode 100644 index 0000000..be44746 --- /dev/null +++ b/app/Filament/Jabali/Pages/Files.php @@ -0,0 +1,1128 @@ + ['as' => 'path', 'except' => '']]; + + public array $items = []; + + protected ?AgentClient $agent = null; + + public function getTitle(): string|Htmlable + { + return 'File Manager'; + } + + public function getAgent(): AgentClient + { + if ($this->agent === null) { + $this->agent = new AgentClient; + } + + return $this->agent; + } + + public function mount(): void + { + // Check if path is provided in URL + $path = request()->get('path'); + if ($path !== null && $path !== '') { + try { + $this->currentPath = $this->sanitizePath((string) $path); + } catch (Exception $e) { + // Invalid path from URL - reset to home directory + $this->currentPath = ''; + Notification::make() + ->title('Invalid path') + ->body('The requested path is not allowed.') + ->danger() + ->send(); + } + } + $this->loadDirectory(); + } + + protected function getHeaderActions(): array + { + return [ + $this->getTourAction(), + ]; + } + + public function getUsername(): string + { + return Auth::user()->username; + } + + public function getMaxUploadSizeMb(): int + { + return (int) DnsSetting::get('max_upload_size_mb', 100); + } + + public function showUploadError(): void + { + Notification::make() + ->title(__('Upload failed')) + ->body(__('File exceeds maximum upload size of :size MB', ['size' => $this->getMaxUploadSizeMb()])) + ->danger() + ->send(); + } + + /** + * Sanitize and validate a path to prevent directory traversal attacks. + * Ensures the path stays within the user's home directory. + * + * @param string $path The path to sanitize + * @return string The sanitized path + * + * @throws Exception If the path is invalid or attempts traversal + */ + protected function sanitizePath(string $path): string + { + // Remove null bytes + $path = str_replace("\0", '', $path); + + // Normalize directory separators + $path = str_replace('\\', '/', $path); + + // Remove leading/trailing slashes and whitespace + $path = trim($path, "/ \t\n\r"); + + // Block any path containing .. sequences (traversal attempt) + if (preg_match('/(?:^|\/)\.\.(\/|$)/', $path)) { + throw new Exception('Invalid path: directory traversal not allowed'); + } + + // Block absolute paths + if (str_starts_with($path, '/')) { + throw new Exception('Invalid path: absolute paths not allowed'); + } + + // Block paths starting with ~ + if (str_starts_with($path, '~')) { + throw new Exception('Invalid path: home directory shortcuts not allowed'); + } + + // Remove redundant slashes and normalize + $path = preg_replace('#/+#', '/', $path); + + // Remove . segments + $parts = explode('/', $path); + $parts = array_filter($parts, fn ($part) => $part !== '' && $part !== '.'); + + return implode('/', $parts); + } + + /** + * Sanitize a filename to prevent path injection. + * + * @param string $filename The filename to sanitize + * @return string The sanitized filename + * + * @throws Exception If the filename is invalid + */ + protected function sanitizeFilename(string $filename): string + { + // Remove null bytes + $filename = str_replace("\0", '', $filename); + + // Remove any path separators from filename + $filename = basename(str_replace('\\', '/', $filename)); + + // Block hidden files starting with .. or just . + if ($filename === '' || $filename === '.' || $filename === '..') { + throw new Exception('Invalid filename'); + } + + // Block filenames that are just dots + if (preg_match('/^\.+$/', $filename)) { + throw new Exception('Invalid filename'); + } + + return $filename; + } + + public function loadDirectory(?string $path = null): void + { + if ($path !== null) { + $this->currentPath = $path; + } + + try { + $result = $this->getAgent()->fileList($this->getUsername(), $this->currentPath, $this->showHidden); + $items = $result['items'] ?? []; + + // Add ".." entry for navigating up (only if not in root) + if (! empty($this->currentPath)) { + $parentPath = dirname($this->currentPath); + if ($parentPath === '.') { + $parentPath = ''; + } + array_unshift($items, [ + 'name' => '..', + 'path' => $parentPath, + 'is_dir' => true, + 'size' => null, + 'modified' => time(), + 'permissions' => '----', + 'is_parent' => true, + ]); + } + + $this->items = $items; + } catch (Exception $e) { + $this->items = []; + Notification::make() + ->title('Error loading directory') + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function navigateTo(string $path): void + { + try { + $this->currentPath = $this->sanitizePath($path); + $this->loadDirectory(); + } catch (Exception $e) { + Notification::make() + ->title('Invalid path') + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function navigateUp(): void + { + if (empty($this->currentPath)) { + return; + } + + $parts = explode('/', $this->currentPath); + array_pop($parts); + $this->currentPath = implode('/', $parts); + $this->loadDirectory(); + $this->resetTable(); + } + + public function openFolder(string $name): void + { + $newPath = empty($this->currentPath) ? $name : $this->currentPath.'/'.$name; + $this->navigateTo($newPath); + $this->resetTable(); + } + + public function getPathBreadcrumbs(): array + { + $breadcrumbs = [['name' => 'Home', 'path' => '']]; + + if (! empty($this->currentPath)) { + $parts = explode('/', $this->currentPath); + $path = ''; + foreach ($parts as $part) { + $path = empty($path) ? $part : $path.'/'.$part; + $breadcrumbs[] = ['name' => $part, 'path' => $path]; + } + } + + return $breadcrumbs; + } + + public function table(Table $table): Table + { + return $table + ->records(fn () => $this->items) + ->columns([ + TextColumn::make('name') + ->label(__('Name')) + ->icon(fn (array $record): string => match (true) { + ($record['is_parent'] ?? false) => 'heroicon-o-arrow-uturn-left', + $record['is_dir'] => 'heroicon-o-folder', + default => 'heroicon-o-document', + }) + ->iconColor(fn (array $record): string => match (true) { + ($record['is_parent'] ?? false) => 'gray', + $record['is_dir'] => 'warning', + default => 'info', + }) + ->url(fn (array $record): ?string => $record['is_dir'] + ? route('filament.jabali.pages.files', ['path' => $record['path']]) + : null) + ->openUrlInNewTab(false) + ->weight('medium') + ->searchable(), + TextColumn::make('size') + ->label(__('Size')) + ->formatStateUsing(fn (array $record): string => $record['is_dir'] ? '—' : $this->formatSize($record['size'])) + ->color('gray'), + TextColumn::make('permissions') + ->label(__('Permissions')) + ->badge() + ->color('gray') + ->formatStateUsing(fn (array $record): string => $record['permissions'] ?? '----'), + TextColumn::make('modified') + ->label(__('Modified')) + ->formatStateUsing(fn (array $record): string => $this->formatDate($record['modified'])) + ->color('gray'), + ]) + ->recordActions([ + Action::make('view') + ->label(__('View')) + ->icon('heroicon-o-eye') + ->color('info') + ->visible(fn (array $record): bool => ! $record['is_dir'] && $this->isImage($record['name'])) + ->modalHeading(fn (array $record): string => basename($record['path'])) + ->modalWidth('4xl') + ->modalSubmitAction(false) + ->modalCancelActionLabel(__('Close')) + ->modalContent(fn (array $record) => view('filament.jabali.components.image-viewer', [ + 'imageData' => $this->getImageData($record['path']), + 'filename' => basename($record['path']), + ])), + Action::make('edit') + ->label(__('Edit')) + ->icon('heroicon-o-pencil-square') + ->color('gray') + ->visible(fn (array $record): bool => ! $record['is_dir'] && $this->isEditable($record['name'])) + ->modalHeading(fn (array $record): string => __('Edit').': '.basename($record['path'])) + ->modalWidth('5xl') + ->modalSubmitActionLabel(__('Save Changes')) + ->fillForm(function (array $record): array { + try { + $result = $this->getAgent()->fileRead($this->getUsername(), $record['path']); + + return ['content' => base64_decode($result['content'])]; + } catch (Exception) { + return ['content' => '']; + } + }) + ->form(fn (array $record): array => [ + CodeEditor::make('content') + ->label('') + ->language($this->getLanguageForFile($record['name'])) + ->wrap() + ->required(), + ]) + ->action(function (array $data, array $record): void { + try { + $this->getAgent()->fileWrite($this->getUsername(), $record['path'], $data['content']); + Notification::make()->title(__('File saved'))->success()->send(); + } catch (Exception $e) { + Notification::make()->title(__('Error saving file'))->body($e->getMessage())->danger()->send(); + } + }), + Action::make('download') + ->label(__('Download')) + ->icon('heroicon-o-arrow-down-tray') + ->color('gray') + ->visible(fn (array $record): bool => ! $record['is_dir']) + ->action(fn (array $record) => $this->downloadFile($record['path'])), + Action::make('extract') + ->label(__('Extract')) + ->icon('heroicon-o-archive-box-arrow-down') + ->color('warning') + ->visible(fn (array $record): bool => ! $record['is_dir'] && $this->isExtractable($record['name'])) + ->requiresConfirmation() + ->modalHeading(__('Extract Archive')) + ->modalDescription(__('Extract the contents of this archive to the current folder?')) + ->modalIcon('heroicon-o-archive-box-arrow-down') + ->modalIconColor('warning') + ->modalSubmitActionLabel(__('Extract')) + ->action(function (array $record): void { + try { + $this->getAgent()->fileExtract($this->getUsername(), $record['path']); + Notification::make()->title(__('Archive extracted successfully'))->success()->send(); + $this->loadDirectory(); + $this->resetTable(); + } catch (Exception $e) { + Notification::make()->title(__('Error extracting archive'))->body($e->getMessage())->danger()->send(); + } + }), + Action::make('permissions') + ->label(__('Permissions')) + ->icon('heroicon-o-lock-closed') + ->color('gray') + ->visible(fn (array $record): bool => ! ($record['is_parent'] ?? false)) + ->modalHeading(__('Change Permissions')) + ->modalDescription(fn (array $record): string => basename($record['path'])) + ->modalIcon('heroicon-o-lock-closed') + ->modalIconColor('warning') + ->modalSubmitActionLabel(__('Apply')) + ->fillForm(function (array $record): array { + try { + $result = $this->getAgent()->fileInfo($this->getUsername(), $record['path']); + $perms = $result['info']['permissions'] ?? '0644'; + + return $this->parsePermissions($perms); + } catch (Exception) { + return $this->parsePermissions('0644'); + } + }) + ->form([ + TextInput::make('mode') + ->label(__('Numeric Mode')) + ->placeholder('755') + ->maxLength(4) + ->helperText(__('Enter octal mode (e.g., 755, 644)')), + Grid::make(3) + ->schema([ + \Filament\Schemas\Components\Section::make(__('Owner')) + ->schema([ + Toggle::make('owner_read')->label(__('Read')), + Toggle::make('owner_write')->label(__('Write')), + Toggle::make('owner_execute')->label(__('Execute')), + ]), + \Filament\Schemas\Components\Section::make(__('Group')) + ->schema([ + Toggle::make('group_read')->label(__('Read')), + Toggle::make('group_write')->label(__('Write')), + Toggle::make('group_execute')->label(__('Execute')), + ]), + \Filament\Schemas\Components\Section::make(__('Others')) + ->schema([ + Toggle::make('other_read')->label(__('Read')), + Toggle::make('other_write')->label(__('Write')), + Toggle::make('other_execute')->label(__('Execute')), + ]), + ]), + ]) + ->action(function (array $data, array $record): void { + try { + $mode = ! empty($data['mode']) ? $data['mode'] : $this->buildPermissionMode($data); + $this->getAgent()->fileChmod($this->getUsername(), $record['path'], $mode); + Notification::make()->title(__('Permissions changed'))->success()->send(); + $this->loadDirectory(); + $this->resetTable(); + } catch (Exception $e) { + Notification::make()->title(__('Error changing permissions'))->body($e->getMessage())->danger()->send(); + } + }), + Action::make('rename') + ->label(__('Rename')) + ->icon('heroicon-o-pencil') + ->color('gray') + ->visible(fn (array $record): bool => ! ($record['is_parent'] ?? false)) + ->modalHeading(__('Rename')) + ->form(fn (array $record): array => [ + TextInput::make('name') + ->label(__('New Name')) + ->default($record['name']) + ->required(), + ]) + ->action(function (array $data, array $record): void { + try { + $newName = $this->sanitizeFilename($data['name']); + // Build full new path by replacing filename in old path + $oldPath = $record['path']; + $newPath = dirname($oldPath).'/'.$newName; + $this->getAgent()->fileRename($this->getUsername(), $oldPath, $newPath); + Notification::make()->title(__('Renamed successfully'))->success()->send(); + $this->loadDirectory(); + $this->resetTable(); + } catch (Exception $e) { + Notification::make()->title(__('Error renaming'))->body($e->getMessage())->danger()->send(); + } + }), + Action::make('moveToTrash') + ->label(__('Trash')) + ->icon('heroicon-o-trash') + ->color('danger') + ->visible(fn (array $record): bool => ! ($record['is_parent'] ?? false)) + ->requiresConfirmation() + ->action(function (array $record): void { + try { + $this->getAgent()->fileTrash($this->getUsername(), $record['path']); + Notification::make()->title(__('Moved to trash'))->success()->send(); + $this->loadDirectory(); + $this->resetTable(); + } catch (Exception $e) { + Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); + } + }), + ]) + ->bulkActions([ + \Filament\Actions\BulkAction::make('bulkTrash') + ->label(__('Trash')) + ->icon('heroicon-o-trash') + ->color('danger') + ->size('sm') + ->requiresConfirmation() + ->modalHeading(__('Move to Trash')) + ->modalDescription(__('Move selected items to trash? You can restore them later.')) + ->modalSubmitActionLabel(__('Move to Trash')) + ->modalIcon('heroicon-o-trash') + ->modalIconColor('warning') + ->deselectRecordsAfterCompletion() + ->action(function (\Illuminate\Support\Collection $records): void { + $trashed = 0; + $failed = 0; + foreach ($records as $record) { + try { + $this->getAgent()->fileTrash($this->getUsername(), $record['path']); + $trashed++; + } catch (Exception $e) { + $failed++; + } + } + if ($trashed > 0) { + Notification::make() + ->title(__(':count item(s) moved to trash', ['count' => $trashed])) + ->success() + ->send(); + } + if ($failed > 0) { + Notification::make() + ->title(__(':count item(s) failed', ['count' => $failed])) + ->danger() + ->send(); + } + $this->loadDirectory(); + $this->resetTable(); + }), + \Filament\Actions\BulkAction::make('bulkMove') + ->label(__('Move')) + ->icon('heroicon-o-arrow-right') + ->color('warning') + ->size('sm') + ->modalHeading(__('Move Selected Items')) + ->modalDescription(__('Select the destination folder')) + ->modalSubmitActionLabel(__('Move')) + ->form([ + TextInput::make('destination') + ->label(__('Destination Path')) + ->placeholder(__('e.g., domains/example.com/public_html')) + ->required() + ->helperText(__('Enter the path relative to your home directory')), + ]) + ->deselectRecordsAfterCompletion() + ->action(function (\Illuminate\Support\Collection $records, array $data): void { + $moved = 0; + $failed = 0; + $destination = trim($data['destination'], '/'); + foreach ($records as $record) { + try { + $filename = basename($record['path']); + $newPath = $destination.'/'.$filename; + $this->getAgent()->fileMove($this->getUsername(), $record['path'], $newPath); + $moved++; + } catch (Exception $e) { + $failed++; + } + } + if ($moved > 0) { + Notification::make() + ->title(__(':count item(s) moved', ['count' => $moved])) + ->success() + ->send(); + } + if ($failed > 0) { + Notification::make() + ->title(__(':count item(s) failed to move', ['count' => $failed])) + ->danger() + ->send(); + } + $this->loadDirectory(); + $this->resetTable(); + }), + \Filament\Actions\BulkAction::make('bulkCopy') + ->label(__('Copy')) + ->icon('heroicon-o-document-duplicate') + ->color('info') + ->size('sm') + ->modalHeading(__('Copy Selected Items')) + ->modalDescription(__('Select the destination folder')) + ->modalSubmitActionLabel(__('Copy')) + ->form([ + TextInput::make('destination') + ->label(__('Destination Path')) + ->placeholder(__('e.g., domains/example.com/public_html')) + ->required() + ->helperText(__('Enter the path relative to your home directory')), + ]) + ->deselectRecordsAfterCompletion() + ->action(function (\Illuminate\Support\Collection $records, array $data): void { + $copied = 0; + $failed = 0; + $destination = trim($data['destination'], '/'); + foreach ($records as $record) { + try { + $filename = basename($record['path']); + $newPath = $destination.'/'.$filename; + $this->getAgent()->fileCopy($this->getUsername(), $record['path'], $newPath); + $copied++; + } catch (Exception $e) { + $failed++; + } + } + if ($copied > 0) { + Notification::make() + ->title(__(':count item(s) copied', ['count' => $copied])) + ->success() + ->send(); + } + if ($failed > 0) { + Notification::make() + ->title(__(':count item(s) failed to copy', ['count' => $failed])) + ->danger() + ->send(); + } + $this->loadDirectory(); + $this->resetTable(); + }), + ]) + ->headerActions([ + $this->newFolderAction(), + $this->newFileAction(), + $this->uploadAction(), + $this->trashAction(), + Action::make('toggleHidden') + ->label($this->showHidden ? __('Hide Hidden') : __('Show Hidden')) + ->icon($this->showHidden ? 'heroicon-o-eye-slash' : 'heroicon-o-eye') + ->color($this->showHidden ? 'warning' : 'gray') + ->action(function () { + $this->showHidden = ! $this->showHidden; + $this->loadDirectory(); + $this->resetTable(); + }), + Action::make('refreshTable') + ->label(__('Refresh')) + ->icon('heroicon-o-arrow-path') + ->color('gray') + ->action(function () { + $this->loadDirectory(); + $this->resetTable(); + }), + ]) + ->emptyStateHeading(__('This folder is empty')) + ->emptyStateDescription(__('Create a new file or folder to get started')) + ->emptyStateIcon('heroicon-o-folder-open') + ->striped(); + } + + public function getTableRecordKey(Model|array $record): string + { + return is_array($record) ? $record['path'] : $record->getKey(); + } + + // Drag and drop operations + public function moveItem(string $sourcePath, string $destPath): void + { + try { + $this->getAgent()->fileMove($this->getUsername(), $sourcePath, $destPath); + + Notification::make() + ->title('Item moved successfully') + ->success() + ->send(); + + $this->loadDirectory(); + $this->resetTable(); + } catch (Exception $e) { + Notification::make() + ->title('Error moving item') + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function uploadDroppedFile(string $filename, string $base64Content): void + { + try { + // Sanitize filename to prevent path traversal + $filename = $this->sanitizeFilename($filename); + + $maxSizeMb = $this->getMaxUploadSizeMb(); + $maxSizeBytes = $maxSizeMb * 1024 * 1024; + + // Base64 is ~33% larger than original, so decode first then check size + $content = base64_decode($base64Content); + if (strlen($content) > $maxSizeBytes) { + throw new Exception(__('File too large (max :size MB)', ['size' => $maxSizeMb])); + } + + $this->getAgent()->fileUpload( + $this->getUsername(), + $this->currentPath, + $filename, + $content + ); + + Notification::make() + ->title("Uploaded: $filename") + ->success() + ->send(); + + $this->loadDirectory(); + $this->resetTable(); + } catch (Exception $e) { + Notification::make() + ->title("Upload failed: $filename") + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + // Header Actions + protected function newFolderAction(): Action + { + return Action::make('newFolder') + ->label(__('New Folder')) + ->icon('heroicon-o-folder-plus') + ->modalHeading(__('Create New Folder')) + ->modalDescription(__('Enter a name for the new folder')) + ->modalIcon('heroicon-o-folder-plus') + ->modalIconColor('warning') + ->modalSubmitActionLabel(__('Create Folder')) + ->form([ + TextInput::make('name') + ->label(__('Folder Name')) + ->placeholder(__('my-folder')) + ->required() + ->autocomplete(false) + ->maxLength(255) + ->helperText(__('Use letters, numbers, hyphens, and underscores')), + ]) + ->action(function (array $data): void { + try { + $folderName = $this->sanitizeFilename($data['name']); + $path = empty($this->currentPath) + ? $folderName + : $this->currentPath.'/'.$folderName; + + $this->getAgent()->fileMkdir($this->getUsername(), $path); + + Notification::make() + ->title(__('Folder created')) + ->success() + ->send(); + + $this->loadDirectory(); + $this->resetTable(); + } catch (Exception $e) { + Notification::make() + ->title(__('Error creating folder')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + }); + } + + protected function newFileAction(): Action + { + return Action::make('newFile') + ->label(__('New File')) + ->icon('heroicon-o-document-plus') + ->modalHeading(__('Create New File')) + ->modalDescription(__('Create a new file with optional content')) + ->modalIcon('heroicon-o-document-plus') + ->modalIconColor('info') + ->modalSubmitActionLabel(__('Create File')) + ->modalWidth('2xl') + ->form([ + TextInput::make('name') + ->label(__('File Name')) + ->placeholder(__('example.php')) + ->required() + ->autocomplete(false) + ->maxLength(255) + ->helperText(__('Include the file extension (e.g., .php, .html, .txt)')), + Textarea::make('content') + ->label(__('Content')) + ->placeholder(__('Enter file content here...')) + ->rows(12) + ->extraAttributes(['class' => 'font-mono text-sm']), + ]) + ->action(function (array $data): void { + try { + $fileName = $this->sanitizeFilename($data['name']); + $path = empty($this->currentPath) + ? $fileName + : $this->currentPath.'/'.$fileName; + + $this->getAgent()->fileWrite($this->getUsername(), $path, $data['content'] ?? ''); + + Notification::make() + ->title(__('File created')) + ->success() + ->send(); + + $this->loadDirectory(); + $this->resetTable(); + } catch (Exception $e) { + Notification::make() + ->title(__('Error creating file')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + }); + } + + protected function uploadAction(): Action + { + $maxSizeMb = $this->getMaxUploadSizeMb(); + + return Action::make('upload') + ->label(__('Upload')) + ->icon('heroicon-o-arrow-up-tray') + ->modalHeading(__('Upload Files')) + ->modalDescription(__('Select files to upload to the current folder')) + ->modalIcon('heroicon-o-arrow-up-tray') + ->modalIconColor('success') + ->modalSubmitActionLabel(__('Upload')) + ->form([ + FileUpload::make('files') + ->label(__('Select Files')) + ->multiple() + ->storeFiles(false) + ->required() + ->maxSize($maxSizeMb * 1024) // Convert MB to KB for Filament + ->validationMessages([ + 'max' => __('File exceeds maximum upload size of :size MB', ['size' => $maxSizeMb]), + ]) + ->helperText(__('Maximum file size: :size MB', ['size' => $maxSizeMb])), + ]) + ->action(function (array $data): void { + $uploaded = 0; + foreach ($data['files'] ?? [] as $file) { + try { + $filename = $this->sanitizeFilename($file->getClientOriginalName()); + $content = file_get_contents($file->getRealPath()); + $this->getAgent()->fileUpload( + $this->getUsername(), + $this->currentPath, + $filename, + $content + ); + $uploaded++; + } catch (Exception $e) { + Notification::make() + ->title(__('Upload failed: ').$file->getClientOriginalName()) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + if ($uploaded > 0) { + Notification::make() + ->title(__(':count file(s) uploaded', ['count' => $uploaded])) + ->success() + ->send(); + $this->loadDirectory(); + $this->resetTable(); + } + }); + } + + protected function refreshAction(): Action + { + return Action::make('refresh') + ->label(__('Refresh')) + ->icon('heroicon-o-arrow-path') + ->color('gray') + ->action(function () { + $this->loadDirectory(); + $this->resetTable(); + }); + } + + // Helper to get CodeEditor language based on file extension + protected function getLanguageForFile(string $filename): ?Language + { + $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); + + // Handle .blade.php + if (str_ends_with(strtolower($filename), '.blade.php')) { + return Language::Php; + } + + return match ($ext) { + 'php' => Language::Php, + 'js', 'mjs', 'cjs' => Language::JavaScript, + 'json' => Language::Json, + 'html', 'htm' => Language::Html, + 'css', 'scss', 'sass' => Language::Css, + 'xml' => Language::Xml, + 'sql' => Language::Sql, + 'md', 'markdown' => Language::Markdown, + 'yml', 'yaml' => Language::Yaml, + 'py' => Language::Python, + 'java' => Language::Java, + 'go' => Language::Go, + 'cpp', 'c', 'h', 'hpp' => Language::Cpp, + default => null, + }; + } + + // Row Actions + public function downloadFile(string $path): void + { + try { + $result = $this->getAgent()->fileRead($this->getUsername(), $path); + $this->dispatch('download-file', + content: base64_encode(base64_decode($result['content'])), + filename: basename($path) + ); + } catch (Exception $e) { + Notification::make()->title('Error downloading')->body($e->getMessage())->danger()->send(); + } + } + + public function formatSize(?int $bytes): string + { + if ($bytes === null) { + return '—'; + } + if ($bytes < 1024) { + return $bytes.' B'; + } + if ($bytes < 1048576) { + return round($bytes / 1024, 1).' KB'; + } + if ($bytes < 1073741824) { + return round($bytes / 1048576, 1).' MB'; + } + + return round($bytes / 1073741824, 1).' GB'; + } + + public function formatDate(int $timestamp): string + { + return date('M d, Y H:i', $timestamp); + } + + public function isEditable(string $name): bool + { + $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)); + $editable = ['php', 'js', 'ts', 'css', 'scss', 'html', 'htm', 'json', 'xml', 'txt', 'md', 'yml', 'yaml', 'env', 'htaccess', 'conf', 'ini', 'sh', 'bash', 'log', 'sql', 'vue', 'jsx', 'tsx']; + + return in_array($ext, $editable) || empty($ext); + } + + public function isExtractable(string $name): bool + { + $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)); + $extractable = ['zip', 'tar', 'gz', 'tgz', 'bz2', 'xz', 'rar', '7z']; + + if (preg_match('/\.(tar\.gz|tar\.bz2|tar\.xz)$/i', $name)) { + return true; + } + + return in_array($ext, $extractable); + } + + public function isImage(string $name): bool + { + $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)); + + return in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico']); + } + + public function getImageData(string $path): ?string + { + try { + $result = $this->getAgent()->fileRead($this->getUsername(), $path); + $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + $mimeTypes = [ + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'png' => 'image/png', + 'gif' => 'image/gif', + 'webp' => 'image/webp', + 'svg' => 'image/svg+xml', + 'bmp' => 'image/bmp', + 'ico' => 'image/x-icon', + ]; + $mime = $mimeTypes[$ext] ?? 'image/png'; + + return 'data:'.$mime.';base64,'.$result['content']; + } catch (Exception $e) { + return null; + } + } + + public function parsePermissions(string $mode): array + { + $mode = ltrim($mode, '0'); + $mode = str_pad($mode, 3, '0', STR_PAD_LEFT); + + $owner = (int) ($mode[0] ?? 0); + $group = (int) ($mode[1] ?? 0); + $other = (int) ($mode[2] ?? 0); + + return [ + 'mode' => $mode, + 'owner_read' => (bool) ($owner & 4), + 'owner_write' => (bool) ($owner & 2), + 'owner_execute' => (bool) ($owner & 1), + 'group_read' => (bool) ($group & 4), + 'group_write' => (bool) ($group & 2), + 'group_execute' => (bool) ($group & 1), + 'other_read' => (bool) ($other & 4), + 'other_write' => (bool) ($other & 2), + 'other_execute' => (bool) ($other & 1), + ]; + } + + public function buildPermissionMode(array $data): string + { + $owner = 0; + $group = 0; + $other = 0; + + if ($data['owner_read'] ?? false) { + $owner += 4; + } + if ($data['owner_write'] ?? false) { + $owner += 2; + } + if ($data['owner_execute'] ?? false) { + $owner += 1; + } + + if ($data['group_read'] ?? false) { + $group += 4; + } + if ($data['group_write'] ?? false) { + $group += 2; + } + if ($data['group_execute'] ?? false) { + $group += 1; + } + + if ($data['other_read'] ?? false) { + $other += 4; + } + if ($data['other_write'] ?? false) { + $other += 2; + } + if ($data['other_execute'] ?? false) { + $other += 1; + } + + return "{$owner}{$group}{$other}"; + } + + protected function trashAction(): Action + { + return Action::make('trash') + ->label(__('Trash')) + ->icon('heroicon-o-trash') + ->color('danger') + ->modalHeading(__('Trash')) + ->modalDescription(__('View and manage deleted items')) + ->modalIcon('heroicon-o-trash') + ->modalWidth('5xl') + ->modalSubmitAction(false) + ->modalCancelActionLabel(__('Close')) + ->modalContent(fn () => view('filament.jabali.components.trash-table-embed')); + } + + public function getTrashItems(): array + { + try { + $result = $this->getAgent()->fileListTrash($this->getUsername()); + + return $result['items'] ?? []; + } catch (Exception) { + return []; + } + } + + public function restoreFromTrash(string $trashName): void + { + try { + $result = $this->getAgent()->fileRestore($this->getUsername(), $trashName); + Notification::make() + ->title(__('Restored')) + ->body(__('Restored to: :path', ['path' => $result['restored_path'] ?? ''])) + ->success() + ->send(); + $this->loadDirectory(); + $this->resetTable(); + } catch (Exception $e) { + Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); + } + } + + public function deleteFromTrash(string $trashName): void + { + try { + $trashPath = ".trash/$trashName"; + $this->getAgent()->fileDelete($this->getUsername(), $trashPath); + Notification::make()->title(__('Permanently deleted'))->success()->send(); + } catch (Exception $e) { + Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); + } + } + + public function emptyTrash(): void + { + try { + $result = $this->getAgent()->fileEmptyTrash($this->getUsername()); + Notification::make() + ->title(__('Trash emptied')) + ->body(__(':count items deleted', ['count' => $result['deleted'] ?? 0])) + ->success() + ->send(); + } catch (Exception $e) { + Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); + } + } +} diff --git a/app/Filament/Jabali/Pages/Logs.php b/app/Filament/Jabali/Pages/Logs.php new file mode 100644 index 0000000..f7e9ef6 --- /dev/null +++ b/app/Filament/Jabali/Pages/Logs.php @@ -0,0 +1,241 @@ +loadDomains(); + + if (! empty($this->domains) && ! $this->selectedDomain) { + $this->selectedDomain = $this->domains[0]['domain'] ?? null; + } + + if ($this->selectedDomain) { + $this->loadLogs(); + } + } + + protected function getAgent(): AgentClient + { + if ($this->agent === null) { + $this->agent = new AgentClient; + } + + return $this->agent; + } + + protected function getUsername(): string + { + return Auth::user()->username ?? Auth::user()->name ?? 'unknown'; + } + + protected function loadDomains(): void + { + $result = $this->getAgent()->send('domain.list', [ + 'username' => $this->getUsername(), + ]); + + $this->domains = ($result['success'] ?? false) ? ($result['domains'] ?? []) : []; + } + + public function getDomainOptions(): array + { + $options = []; + foreach ($this->domains as $domain) { + $d = $domain['domain'] ?? $domain; + $options[$d] = $d; + } + + return $options; + } + + public function updatedSelectedDomain(): void + { + $this->statsGenerated = false; + $this->statsUrl = ''; + $this->loadLogs(); + } + + public function setLogType(string $type): void + { + $this->logType = $type; + $this->loadLogs(); + } + + public function loadLogs(): void + { + if (! $this->selectedDomain) { + $this->logContent = ''; + $this->logInfo = []; + + return; + } + + try { + $result = $this->getAgent()->send('logs.tail', [ + 'username' => $this->getUsername(), + 'domain' => $this->selectedDomain, + 'type' => $this->logType, + 'lines' => $this->logLines, + ]); + + if ($result['success'] ?? false) { + $this->logContent = $result['content'] ?? ''; + $this->logInfo = [ + 'file_size' => $this->formatBytes($result['file_size'] ?? 0), + 'last_modified' => $result['last_modified'] ?? '', + 'lines' => $result['lines'] ?? 0, + ]; + } else { + $this->logContent = ''; + $this->logInfo = []; + } + } catch (\Exception $e) { + $this->logContent = ''; + $this->logInfo = []; + } + } + + public function refreshLogs(): void + { + $this->loadLogs(); + Notification::make() + ->title(__('Logs refreshed')) + ->success() + ->send(); + } + + public function generateStats(): void + { + if (! $this->selectedDomain) { + Notification::make() + ->title(__('No domain selected')) + ->danger() + ->send(); + + return; + } + + try { + $result = $this->getAgent()->send('logs.goaccess', [ + 'username' => $this->getUsername(), + 'domain' => $this->selectedDomain, + 'period' => 'all', + ]); + + if ($result['success'] ?? false) { + $this->statsGenerated = true; + $this->statsUrl = 'https://'.$this->selectedDomain.($result['report_url'] ?? '/stats/report.html'); + + Notification::make() + ->title(__('Statistics generated')) + ->body(__('Report generated with :lines log entries', ['lines' => number_format($result['log_lines'] ?? 0)])) + ->success() + ->send(); + } else { + Notification::make() + ->title(__('Error generating statistics')) + ->body($result['error'] ?? 'Unknown error') + ->danger() + ->send(); + } + } catch (\Exception $e) { + Notification::make() + ->title(__('Error')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + protected function getHeaderActions(): array + { + return [ + $this->getTourAction(), + + Action::make('generateStats') + ->label(__('Generate Statistics')) + ->icon('heroicon-o-chart-bar') + ->color('primary') + ->visible(fn () => $this->selectedDomain !== null) + ->action(fn () => $this->generateStats()), + + Action::make('refreshLogs') + ->label(__('Refresh')) + ->icon('heroicon-o-arrow-path') + ->color('gray') + ->visible(fn () => $this->selectedDomain !== null) + ->action(fn () => $this->refreshLogs()), + ]; + } + + protected 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]; + } +} diff --git a/app/Filament/Jabali/Pages/PhpSettings.php b/app/Filament/Jabali/Pages/PhpSettings.php new file mode 100644 index 0000000..08ffa8d --- /dev/null +++ b/app/Filament/Jabali/Pages/PhpSettings.php @@ -0,0 +1,325 @@ +agent === null) { + $this->agent = new AgentClient; + } + + return $this->agent; + } + + protected function getUsername(): string + { + return Auth::user()->username ?? Auth::user()->name ?? 'unknown'; + } + + public function mount(): void + { + $this->loadDomains(); + $this->loadPhpVersions(); + + if (! empty($this->domains)) { + $this->selectedDomain = $this->domains[0]['domain'] ?? null; + $this->loadSettings(); + } + + $this->form->fill($this->data); + } + + protected function loadDomains(): void + { + $result = $this->getAgent()->send('domain.list', [ + 'username' => $this->getUsername(), + ]); + + $this->domains = ($result['success'] ?? false) ? ($result['domains'] ?? []) : []; + } + + protected function loadPhpVersions(): void + { + $result = $this->getAgent()->send('php.list_versions', []); + + $this->phpVersions = []; + if ($result['success'] ?? false) { + foreach ($result['versions'] ?? [] as $v) { + $version = $v['version'] ?? $v; + $this->phpVersions[$version] = "PHP $version"; + } + } + + if (empty($this->phpVersions)) { + $this->phpVersions = ['8.4' => 'PHP 8.4']; + } + } + + public function selectDomain(string $domain): void + { + $this->selectedDomain = $domain; + $this->loadSettings(); + $this->form->fill($this->data); + } + + public function loadSettings(): void + { + if (! $this->selectedDomain) { + return; + } + + $result = $this->getAgent()->send('php.getSettings', [ + 'domain' => $this->selectedDomain, + 'username' => $this->getUsername(), + ]); + + if ($result['success'] ?? false) { + $settings = $result['settings'] ?? []; + $this->data = [ + 'php_version' => $settings['php_version'] ?? array_key_first($this->phpVersions), + 'memory_limit' => $settings['memory_limit'] ?? '256M', + 'upload_max_filesize' => $settings['upload_max_filesize'] ?? '64M', + 'post_max_size' => $settings['post_max_size'] ?? '64M', + 'max_input_vars' => $settings['max_input_vars'] ?? '3000', + 'max_execution_time' => $settings['max_execution_time'] ?? '300', + 'max_input_time' => $settings['max_input_time'] ?? '300', + ]; + } else { + $this->data = [ + 'php_version' => array_key_first($this->phpVersions), + 'memory_limit' => '256M', + 'upload_max_filesize' => '64M', + 'post_max_size' => '64M', + 'max_input_vars' => '3000', + 'max_execution_time' => '300', + 'max_input_time' => '300', + ]; + } + } + + public function form(Schema $form): Schema + { + return $form + ->schema([ + Section::make(__('PHP Version')) + ->description(__('Choose which PHP version to use for this domain.')) + ->icon('heroicon-o-code-bracket') + ->schema([ + Select::make('php_version') + ->label(__('PHP Version')) + ->options($this->phpVersions) + ->required() + ->helperText(__('Recommended: Use the latest stable PHP version for best security and performance.')), + ]), + + Section::make(__('Resource Limits')) + ->description(__('Configure memory and upload limits for your PHP applications.')) + ->icon('heroicon-o-cpu-chip') + ->schema([ + Grid::make(2) + ->schema([ + Select::make('memory_limit') + ->label(__('Memory Limit')) + ->options([ + '64M' => '64 MB', + '128M' => '128 MB', + '256M' => '256 MB', + '512M' => '512 MB', + '1024M' => '1024 MB', + '2048M' => '2048 MB', + ]) + ->required() + ->helperText(__('Maximum memory a script can allocate.')), + + Select::make('upload_max_filesize') + ->label(__('Upload Max Filesize')) + ->options([ + '2M' => '2 MB', + '8M' => '8 MB', + '16M' => '16 MB', + '32M' => '32 MB', + '64M' => '64 MB', + '128M' => '128 MB', + '256M' => '256 MB', + '512M' => '512 MB', + ]) + ->required() + ->helperText(__('Maximum size of uploaded files.')), + + Select::make('post_max_size') + ->label(__('Post Max Size')) + ->options([ + '8M' => '8 MB', + '16M' => '16 MB', + '32M' => '32 MB', + '64M' => '64 MB', + '128M' => '128 MB', + '256M' => '256 MB', + '512M' => '512 MB', + ]) + ->required() + ->helperText(__('Maximum size of POST data.')), + + Select::make('max_input_vars') + ->label(__('Max Input Vars')) + ->options([ + '1000' => '1,000', + '2000' => '2,000', + '3000' => '3,000', + '5000' => '5,000', + '10000' => '10,000', + ]) + ->required() + ->helperText(__('Maximum number of input variables.')), + ]), + ]), + + Section::make(__('Execution Limits')) + ->description(__('Set maximum execution and input processing time for PHP scripts.')) + ->icon('heroicon-o-clock') + ->schema([ + Grid::make(2) + ->schema([ + Select::make('max_execution_time') + ->label(__('Max Execution Time')) + ->options([ + '30' => '30 '.__('seconds'), + '60' => '60 '.__('seconds'), + '120' => '120 '.__('seconds'), + '300' => '300 '.__('seconds'), + '600' => '600 '.__('seconds'), + '900' => '900 '.__('seconds'), + ]) + ->required() + ->helperText(__('Maximum time a script can run.')), + + Select::make('max_input_time') + ->label(__('Max Input Time')) + ->options([ + '60' => '60 '.__('seconds'), + '120' => '120 '.__('seconds'), + '300' => '300 '.__('seconds'), + '600' => '600 '.__('seconds'), + '900' => '900 '.__('seconds'), + ]) + ->required() + ->helperText(__('Maximum time for parsing input data.')), + ]), + ]), + ]) + ->statePath('data'); + } + + protected function getHeaderActions(): array + { + return [ + $this->getTourAction(), + $this->saveSettingsAction(), + ]; + } + + protected function saveSettingsAction(): Action + { + return Action::make('saveSettings') + ->label(__('Save Changes')) + ->icon('heroicon-o-check') + ->color('success') + ->requiresConfirmation() + ->modalHeading(__('Save PHP Settings')) + ->modalDescription(__('This will update PHP settings for :domain. PHP-FPM will be reloaded to apply changes.', ['domain' => $this->selectedDomain ?? ''])) + ->modalIcon('heroicon-o-cog-6-tooth') + ->modalIconColor('warning') + ->modalSubmitActionLabel(__('Save & Reload PHP')) + ->visible(fn () => $this->selectedDomain !== null) + ->action(function (): void { + $this->save(); + }); + } + + public function save(): void + { + $formData = $this->form->getState(); + + $result = $this->getAgent()->send('php.setSettings', [ + 'domain' => $this->selectedDomain, + 'username' => $this->getUsername(), + 'settings' => [ + 'php_version' => $formData['php_version'], + 'memory_limit' => $formData['memory_limit'], + 'upload_max_filesize' => $formData['upload_max_filesize'], + 'post_max_size' => $formData['post_max_size'], + 'max_input_vars' => $formData['max_input_vars'], + 'max_execution_time' => $formData['max_execution_time'], + 'max_input_time' => $formData['max_input_time'], + ], + ]); + + if ($result['success'] ?? false) { + Notification::make() + ->title(__('PHP settings saved')) + ->body(__('PHP-FPM has been reloaded to apply your changes.')) + ->success() + ->send(); + } else { + Notification::make() + ->title(__('Failed to save settings')) + ->body($result['error'] ?? __('Unknown error')) + ->danger() + ->send(); + } + } +} diff --git a/app/Filament/Jabali/Pages/ProtectedDirectories.php b/app/Filament/Jabali/Pages/ProtectedDirectories.php new file mode 100644 index 0000000..f1b652f --- /dev/null +++ b/app/Filament/Jabali/Pages/ProtectedDirectories.php @@ -0,0 +1,319 @@ +agent === null) { + $this->agent = new AgentClient; + } + + return $this->agent; + } + + protected function getUsername(): string + { + return Auth::user()->username ?? Auth::user()->name ?? 'unknown'; + } + + public function mount(): void + { + $this->loadDomains(); + + if (! empty($this->domains)) { + $this->selectedDomain = $this->domains[0]['domain'] ?? null; + $this->loadProtectedDirectories(); + } + } + + protected function loadDomains(): void + { + $result = $this->getAgent()->send('domain.list', [ + 'username' => $this->getUsername(), + ]); + + $this->domains = ($result['success'] ?? false) ? ($result['domains'] ?? []) : []; + } + + public function selectDomain(string $domain): void + { + $this->selectedDomain = $domain; + $this->loadProtectedDirectories(); + $this->resetTable(); + } + + public function loadProtectedDirectories(): void + { + if (! $this->selectedDomain) { + $this->protectedDirs = []; + + return; + } + + $result = $this->getAgent()->send('domain.list_protected_dirs', [ + 'domain' => $this->selectedDomain, + 'username' => $this->getUsername(), + ]); + + $this->protectedDirs = ($result['success'] ?? false) ? ($result['directories'] ?? []) : []; + } + + public function table(Table $table): Table + { + return $table + ->records(fn () => $this->protectedDirs) + ->columns([ + TextColumn::make('path') + ->label(__('Directory')) + ->icon('heroicon-o-folder') + ->searchable(), + TextColumn::make('name') + ->label(__('Protected Area Name')) + ->searchable(), + TextColumn::make('users_count') + ->label(__('Users')) + ->badge() + ->color('info'), + ]) + ->actions([ + \Filament\Actions\Action::make('manageUsers') + ->label(__('Manage Users')) + ->icon('heroicon-o-users') + ->color('gray') + ->modalHeading(fn (array $record): string => __('Manage Users for :path', ['path' => $record['path']])) + ->modalWidth('lg') + ->form(fn (array $record): array => [ + Section::make(__('Current Users')) + ->description(__('Users who can access this protected directory.')) + ->schema([ + \Filament\Schemas\Components\View::make('filament.jabali.components.protected-dir-users') + ->viewData(['users' => $record['users'] ?? [], 'path' => $record['path']]), + ]), + Section::make(__('Add New User')) + ->schema([ + TextInput::make('new_username') + ->label(__('Username')) + ->required() + ->alphaNum() + ->maxLength(32), + TextInput::make('new_password') + ->label(__('Password')) + ->password() + ->required() + ->minLength(6), + ]), + ]) + ->action(function (array $data, array $record): void { + if (empty($data['new_username']) || empty($data['new_password'])) { + return; + } + + $result = $this->getAgent()->send('domain.add_protected_dir_user', [ + 'domain' => $this->selectedDomain, + 'username' => $this->getUsername(), + 'path' => $record['path'], + 'auth_username' => $data['new_username'], + 'auth_password' => $data['new_password'], + ]); + + if ($result['success'] ?? false) { + Notification::make() + ->title(__('User added')) + ->success() + ->send(); + $this->loadProtectedDirectories(); + $this->resetTable(); + } else { + Notification::make() + ->title(__('Failed to add user')) + ->body($result['error'] ?? __('Unknown error')) + ->danger() + ->send(); + } + }), + \Filament\Actions\Action::make('remove') + ->label(__('Remove Protection')) + ->icon('heroicon-o-lock-open') + ->color('danger') + ->requiresConfirmation() + ->modalHeading(__('Remove Directory Protection')) + ->modalDescription(fn (array $record): string => __('Are you sure you want to remove password protection from ":path"? Anyone will be able to access this directory.', ['path' => $record['path']])) + ->action(function (array $record): void { + $result = $this->getAgent()->send('domain.remove_protected_dir', [ + 'domain' => $this->selectedDomain, + 'username' => $this->getUsername(), + 'path' => $record['path'], + ]); + + if ($result['success'] ?? false) { + Notification::make() + ->title(__('Protection removed')) + ->body(__('Directory ":path" is no longer password protected.', ['path' => $record['path']])) + ->success() + ->send(); + $this->loadProtectedDirectories(); + $this->resetTable(); + } else { + Notification::make() + ->title(__('Failed to remove protection')) + ->body($result['error'] ?? __('Unknown error')) + ->danger() + ->send(); + } + }), + ]) + ->emptyStateHeading(__('No protected directories')) + ->emptyStateDescription(__('Add password protection to directories to restrict access.')) + ->emptyStateIcon('heroicon-o-lock-open') + ->striped(); + } + + protected function getHeaderActions(): array + { + return [ + $this->addProtectionAction(), + ]; + } + + protected function addProtectionAction(): Action + { + return Action::make('addProtection') + ->label(__('Protect Directory')) + ->icon('heroicon-o-lock-closed') + ->color('primary') + ->visible(fn () => $this->selectedDomain !== null) + ->modalHeading(__('Protect a Directory')) + ->modalDescription(__('Add password protection to a directory. Users will need to enter a username and password to access it.')) + ->form([ + TextInput::make('path') + ->label(__('Directory Path')) + ->placeholder('/admin') + ->required() + ->helperText(__('Path relative to your document root (e.g., /admin, /private, /members)')), + TextInput::make('name') + ->label(__('Protected Area Name')) + ->placeholder(__('Restricted Area')) + ->required() + ->maxLength(100) + ->helperText(__('This name will be shown in the browser login prompt.')), + TextInput::make('auth_username') + ->label(__('Username')) + ->required() + ->alphaNum() + ->maxLength(32) + ->helperText(__('Username for accessing the protected directory.')), + TextInput::make('auth_password') + ->label(__('Password')) + ->password() + ->required() + ->minLength(6) + ->helperText(__('Password for the user. Minimum 6 characters.')), + ]) + ->action(function (array $data): void { + // Normalize path + $path = '/'.ltrim(trim($data['path']), '/'); + + $result = $this->getAgent()->send('domain.add_protected_dir', [ + 'domain' => $this->selectedDomain, + 'username' => $this->getUsername(), + 'path' => $path, + 'name' => $data['name'], + 'auth_username' => $data['auth_username'], + 'auth_password' => $data['auth_password'], + ]); + + if ($result['success'] ?? false) { + Notification::make() + ->title(__('Directory protected')) + ->body(__('Password protection has been added to ":path".', ['path' => $path])) + ->success() + ->send(); + $this->loadProtectedDirectories(); + $this->resetTable(); + } else { + Notification::make() + ->title(__('Failed to protect directory')) + ->body($result['error'] ?? __('Unknown error')) + ->danger() + ->send(); + } + }); + } + + public function deleteProtectedDirUser(string $path, string $authUsername): void + { + $result = $this->getAgent()->send('domain.remove_protected_dir_user', [ + 'domain' => $this->selectedDomain, + 'username' => $this->getUsername(), + 'path' => $path, + 'auth_username' => $authUsername, + ]); + + if ($result['success'] ?? false) { + Notification::make() + ->title(__('User removed')) + ->success() + ->send(); + $this->loadProtectedDirectories(); + $this->resetTable(); + } else { + Notification::make() + ->title(__('Failed to remove user')) + ->body($result['error'] ?? __('Unknown error')) + ->danger() + ->send(); + } + } +} diff --git a/app/Filament/Jabali/Pages/SshKeys.php b/app/Filament/Jabali/Pages/SshKeys.php new file mode 100644 index 0000000..01b5100 --- /dev/null +++ b/app/Filament/Jabali/Pages/SshKeys.php @@ -0,0 +1,449 @@ +loadSshKeys(); + $this->loadShellStatus(); + $this->sshHost = request()->getHost(); + $this->sshPort = '22'; + $this->sshUsername = $this->getUsername(); + $this->sshCommand = 'ssh '.$this->sshUsername.'@'.$this->sshHost; + } + + protected function loadShellStatus(): void + { + try { + $result = $this->getAgent()->send('ssh.shell_status', ['username' => $this->getUsername()]); + $this->shellEnabled = $result['shell_enabled'] ?? false; + } catch (\Exception $e) { + $this->shellEnabled = false; + } + } + + public function toggleShellAccess(): void + { + try { + $command = $this->shellEnabled ? 'ssh.disable_shell' : 'ssh.enable_shell'; + $result = $this->getAgent()->send($command, ['username' => $this->getUsername()]); + + if ($result['success'] ?? false) { + $this->shellEnabled = ! $this->shellEnabled; + Notification::make() + ->title($this->shellEnabled ? __('SSH Shell Enabled') : __('SSH Shell Disabled')) + ->body($this->shellEnabled + ? __('You now have jailed shell access with wp-cli support.') + : __('Shell access disabled. You can still use SFTP for file transfers.')) + ->success() + ->send(); + } else { + throw new \Exception($result['error'] ?? __('Failed to toggle shell access')); + } + } catch (\Exception $e) { + Notification::make() + ->title(__('Error')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + protected function getUsername(): string + { + $user = Auth::user(); + + return $user->system_username ?? $user->username ?? $user->email; + } + + protected function getAgent() + { + return app(\App\Services\Agent\AgentClient::class); + } + + protected function loadSshKeys(): void + { + try { + $result = $this->getAgent()->send('ssh.list_keys', ['username' => $this->getUsername()]); + $this->sshKeys = $result['keys'] ?? []; + } catch (\Exception $e) { + $this->sshKeys = []; + } + } + + public function table(Table $table): Table + { + return $table + ->records(fn () => $this->sshKeys) + ->columns([ + TextColumn::make('name') + ->label(__('Key Name')) + ->icon('heroicon-o-key') + ->iconColor('success') + ->weight(FontWeight::Medium) + ->searchable(), + TextColumn::make('fingerprint') + ->label(__('Fingerprint')) + ->fontFamily('mono') + ->size('sm') + ->color('gray') + ->limit(40), + TextColumn::make('added_at') + ->label(__('Added')) + ->date('M d, Y') + ->sortable(), + ]) + ->recordActions([ + Action::make('delete') + ->label(__('Delete')) + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->modalHeading(__('Delete SSH Key')) + ->modalDescription(__('Are you sure you want to delete this SSH key? You will no longer be able to use it to connect to the server.')) + ->modalIcon('heroicon-o-trash') + ->modalIconColor('danger') + ->modalSubmitActionLabel(__('Delete Key')) + ->action(function (array $record): void { + try { + $result = $this->getAgent()->send('ssh.delete_key', [ + 'username' => $this->getUsername(), + 'key_id' => $record['id'], + ]); + + if ($result['success'] ?? false) { + Notification::make() + ->title(__('SSH Key Deleted')) + ->success() + ->send(); + $this->loadSshKeys(); + $this->resetTable(); + } else { + throw new \Exception($result['error'] ?? __('Failed to delete key')); + } + } catch (\Exception $e) { + Notification::make() + ->title(__('Error')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + }), + ]) + ->emptyStateHeading(__('No SSH keys added yet')) + ->emptyStateDescription(__('Click "Generate SSH Key" to create a new key pair, or "Add Existing Key" to add your own public key')) + ->emptyStateIcon('heroicon-o-key') + ->striped(); + } + + public function getTableRecordKey(Model|array $record): string + { + return is_array($record) ? ($record['id'] ?? $record['name']) : $record->getKey(); + } + + protected function getHeaderActions(): array + { + return [ + $this->getTourAction(), + Action::make('generateKey') + ->label(__('Generate SSH Key')) + ->icon('heroicon-o-sparkles') + ->color('success') + ->modalHeading(__('Generate SSH Key')) + ->modalDescription(__('Generate a new SSH key pair for secure server access')) + ->modalIcon('heroicon-o-sparkles') + ->modalIconColor('success') + ->modalSubmitActionLabel(__('Generate Key')) + ->form([ + TextInput::make('name') + ->label(__('Key Name')) + ->placeholder(__('My Generated Key')) + ->required() + ->maxLength(50) + ->helperText(__('A descriptive name to identify this key')), + Select::make('type') + ->label(__('Key Type')) + ->options([ + 'ed25519' => __('ED25519 (Recommended)'), + 'rsa' => __('RSA 4096-bit'), + ]) + ->default('ed25519') + ->required() + ->helperText(__('ED25519 is faster and more secure')), + TextInput::make('passphrase') + ->label(__('Passphrase (Optional)')) + ->password() + ->helperText(__('Leave empty for no passphrase')), + ]) + ->action(function (array $data): void { + try { + $result = $this->generateSshKey($data['name'], $data['type'], $data['passphrase'] ?? ''); + + if ($result) { + $this->generatedKey = $result; + $this->loadSshKeys(); + + Notification::make() + ->title(__('SSH Key Generated!')) + ->body(__('Download your private key below. The public key has been added to your account.')) + ->success() + ->persistent() + ->send(); + } + } catch (\Exception $e) { + Notification::make() + ->title(__('Error')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + }), + Action::make('addKey') + ->label(__('Add Existing Key')) + ->icon('heroicon-o-plus') + ->color('primary') + ->modalHeading(__('Add Existing SSH Key')) + ->modalDescription(__('Add your existing public key to enable SSH access')) + ->modalIcon('heroicon-o-key') + ->modalIconColor('primary') + ->modalSubmitActionLabel(__('Add Key')) + ->form([ + TextInput::make('name') + ->label(__('Key Name')) + ->placeholder(__('My Laptop')) + ->required() + ->maxLength(50) + ->helperText(__('A descriptive name to identify this key')), + Textarea::make('public_key') + ->label(__('Public Key')) + ->placeholder(__('ssh-rsa AAAAB3... or ssh-ed25519 AAAAC3...')) + ->required() + ->rows(4) + ->helperText(__('Paste your public key (usually from ~/.ssh/id_rsa.pub or ~/.ssh/id_ed25519.pub)')), + ]) + ->action(function (array $data): void { + try { + $result = $this->getAgent()->send('ssh.add_key', [ + 'username' => $this->getUsername(), + 'name' => $data['name'], + 'public_key' => trim($data['public_key']), + ]); + + if ($result['success'] ?? false) { + Notification::make() + ->title(__('SSH Key Added')) + ->success() + ->send(); + $this->loadSshKeys(); + } else { + throw new \Exception($result['error'] ?? __('Failed to add key')); + } + } catch (\Exception $e) { + Notification::make() + ->title(__('Error')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + }), + ]; + } + + protected function generateSshKey(string $name, string $type, string $passphrase = ''): array + { + $result = $this->getAgent()->send('ssh.generate_key', [ + 'name' => $name, + 'type' => $type, + 'passphrase' => $passphrase, + ]); + + if (! ($result['success'] ?? false)) { + throw new \Exception($result['error'] ?? __('Failed to generate SSH key')); + } + + // Add public key to user authorized_keys + $addResult = $this->getAgent()->send('ssh.add_key', [ + 'username' => $this->getUsername(), + 'name' => $name, + 'public_key' => trim($result['public_key']), + ]); + + if (! ($addResult['success'] ?? false)) { + throw new \Exception($addResult['error'] ?? __('Failed to add key to server')); + } + + return [ + 'name' => $result['name'], + 'type' => $result['type'], + 'private_key' => $result['private_key'], + 'public_key' => $result['public_key'], + 'ppk_key' => $result['ppk_key'] ?? null, + 'fingerprint' => $result['fingerprint'] ?? '', + ]; + } + + public function clearGeneratedKey(): void + { + $this->generatedKey = null; + } + + public function downloadPrivateKey(): \Symfony\Component\HttpFoundation\StreamedResponse + { + if (! $this->generatedKey) { + abort(404); + } + + $key = $this->generatedKey['private_key']; + $name = preg_replace('/[^a-zA-Z0-9_-]/', '_', $this->generatedKey['name']); + + return response()->streamDownload(function () use ($key) { + echo $key; + }, "id_{$this->generatedKey['type']}_{$name}", [ + 'Content-Type' => 'application/x-pem-file', + ]); + } + + public function downloadPpkKey(): \Symfony\Component\HttpFoundation\StreamedResponse + { + if (! $this->generatedKey || ! $this->generatedKey['ppk_key']) { + abort(404); + } + + $key = $this->generatedKey['ppk_key']; + $name = preg_replace('/[^a-zA-Z0-9_-]/', '_', $this->generatedKey['name']); + + return response()->streamDownload(function () use ($key) { + echo $key; + }, "{$name}.ppk", [ + 'Content-Type' => 'application/x-putty-private-key', + ]); + } + + public function downloadFileZillaConfig(): \Symfony\Component\HttpFoundation\StreamedResponse + { + $xml = $this->generateFileZillaXml(); + + return response()->streamDownload(function () use ($xml) { + echo $xml; + }, 'jabali-sftp.xml', [ + 'Content-Type' => 'application/xml', + ]); + } + + public function downloadWinScpConfig(): \Symfony\Component\HttpFoundation\StreamedResponse + { + $ini = $this->generateWinScpIni(); + + return response()->streamDownload(function () use ($ini) { + echo $ini; + }, 'jabali-sftp.ini', [ + 'Content-Type' => 'text/plain', + ]); + } + + protected function generateFileZillaXml(): string + { + $host = htmlspecialchars($this->sshHost); + $port = htmlspecialchars($this->sshPort); + $user = htmlspecialchars($this->sshUsername); + + return << + + + + {$host} + {$port} + 1 + 0 + {$user} + 1 + Auto + 0 + Jabali - {$host} + /home/{$user} + 0 + 0 + + + +XML; + } + + protected function generateWinScpIni(): string + { + $host = $this->sshHost; + $port = $this->sshPort; + $user = $this->sshUsername; + $sessionName = "Jabali - {$host}"; + + return <<agent === null) { + $this->agent = new AgentClient; + } + + return $this->agent; + } + + public function getUsername(): string + { + return Auth::user()->username; + } + + public function table(Table $table): Table + { + return $table + ->query(Domain::query()->where('user_id', Auth::id())->with('sslCertificate')) + ->columns([ + TextColumn::make('domain') + ->label(__('Domain')) + ->icon(fn (Domain $record) => $record->sslCertificate?->isActive() ? 'heroicon-o-lock-closed' : 'heroicon-o-lock-open') + ->iconColor(fn (Domain $record) => $record->sslCertificate?->status_color ?? 'gray') + ->description(fn (Domain $record) => $record->sslCertificate?->issuer ?? __('No certificate')) + ->searchable() + ->sortable(), + TextColumn::make('sslCertificate.type') + ->label(__('Type')) + ->badge() + ->formatStateUsing(fn (?string $state) => match ($state) { + 'lets_encrypt' => __("Let's Encrypt"), + 'self_signed' => __('Self-Signed'), + 'custom' => __('Custom'), + default => __('No SSL'), + }) + ->color('gray'), + TextColumn::make('sslCertificate.status') + ->label(__('Status')) + ->badge() + ->getStateUsing(fn (Domain $record) => $record->sslCertificate?->status_label ?? __('No Certificate')) + ->color(fn (Domain $record) => $record->sslCertificate?->status_color ?? 'gray'), + TextColumn::make('sslCertificate.expires_at') + ->label(__('Expires')) + ->date('M d, Y') + ->description(fn (Domain $record) => $record->sslCertificate?->days_until_expiry !== null + ? ($record->sslCertificate->days_until_expiry < 0 + ? __('Expired :days days ago', ['days' => abs($record->sslCertificate->days_until_expiry)]) + : __(':days days left', ['days' => $record->sslCertificate->days_until_expiry])) + : null) + ->placeholder('-') + ->sortable(), + IconColumn::make('sslCertificate.auto_renew') + ->label(__('Auto-Renew')) + ->boolean() + ->trueIcon('heroicon-o-check-circle') + ->falseIcon('heroicon-o-x-circle') + ->trueColor('success') + ->falseColor('gray') + ->action(fn (Domain $record) => $record->sslCertificate?->type === 'lets_encrypt' ? $this->toggleAutoRenew($record->domain) : null), + ]) + ->recordActions([ + Action::make('issueSsl') + ->label(__('Issue SSL')) + ->icon('heroicon-o-shield-check') + ->color('success') + ->visible(fn (Domain $record) => ! $record->sslCertificate || $record->sslCertificate->type === 'self_signed' || $record->sslCertificate->status === 'failed') + ->requiresConfirmation() + ->modalHeading(__('Issue SSL Certificate')) + ->modalDescription(fn (Domain $record) => __("Issue a free Let's Encrypt SSL certificate for :domain? This will enable HTTPS for your domain.", ['domain' => $record->domain])) + ->modalIcon('heroicon-o-shield-check') + ->modalIconColor('success') + ->modalSubmitActionLabel(__('Issue Certificate')) + ->action(fn (Domain $record) => $this->issueLetsEncrypt($record->domain)), + Action::make('renew') + ->label(__('Renew')) + ->icon('heroicon-o-arrow-path') + ->color('info') + ->visible(fn (Domain $record) => $record->sslCertificate?->type === 'lets_encrypt' && $record->sslCertificate?->status === 'active') + ->requiresConfirmation() + ->modalHeading(__('Renew SSL Certificate')) + ->modalDescription(fn (Domain $record) => __("Renew the Let's Encrypt certificate for :domain? This will extend the certificate validity.", ['domain' => $record->domain])) + ->modalIcon('heroicon-o-arrow-path') + ->modalIconColor('info') + ->modalSubmitActionLabel(__('Renew Certificate')) + ->action(fn (Domain $record) => $this->renewCertificate($record->domain)), + Action::make('selfSigned') + ->label(__('Self-Signed')) + ->icon('heroicon-o-exclamation-triangle') + ->color('warning') + ->visible(fn (Domain $record) => ! $record->sslCertificate) + ->requiresConfirmation() + ->modalHeading(__('Generate Self-Signed Certificate')) + ->modalDescription(fn (Domain $record) => __('Generate a self-signed certificate for :domain? Note: Browsers will show a security warning for self-signed certificates.', ['domain' => $record->domain])) + ->modalIcon('heroicon-o-exclamation-triangle') + ->modalIconColor('warning') + ->modalSubmitActionLabel(__('Generate Certificate')) + ->action(fn (Domain $record) => $this->generateSelfSigned($record->domain)), + Action::make('check') + ->label(__('Check')) + ->icon('heroicon-o-magnifying-glass') + ->color('gray') + ->action(fn (Domain $record) => $this->checkCertificate($record->domain)), + ]) + ->emptyStateHeading(__('No domains yet')) + ->emptyStateDescription(__('Add a domain first to manage SSL certificates')) + ->emptyStateIcon('heroicon-o-lock-closed') + ->striped(); + } + + public function issueLetsEncrypt(string $domainName): void + { + try { + $domain = Domain::where('domain', $domainName) + ->where('user_id', Auth::id()) + ->firstOrFail(); + + $result = $this->getAgent()->sslIssue( + $domainName, + $this->getUsername(), + Auth::user()->email, + true + ); + + if ($result['success'] ?? false) { + // Update or create certificate record + SslCertificate::updateOrCreate( + ['domain_id' => $domain->id], + [ + 'type' => 'lets_encrypt', + 'status' => 'active', + 'issuer' => "Let's Encrypt", + 'certificate' => $result['certificate'] ?? null, + 'issued_at' => now(), + 'expires_at' => isset($result['valid_to']) ? \Carbon\Carbon::parse($result['valid_to']) : now()->addMonths(3), + 'last_check_at' => now(), + 'last_error' => null, + 'renewal_attempts' => 0, + 'auto_renew' => true, + ] + ); + + $domain->update(['ssl_enabled' => true]); + + Notification::make() + ->title(__('SSL Certificate Issued')) + ->body(__("Let's Encrypt certificate has been issued for :domain", ['domain' => $domainName])) + ->success() + ->send(); + } else { + $error = $result['error'] ?? __('Unknown error'); + + // Record the failure + SslCertificate::updateOrCreate( + ['domain_id' => $domain->id], + [ + 'type' => 'lets_encrypt', + 'status' => 'failed', + 'last_check_at' => now(), + 'last_error' => $error, + ] + ); + + Notification::make() + ->title(__('SSL Certificate Failed')) + ->body($error) + ->danger() + ->send(); + } + } catch (Exception $e) { + Notification::make() + ->title(__('Error')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function generateSelfSigned(string $domainName): void + { + try { + $domain = Domain::where('domain', $domainName) + ->where('user_id', Auth::id()) + ->firstOrFail(); + + $result = $this->getAgent()->sslGenerateSelfSigned( + $domainName, + $this->getUsername(), + 365 + ); + + if ($result['success'] ?? false) { + SslCertificate::updateOrCreate( + ['domain_id' => $domain->id], + [ + 'type' => 'self_signed', + 'status' => 'active', + 'issuer' => 'Self-Signed', + 'issued_at' => now(), + 'expires_at' => now()->addDays($result['valid_days'] ?? 365), + 'last_check_at' => now(), + 'last_error' => null, + 'auto_renew' => false, + ] + ); + + $domain->update(['ssl_enabled' => true]); + + Notification::make() + ->title(__('Self-Signed Certificate Generated')) + ->body(__('Self-signed certificate created for :domain', ['domain' => $domainName])) + ->success() + ->send(); + } else { + Notification::make() + ->title(__('Certificate Generation Failed')) + ->body($result['error'] ?? __('Unknown error')) + ->danger() + ->send(); + } + } catch (Exception $e) { + Notification::make() + ->title(__('Error')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function renewCertificate(string $domainName): void + { + try { + $domain = Domain::where('domain', $domainName) + ->where('user_id', Auth::id()) + ->firstOrFail(); + + $result = $this->getAgent()->sslRenew($domainName, $this->getUsername()); + + if ($result['success'] ?? false) { + $ssl = $domain->sslCertificate; + if ($ssl) { + $ssl->update([ + 'status' => 'active', + 'issued_at' => now(), + 'expires_at' => isset($result['valid_to']) ? \Carbon\Carbon::parse($result['valid_to']) : now()->addMonths(3), + 'last_check_at' => now(), + 'last_error' => null, + 'renewal_attempts' => 0, + ]); + } + + Notification::make() + ->title(__('Certificate Renewed')) + ->body(__('SSL certificate has been renewed for :domain', ['domain' => $domainName])) + ->success() + ->send(); + } else { + Notification::make() + ->title(__('Renewal Failed')) + ->body($result['error'] ?? __('Unknown error')) + ->danger() + ->send(); + } + } catch (Exception $e) { + Notification::make() + ->title(__('Error')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function checkCertificate(string $domainName): void + { + try { + $domain = Domain::where('domain', $domainName) + ->where('user_id', Auth::id()) + ->firstOrFail(); + + $result = $this->getAgent()->sslCheck($domainName, $this->getUsername()); + + if ($result['success'] ?? false) { + $sslData = $result['ssl'] ?? []; + + if ($sslData['has_ssl'] ?? false) { + SslCertificate::updateOrCreate( + ['domain_id' => $domain->id], + [ + 'type' => $sslData['type'] ?? 'custom', + 'status' => ($sslData['is_expired'] ?? false) ? 'expired' : 'active', + 'issuer' => $sslData['issuer'], + 'certificate' => $sslData['certificate'] ?? null, + 'issued_at' => isset($sslData['valid_from']) ? \Carbon\Carbon::parse($sslData['valid_from']) : null, + 'expires_at' => isset($sslData['valid_to']) ? \Carbon\Carbon::parse($sslData['valid_to']) : null, + 'last_check_at' => now(), + ] + ); + + $domain->update(['ssl_enabled' => true]); + } + + Notification::make() + ->title(__('Certificate Checked')) + ->body($sslData['has_ssl'] ? __('Certificate found: :issuer', ['issuer' => $sslData['issuer']]) : __('No certificate found')) + ->success() + ->send(); + } else { + Notification::make() + ->title(__('Check Failed')) + ->body($result['error'] ?? __('Unknown error')) + ->danger() + ->send(); + } + } catch (Exception $e) { + Notification::make() + ->title(__('Error')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function toggleAutoRenew(string $domainName): void + { + try { + $domain = Domain::where('domain', $domainName) + ->where('user_id', Auth::id()) + ->firstOrFail(); + + $ssl = $domain->sslCertificate; + if ($ssl) { + $ssl->update(['auto_renew' => ! $ssl->auto_renew]); + + Notification::make() + ->title(__('Auto-Renew Updated')) + ->body($ssl->auto_renew ? __('Auto-renewal enabled') : __('Auto-renewal disabled')) + ->success() + ->send(); + } + } catch (Exception $e) { + Notification::make() + ->title(__('Error')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function installCustomCertificateAction(): Action + { + return Action::make('installCustomCertificate') + ->label(__('Install Custom Certificate')) + ->icon('heroicon-o-document-plus') + ->modalHeading(__('Install Custom SSL Certificate')) + ->modalDescription(__('Upload your own SSL certificate files to secure your domain with a custom certificate.')) + ->modalIcon('heroicon-o-document-plus') + ->modalIconColor('primary') + ->modalSubmitActionLabel(__('Install Certificate')) + ->modalWidth('lg') + ->form([ + Select::make('domain') + ->label(__('Domain')) + ->options(function () { + return Domain::where('user_id', Auth::id()) + ->pluck('domain', 'domain') + ->toArray(); + }) + ->required() + ->searchable() + ->helperText(__('Select the domain to install the certificate on')), + Textarea::make('certificate') + ->label(__('Certificate (PEM format)')) + ->placeholder("-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----") + ->rows(8) + ->required() + ->helperText(__('Paste your SSL certificate in PEM format')), + Textarea::make('private_key') + ->label(__('Private Key (PEM format)')) + ->placeholder("-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----") + ->rows(8) + ->required() + ->helperText(__('Paste your private key in PEM format. Keep this secure!')), + Textarea::make('ca_bundle') + ->label(__('CA Bundle (optional)')) + ->placeholder("-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----") + ->rows(6) + ->helperText(__('Paste the certificate authority chain if required by your certificate provider')), + ]) + ->action(function (array $data): void { + try { + $domain = Domain::where('domain', $data['domain']) + ->where('user_id', Auth::id()) + ->firstOrFail(); + + $result = $this->getAgent()->sslInstall( + $data['domain'], + $this->getUsername(), + $data['certificate'], + $data['private_key'], + $data['ca_bundle'] ?? null + ); + + if ($result['success'] ?? false) { + SslCertificate::updateOrCreate( + ['domain_id' => $domain->id], + [ + 'type' => 'custom', + 'status' => 'active', + 'issuer' => $result['issuer'] ?? 'Custom', + 'certificate' => $data['certificate'], + 'private_key' => $data['private_key'], + 'ca_bundle' => $data['ca_bundle'] ?? null, + 'issued_at' => isset($result['valid_from']) ? \Carbon\Carbon::parse($result['valid_from']) : now(), + 'expires_at' => isset($result['valid_to']) ? \Carbon\Carbon::parse($result['valid_to']) : null, + 'last_check_at' => now(), + 'last_error' => null, + 'auto_renew' => false, + ] + ); + + $domain->update(['ssl_enabled' => true]); + + Notification::make() + ->title(__('Certificate Installed')) + ->body(__('Custom SSL certificate installed for :domain', ['domain' => $data['domain']])) + ->success() + ->send(); + } else { + Notification::make() + ->title(__('Installation Failed')) + ->body($result['error'] ?? __('Unknown error')) + ->danger() + ->send(); + } + } catch (Exception $e) { + Notification::make() + ->title(__('Error')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + }); + } + + protected function getHeaderActions(): array + { + return [ + $this->getTourAction(), + $this->installCustomCertificateAction(), + ]; + } +} diff --git a/app/Filament/Jabali/Pages/WordPress.php b/app/Filament/Jabali/Pages/WordPress.php new file mode 100644 index 0000000..cc1d11f --- /dev/null +++ b/app/Filament/Jabali/Pages/WordPress.php @@ -0,0 +1,1404 @@ +agent === null) { + $this->agent = new AgentClient(); + } + return $this->agent; + } + + public function getUsername(): string + { + return Auth::user()->username; + } + + public function mount(): void + { + $this->loadData(); + } + + public function loadData(): void + { + // Load WordPress sites + try { + $result = $this->getAgent()->wpList($this->getUsername()); + $this->sites = $result['sites'] ?? []; + } catch (Exception $e) { + $this->sites = []; + } + + // Load domains for the install form + try { + $result = $this->getAgent()->domainList($this->getUsername()); + $this->domains = $result['domains'] ?? []; + } catch (Exception $e) { + $this->domains = []; + } + } + + public function table(Table $table): Table + { + return $table + ->records(fn () => collect($this->sites)->keyBy('id')->toArray()) + ->columns([ + ViewColumn::make('screenshot') + ->label(__('Preview')) + ->view('filament.jabali.columns.wordpress-screenshot'), + ViewColumn::make('domain') + ->label(__('Site')) + ->view('filament.jabali.columns.wordpress-site'), + TextColumn::make('cache_enabled') + ->label(__('Cache')) + ->badge() + ->formatStateUsing(fn (bool $state): string => $state ? __('On') : __('Off')) + ->color(fn (bool $state): string => $state ? 'success' : 'gray'), + TextColumn::make('debug_enabled') + ->label(__('Debug')) + ->badge() + ->getStateUsing(fn (array $record): bool => $record['debug_enabled'] ?? false) + ->formatStateUsing(fn (bool $state): string => $state ? __('On') : __('Off')) + ->color(fn (bool $state): string => $state ? 'warning' : 'gray'), + TextColumn::make('auto_update') + ->label(__('Updates')) + ->badge() + ->getStateUsing(fn (array $record): bool => $record['auto_update'] ?? false) + ->formatStateUsing(fn (bool $state): string => $state ? __('Auto') : __('Manual')) + ->color(fn (bool $state): string => $state ? 'success' : 'gray'), + ]) + ->recordActions([ + Action::make('admin') + ->label(__('Admin')) + ->icon('heroicon-o-arrow-right-on-rectangle') + ->action(fn (array $record) => $this->autoLogin($record['id'])), + ActionGroup::make([ + Action::make('update') + ->label(__('Update Now')) + ->icon('heroicon-o-arrow-path') + ->action(fn (array $record) => $this->updateWordPress($record['id'])), + Action::make('cache') + ->label(fn (array $record): string => ($record['cache_enabled'] ?? false) ? __('Disable Cache') : __('Enable Cache')) + ->icon('heroicon-o-bolt') + ->modalHeading(fn (array $record): string => ($record['cache_enabled'] ?? false) ? __('Disable Jabali Cache') : __('Enable Jabali Cache')) + ->modalDescription(fn (array $record): string => ($record['cache_enabled'] ?? false) + ? __('This will disable caching for this WordPress site.') + : __('This will enable Redis object caching and nginx page caching for better performance.')) + ->modalWidth('md') + ->form(fn (array $record): array => ($record['cache_enabled'] ?? false) + ? [ + Toggle::make('remove_plugin') + ->label(__('Uninstall Jabali Cache plugin')) + ->helperText(__('Completely remove the plugin files from WordPress. If unchecked, the plugin will only be deactivated.')) + ->default(false) + ->live(), + Toggle::make('reset_data') + ->label(__('Delete plugin settings')) + ->helperText(__('Remove all Jabali Cache settings from the database.')) + ->default(false) + ->visible(fn ($get) => $get('remove_plugin')), + ] + : []) + ->action(fn (array $record, array $data) => $this->toggleCache($record['id'], $data['remove_plugin'] ?? false, $data['reset_data'] ?? false)), + Action::make('debug') + ->label(fn (array $record): string => ($record['debug_enabled'] ?? false) ? __('Disable Debug') : __('Enable Debug')) + ->icon('heroicon-o-bug-ant') + ->color('warning') + ->action(fn (array $record) => $this->toggleDebug($record['id'])), + Action::make('autoUpdate') + ->label(fn (array $record): string => ($record['auto_update'] ?? false) ? __('Disable Auto-Update') : __('Enable Auto-Update')) + ->icon('heroicon-o-arrow-path') + ->action(fn (array $record) => $this->toggleAutoUpdate($record['id'])), + Action::make('staging') + ->label(__('Create Staging')) + ->icon('heroicon-o-document-duplicate') + ->color('info') + ->requiresConfirmation() + ->modalHeading(__('Create Staging Environment')) + ->modalDescription(__('This will create a copy of your site for testing.')) + ->modalIcon('heroicon-o-document-duplicate') + ->modalIconColor('info') + ->form([ + TextInput::make('staging_subdomain') + ->label(__('Staging Subdomain')) + ->prefix('staging-') + ->suffix(fn (array $record): string => '.' . ($record['domain'] ?? '')) + ->default('test') + ->required() + ->alphaNum(), + ]) + ->action(fn (array $data, array $record) => $this->createStaging($record['id'], $data['staging_subdomain'])), + Action::make('security') + ->label(__('Security Scan')) + ->icon('heroicon-o-shield-check') + ->action(fn (array $record) => $this->runSecurityScan($record['id'])), + Action::make('delete') + ->label(__('Delete Site')) + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->modalHeading(__('Delete WordPress Site')) + ->modalDescription(__('Are you sure you want to delete this WordPress installation? This action cannot be undone.')) + ->modalIcon('heroicon-o-trash') + ->modalIconColor('danger') + ->form([ + Toggle::make('delete_files') + ->label(__('Delete all files')) + ->default(true) + ->helperText(__('Permanently remove all WordPress files from the server')), + Toggle::make('delete_database') + ->label(__('Delete database')) + ->default(true) + ->helperText(__('Permanently delete the WordPress database and all content')), + ]) + ->action(function (array $data, array $record): void { + try { + $result = $this->getAgent()->wpDelete( + $this->getUsername(), + $record['id'], + $data['delete_files'] ?? true, + $data['delete_database'] ?? true + ); + + if ($result['success'] ?? false) { + // Delete screenshot if exists + $screenshotPath = storage_path('app/public/screenshots/wp-' . $record['id'] . '.png'); + if (file_exists($screenshotPath)) { + @unlink($screenshotPath); + } + + Notification::make() + ->title(__('WordPress Deleted')) + ->success() + ->send(); + $this->loadData(); + $this->resetTable(); + } else { + throw new Exception($result['error'] ?? __('Deletion failed')); + } + } catch (Exception $e) { + Notification::make() + ->title(__('Deletion Failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + }), + ]) + ->icon('heroicon-o-ellipsis-vertical') + ->color('gray') + ->iconButton(), + ]) + ->emptyStateHeading(__('No WordPress Sites')) + ->emptyStateDescription(__('Click "Install WordPress" to create your first site')) + ->emptyStateIcon('heroicon-o-globe-alt'); + } + + public function getTableRecordKey(\Illuminate\Database\Eloquent\Model|array $record): string + { + return is_array($record) ? ($record['id'] ?? '') : $record->getKey(); + } + + public function generateSecurePassword(int $length = 16): string + { + $lowercase = 'abcdefghijklmnopqrstuvwxyz'; + $uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + $numbers = '0123456789'; + $special = '!@#$%^&*'; + + // Ensure at least one of each required type + $password = $lowercase[random_int(0, strlen($lowercase) - 1)] + . $uppercase[random_int(0, strlen($uppercase) - 1)] + . $numbers[random_int(0, strlen($numbers) - 1)] + . $special[random_int(0, strlen($special) - 1)]; + + // Fill the rest with random characters from all types + $allChars = $lowercase . $uppercase . $numbers . $special; + for ($i = strlen($password); $i < $length; $i++) { + $password .= $allChars[random_int(0, strlen($allChars) - 1)]; + } + + // Shuffle the password to randomize position of required characters + return str_shuffle($password); + } + + public function closeCredentials(): void + { + $this->showCredentials = false; + $this->credentials = []; + } + + protected function getHeaderActions(): array + { + return [ + $this->getTourAction(), + $this->scanAction(), + $this->installAction(), + ]; + } + + protected function scanAction(): Action + { + return Action::make('scan') + ->label(__('Scan for Sites')) + ->icon('heroicon-o-magnifying-glass') + ->color('gray') + ->modalHeading(__('Scan for WordPress Sites')) + ->modalDescription(__('Search your home folder for WordPress installations not yet tracked.')) + ->modalIcon('heroicon-o-magnifying-glass') + ->modalIconColor('info') + ->modalSubmitActionLabel(__('Scan')) + ->form(function (): array { + // Perform scan when form is loaded + $result = $this->getAgent()->wpScan($this->getUsername()); + $this->scannedSites = ($result['success'] ?? false) ? ($result['found'] ?? []) : []; + + if (empty($this->scannedSites)) { + return [ + \Filament\Forms\Components\Placeholder::make('no_sites') + ->label('') + ->content(__('No untracked WordPress installations found in your home folder.')), + ]; + } + + $options = []; + foreach ($this->scannedSites as $site) { + $label = ($site['site_url'] ?? $site['path']) . ' (v' . ($site['version'] ?? '?') . ')'; + $options[$site['path']] = $label; + } + + return [ + \Filament\Forms\Components\Placeholder::make('found_info') + ->label('') + ->content(__('Found :count WordPress installation(s). Select which ones to import:', ['count' => count($this->scannedSites)])), + \Filament\Forms\Components\CheckboxList::make('sites_to_import') + ->label(__('WordPress Sites')) + ->options($options) + ->default(array_keys($options)) + ->descriptions(collect($this->scannedSites)->mapWithKeys(fn ($site) => [$site['path'] => $site['path']])->toArray()) + ->bulkToggleable(), + ]; + }) + ->action(function (array $data): void { + $sitesToImport = $data['sites_to_import'] ?? []; + + if (empty($sitesToImport)) { + Notification::make() + ->title(__('No Sites Selected')) + ->body(__('Please select at least one site to import.')) + ->warning() + ->send(); + return; + } + + $imported = 0; + $failed = 0; + + foreach ($sitesToImport as $path) { + try { + $result = $this->getAgent()->wpImport($this->getUsername(), $path); + if ($result['success'] ?? false) { + $imported++; + } else { + $failed++; + } + } catch (Exception $e) { + $failed++; + } + } + + if ($imported > 0) { + Notification::make() + ->title(__('Import Complete')) + ->body(__(':count site(s) imported successfully.', ['count' => $imported])) + ->success() + ->send(); + } + + if ($failed > 0) { + Notification::make() + ->title(__('Some Imports Failed')) + ->body(__(':count site(s) could not be imported.', ['count' => $failed])) + ->warning() + ->send(); + } + + $this->loadData(); + $this->resetTable(); + }); + } + + protected function installAction(): Action + { + $domainOptions = []; + foreach ($this->domains as $domain) { + $domainOptions[$domain['domain']] = $domain['domain']; + } + + return Action::make('install') + ->label(__('Install WordPress')) + ->icon('heroicon-o-plus-circle') + ->color('primary') + ->modalWidth('lg') + ->modalHeading(__('Install New WordPress Site')) + ->modalDescription(__('Set up a new WordPress installation on one of your domains')) + ->modalIcon('heroicon-o-pencil-square') + ->modalIconColor('primary') + ->modalSubmitActionLabel(__('Install WordPress')) + ->form([ + Select::make('domain') + ->label(__('Domain')) + ->options($domainOptions) + ->required() + ->searchable() + ->placeholder(__('Select a domain...')) + ->helperText(__('The domain where WordPress will be installed')), + Toggle::make('use_www') + ->label(__('Use www prefix')) + ->helperText(__('Install on www.domain.com instead of domain.com')) + ->default(false), + TextInput::make('path') + ->label(__('Directory (optional)')) + ->placeholder(__('Leave empty to install in root')) + ->helperText(__('e.g., "blog" to install at domain.com/blog')), + TextInput::make('site_title') + ->label(__('Site Title')) + ->required() + ->default(__('My WordPress Site')) + ->helperText(__('The name of your WordPress site')), + TextInput::make('admin_user') + ->label(__('Admin Username')) + ->required() + ->default('admin') + ->alphaNum() + ->helperText(__('Username for the WordPress admin account')), + TextInput::make('admin_password') + ->label(__('Admin Password')) + ->password() + ->revealable() + ->required() + ->default(fn () => $this->generateSecurePassword()) + ->minLength(8) + ->rules([ + 'regex:/[a-z]/', // lowercase + 'regex:/[A-Z]/', // uppercase + 'regex:/[0-9]/', // number + ]) + ->suffixActions([ + Action::make('generatePassword') + ->icon('heroicon-o-arrow-path') + ->tooltip(__('Generate secure password')) + ->action(fn ($set) => $set('admin_password', $this->generateSecurePassword())), + Action::make('copyPassword') + ->icon('heroicon-o-clipboard-document') + ->tooltip(__('Copy to clipboard')) + ->action(function ($state, $livewire) { + if ($state) { + $escaped = addslashes($state); + $livewire->js("navigator.clipboard.writeText('{$escaped}')"); + Notification::make() + ->title(__('Copied to clipboard')) + ->success() + ->duration(2000) + ->send(); + } + }), + ]) + ->helperText(__('Minimum 8 characters with uppercase, lowercase, and numbers')), + TextInput::make('admin_email') + ->label(__('Admin Email')) + ->required() + ->email() + ->default(Auth::user()->email ?? '') + ->helperText(__('Email address for the WordPress admin account')), + Select::make('language') + ->label(__('Language')) + ->options([ + 'en_US' => __('English (United States)'), + 'en_GB' => __('English (UK)'), + 'es_ES' => __('Español (España)'), + 'es_MX' => __('Español (México)'), + 'fr_FR' => __('Français'), + 'de_DE' => __('Deutsch'), + 'it_IT' => __('Italiano'), + 'pt_BR' => __('Português (Brasil)'), + 'pt_PT' => __('Português (Portugal)'), + 'ru_RU' => __('Русский'), + 'ja' => __('日本語'), + 'zh_CN' => __('中文 (简体)'), + 'zh_TW' => __('中文 (繁體)'), + 'ar' => __('العربية'), + 'he_IL' => __('עברית'), + 'hi_IN' => __('हिन्दी'), + 'ko_KR' => __('한국어'), + 'nl_NL' => __('Nederlands'), + 'pl_PL' => __('Polski'), + 'sv_SE' => __('Svenska'), + 'tr_TR' => __('Türkçe'), + 'id_ID' => __('Bahasa Indonesia'), + 'th' => __('ไทย'), + 'vi' => __('Tiếng Việt'), + ]) + ->default('en_US') + ->searchable() + ->required() + ->helperText(__('Default language for WordPress admin and content')), + Toggle::make('enable_cache') + ->label(__('Enable Jabali Cache')) + ->helperText(__('Install Redis object caching for better performance')) + ->default(true), + Toggle::make('enable_auto_update') + ->label(__('Enable Auto-Updates')) + ->helperText(__('Automatically update WordPress, plugins, and themes')) + ->default(false), + ]) + ->action(function (array $data): void { + try { + Notification::make() + ->title(__('Installing WordPress...')) + ->body(__('This may take a minute.')) + ->info() + ->send(); + + $result = $this->getAgent()->wpInstall($this->getUsername(), $data['domain'], [ + 'path' => $data['path'] ?? '', + 'site_title' => $data['site_title'], + 'admin_user' => $data['admin_user'], + 'admin_password' => $data['admin_password'], + 'admin_email' => $data['admin_email'], + 'use_www' => $data['use_www'] ?? false, + 'language' => $data['language'] ?? 'en_US', + ]); + + if ($result['success'] ?? false) { + $this->credentials = [ + 'url' => $result['url'], + 'admin_url' => $result['admin_url'], + 'admin_user' => $result['admin_user'], + 'admin_password' => $result['admin_password'], + 'db_name' => $result['db_name'], + 'db_user' => $result['db_user'], + 'db_password' => $result['db_password'], + ]; + + // Enable Jabali Cache (Redis object cache) if requested + // Note: nginx page cache is enabled by default for all WordPress sites + if ($data['enable_cache'] ?? false) { + $siteId = $result['site_id'] ?? null; + if ($siteId) { + try { + $this->getAgent()->wpCacheEnable($this->getUsername(), $siteId); + $this->credentials['cache_enabled'] = true; + } catch (Exception $e) { + // Cache enable failed, but installation succeeded + $this->credentials['cache_enabled'] = false; + $this->credentials['cache_error'] = $e->getMessage(); + } + } + } + + // Store MySQL credentials for phpMyAdmin SSO + if (!empty($result['db_user']) && !empty($result['db_password'])) { + MysqlCredential::updateOrCreate( + [ + 'user_id' => Auth::id(), + 'mysql_username' => $result['db_user'], + ], + [ + 'mysql_password_encrypted' => Crypt::encryptString($result['db_password']), + ] + ); + } + + Notification::make() + ->title(__('WordPress Installed!')) + ->success() + ->send(); + + $this->loadData(); + $this->resetTable(); + + // Show credentials modal + $this->mountAction('showCredentialsAction'); + } else { + throw new Exception($result['error'] ?? __('Unknown error')); + } + } catch (Exception $e) { + Notification::make() + ->title(__('Installation Failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + }); + } + + public function autoLogin(string $siteId): void + { + try { + $result = $this->getAgent()->wpAutoLogin($this->getUsername(), $siteId); + + if ($result['success'] ?? false) { + $loginUrl = $result['login_url']; + $this->js("window.open('{$loginUrl}', '_blank')"); + } else { + throw new Exception($result['error'] ?? __('Failed to generate login link')); + } + } catch (Exception $e) { + Notification::make() + ->title(__('Auto-login Failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function deleteSite(string $siteId): void + { + $this->selectedSiteId = $siteId; + $this->mountAction('deleteAction'); + } + + public function deleteAction(): Action + { + return Action::make('deleteAction') + ->requiresConfirmation() + ->modalHeading(__('Delete WordPress Site')) + ->modalDescription(__('Are you sure you want to delete this WordPress installation? This action cannot be undone.')) + ->modalIcon('heroicon-o-trash') + ->modalIconColor('danger') + ->modalSubmitActionLabel(__('Delete WordPress Site')) + ->form([ + Toggle::make('delete_files') + ->label(__('Delete all files')) + ->default(true) + ->helperText(__('Permanently remove all WordPress files from the server')), + Toggle::make('delete_database') + ->label(__('Delete database')) + ->default(true) + ->helperText(__('Permanently delete the WordPress database and all content')), + ]) + ->color('danger') + ->action(function (array $data): void { + try { + $result = $this->getAgent()->wpDelete( + $this->getUsername(), + $this->selectedSiteId, + $data['delete_files'] ?? true, + $data['delete_database'] ?? true + ); + + if ($result['success'] ?? false) { + Notification::make() + ->title(__('WordPress Deleted')) + ->success() + ->send(); + $this->loadData(); + } else { + throw new Exception($result['error'] ?? __('Deletion failed')); + } + } catch (Exception $e) { + Notification::make() + ->title(__('Deletion Failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + }); + } + + public function toggleCache(string $siteId, bool $removePlugin = false, bool $resetData = false): void + { + try { + // Get site info to get domain + $site = collect($this->sites)->firstWhere('id', $siteId); + if (!$site) { + throw new Exception(__('Site not found')); + } + $siteDomain = $site['domain'] ?? ''; + + // Get current cache status (Redis object cache) + $statusResult = $this->getAgent()->wpCacheStatus($this->getUsername(), $siteId); + $isEnabled = ($statusResult['status']['enabled'] ?? false); + + if ($isEnabled) { + // Disable Redis object cache and nginx page cache + $result = $this->getAgent()->wpCacheDisable($this->getUsername(), $siteId, $removePlugin, $resetData); + if ($result['success'] ?? false) { + // Also update Domain model's page_cache_enabled field + if ($siteDomain) { + Domain::where('domain', $siteDomain) + ->where('user_id', Auth::id()) + ->update(['page_cache_enabled' => false]); + } + + $message = match (true) { + $removePlugin && $resetData => __('Jabali Cache has been completely uninstalled and all settings removed.'), + $removePlugin => __('Jabali Cache has been disabled and uninstalled from this site.'), + default => __('Jabali Cache has been disabled for this site.'), + }; + + Notification::make() + ->title(__('Cache Disabled')) + ->body($message) + ->success() + ->send(); + } else { + throw new Exception($result['error'] ?? __('Failed to disable cache')); + } + } else { + // Enable Redis object cache and nginx page cache + $result = $this->getAgent()->wpCacheEnable($this->getUsername(), $siteId); + if ($result['success'] ?? false) { + // Also update Domain model's page_cache_enabled field + if ($siteDomain) { + Domain::where('domain', $siteDomain) + ->where('user_id', Auth::id()) + ->update(['page_cache_enabled' => true]); + } + + Notification::make() + ->title(__('Cache Enabled')) + ->body(__('Jabali Cache has been enabled. Cache prefix: :prefix', ['prefix' => $result['cache_prefix'] ?? __('unknown')])) + ->success() + ->send(); + } else { + // Check if there are conflicting plugins + if (isset($result['conflicts']) && !empty($result['conflicts'])) { + $conflictNames = array_column($result['conflicts'], 'name'); + Notification::make() + ->title(__('Conflicting Plugins Detected')) + ->body(__('Please disable these caching plugins first: :plugins', ['plugins' => implode(', ', $conflictNames)])) + ->warning() + ->persistent() + ->send(); + } else { + throw new Exception($result['error'] ?? __('Failed to enable cache')); + } + } + } + + $this->loadData(); + $this->resetTable(); + } catch (Exception $e) { + Notification::make() + ->title(__('Cache Toggle Failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function toggleDebug(string $siteId): void + { + try { + $result = $this->getAgent()->send('wp.toggle_debug', [ + 'username' => $this->getUsername(), + 'site_id' => $siteId, + ]); + + if ($result['success'] ?? false) { + $enabled = $result['debug_enabled'] ?? false; + Notification::make() + ->title($enabled ? __('Debug Mode Enabled') : __('Debug Mode Disabled')) + ->body($enabled + ? __('WP_DEBUG is now ON. Check wp-content/debug.log for errors.') + : __('WP_DEBUG has been turned OFF.')) + ->color($enabled ? 'warning' : 'success') + ->send(); + $this->loadData(); + $this->resetTable(); + } else { + throw new Exception($result['error'] ?? __('Failed to toggle debug mode')); + } + } catch (Exception $e) { + Notification::make() + ->title(__('Debug Toggle Failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function toggleAutoUpdate(string $siteId): void + { + try { + $result = $this->getAgent()->send('wp.toggle_auto_update', [ + 'username' => $this->getUsername(), + 'site_id' => $siteId, + ]); + + if ($result['success'] ?? false) { + $enabled = $result['auto_update'] ?? false; + Notification::make() + ->title($enabled ? __('Auto-Update Enabled') : __('Auto-Update Disabled')) + ->body($enabled + ? __('WordPress core, plugins, and themes will be updated automatically.') + : __('Automatic updates have been disabled.')) + ->success() + ->send(); + $this->loadData(); + $this->resetTable(); + } else { + throw new Exception($result['error'] ?? __('Failed to toggle auto-update')); + } + } catch (Exception $e) { + Notification::make() + ->title(__('Auto-Update Toggle Failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function updateWordPress(string $siteId): void + { + try { + Notification::make() + ->title(__('Updating WordPress...')) + ->body(__('This may take a moment.')) + ->info() + ->send(); + + $result = $this->getAgent()->send('wp.update', [ + 'username' => $this->getUsername(), + 'site_id' => $siteId, + 'type' => 'all', + ]); + + if ($result['success'] ?? false) { + Notification::make() + ->title(__('WordPress Updated')) + ->body(__('Core, plugins, and themes have been updated.')) + ->success() + ->send(); + $this->loadData(); + $this->resetTable(); + } else { + throw new Exception($result['error'] ?? __('Update failed')); + } + } catch (Exception $e) { + Notification::make() + ->title(__('Update Failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function createStaging(string $siteId, string $subdomain): void + { + try { + Notification::make() + ->title(__('Creating Staging Environment...')) + ->body(__('This may take several minutes.')) + ->info() + ->send(); + + $result = $this->getAgent()->send('wp.create_staging', [ + 'username' => $this->getUsername(), + 'site_id' => $siteId, + 'subdomain' => 'staging-' . $subdomain, + ]); + + if ($result['success'] ?? false) { + Notification::make() + ->title(__('Staging Environment Created')) + ->body(__('Your staging site is available at: :url', ['url' => $result['staging_url'] ?? ''])) + ->success() + ->persistent() + ->send(); + $this->loadData(); + $this->resetTable(); + } else { + throw new Exception($result['error'] ?? __('Failed to create staging environment')); + } + } catch (Exception $e) { + Notification::make() + ->title(__('Staging Creation Failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function flushCache(string $siteId): void + { + try { + $result = $this->getAgent()->wpCacheFlush($this->getUsername(), $siteId); + + if ($result['success'] ?? false) { + $keysDeleted = $result['keys_deleted'] ?? null; + if ($keysDeleted !== null) { + $body = __('Object cache has been flushed. (:count keys deleted)', ['count' => $keysDeleted]); + } else { + $body = __('Object cache has been flushed.'); + } + + Notification::make() + ->title(__('Cache Flushed')) + ->body($body) + ->success() + ->send(); + } else { + throw new Exception($result['error'] ?? __('Failed to flush cache')); + } + } catch (Exception $e) { + Notification::make() + ->title(__('Flush Failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function getCacheStatus(string $siteId): array + { + try { + $result = $this->getAgent()->wpCacheStatus($this->getUsername(), $siteId); + if ($result['success'] ?? false) { + return $result['status']; + } + } catch (Exception $e) { + // Ignore errors, return empty status + } + + return [ + 'enabled' => false, + 'drop_in_installed' => false, + 'plugin_installed' => false, + 'redis_connected' => false, + 'cached_keys' => 0, + ]; + } + + public function runSecurityScan(string $siteId): void + { + // Find the site + $site = collect($this->sites)->firstWhere('id', $siteId); + if (!$site) { + Notification::make() + ->title(__('Site not found')) + ->danger() + ->send(); + return; + } + + // Check if WPScan is installed + exec('which wpscan 2>/dev/null', $output, $code); + if ($code !== 0) { + Notification::make() + ->title(__('WPScan not available')) + ->body(__('Please contact your administrator to enable security scanning.')) + ->warning() + ->send(); + return; + } + + $this->isSecurityScanning = true; + $this->scanningSiteId = $siteId; + $this->scanningSiteUrl = $site['url']; + $this->securityScanResults = []; + + Notification::make() + ->title(__('Starting security scan...')) + ->body(__('Scanning :url for vulnerabilities.', ['url' => $site['url']])) + ->info() + ->send(); + + // Run WPScan + $url = $site['url']; + exec("wpscan --url " . escapeshellarg($url) . " --format json --no-banner 2>&1", $scanOutput, $scanCode); + + $jsonOutput = implode("\n", $scanOutput); + $results = json_decode($jsonOutput, true); + + if (!$results) { + $this->securityScanResults = [ + 'error' => __('Failed to parse scan results'), + 'raw_output' => $jsonOutput, + 'url' => $url, + 'scan_time' => now()->format('Y-m-d H:i:s'), + ]; + } else { + $results['url'] = $url; + $results['scan_time'] = now()->format('Y-m-d H:i:s'); + $this->securityScanResults = $this->parseWpScanResults($results); + } + + $this->isSecurityScanning = false; + $this->showSecurityScanModal = true; + + $vulnCount = count($this->securityScanResults['vulnerabilities'] ?? []); + if ($vulnCount > 0) { + Notification::make() + ->title(__('Security scan completed')) + ->body(__('Found :count potential vulnerability(ies)', ['count' => $vulnCount])) + ->warning() + ->send(); + } else { + Notification::make() + ->title(__('Security scan completed')) + ->body(__('No vulnerabilities found!')) + ->success() + ->send(); + } + } + + protected function parseWpScanResults(array $results): array + { + $parsed = [ + 'url' => $results['url'] ?? '', + 'scan_time' => $results['scan_time'] ?? now()->format('Y-m-d H:i:s'), + 'wordpress_version' => null, + 'main_theme' => null, + 'plugins' => [], + 'vulnerabilities' => [], + 'interesting_findings' => [], + ]; + + // WordPress version + if (isset($results['version']['number'])) { + $parsed['wordpress_version'] = [ + 'number' => $results['version']['number'], + 'status' => $results['version']['status'] ?? __('unknown'), + 'vulnerabilities' => [], + ]; + + if (!empty($results['version']['vulnerabilities'])) { + foreach ($results['version']['vulnerabilities'] as $vuln) { + $parsed['vulnerabilities'][] = [ + 'type' => __('WordPress Core'), + 'title' => $vuln['title'] ?? __('Unknown vulnerability'), + 'references' => $vuln['references'] ?? [], + 'fixed_in' => $vuln['fixed_in'] ?? null, + ]; + } + } + } + + // Main theme + if (isset($results['main_theme']['slug'])) { + $parsed['main_theme'] = [ + 'name' => $results['main_theme']['slug'], + 'version' => $results['main_theme']['version']['number'] ?? __('Unknown'), + ]; + + if (!empty($results['main_theme']['vulnerabilities'])) { + foreach ($results['main_theme']['vulnerabilities'] as $vuln) { + $parsed['vulnerabilities'][] = [ + 'type' => __('Theme: :name', ['name' => $results['main_theme']['slug']]), + 'title' => $vuln['title'] ?? __('Unknown vulnerability'), + 'references' => $vuln['references'] ?? [], + 'fixed_in' => $vuln['fixed_in'] ?? null, + ]; + } + } + } + + // Plugins + if (!empty($results['plugins'])) { + foreach ($results['plugins'] as $slug => $plugin) { + $parsed['plugins'][] = [ + 'name' => $slug, + 'version' => $plugin['version']['number'] ?? __('Unknown'), + ]; + + if (!empty($plugin['vulnerabilities'])) { + foreach ($plugin['vulnerabilities'] as $vuln) { + $parsed['vulnerabilities'][] = [ + 'type' => __('Plugin: :name', ['name' => $slug]), + 'title' => $vuln['title'] ?? __('Unknown vulnerability'), + 'references' => $vuln['references'] ?? [], + 'fixed_in' => $vuln['fixed_in'] ?? null, + ]; + } + } + } + } + + // Interesting findings + if (!empty($results['interesting_findings'])) { + foreach ($results['interesting_findings'] as $finding) { + $parsed['interesting_findings'][] = [ + 'type' => $finding['type'] ?? 'info', + 'description' => $finding['to_s'] ?? ($finding['url'] ?? __('Unknown finding')), + 'url' => $finding['url'] ?? null, + ]; + } + } + + return $parsed; + } + + public function closeSecurityScanModal(): void + { + $this->showSecurityScanModal = false; + $this->securityScanResults = []; + $this->scanningSiteId = null; + $this->scanningSiteUrl = null; + } + + public function showCredentialsModal(): void + { + $this->mountAction('showCredentialsAction'); + } + + public function showCredentialsAction(): Action + { + return Action::make('showCredentialsAction') + ->modalHeading(__('WordPress Installed!')) + ->modalDescription(__('Save these credentials! They won\'t be shown again.')) + ->modalIcon('heroicon-o-check-circle') + ->modalIconColor('success') + ->modalSubmitAction(false) + ->modalCancelActionLabel(__('Done')) + ->infolist([ + Section::make(__('Site Information')) + ->icon('heroicon-o-globe-alt') + ->columns(1) + ->schema([ + TextEntry::make('site_url') + ->label(__('Site URL')) + ->state(fn () => $this->credentials['url'] ?? '') + ->copyable() + ->fontFamily('mono'), + TextEntry::make('admin_url') + ->label(__('Admin URL')) + ->state(fn () => $this->credentials['admin_url'] ?? '') + ->copyable() + ->fontFamily('mono'), + ]), + Section::make(__('Admin Credentials')) + ->icon('heroicon-o-user') + ->columns(2) + ->schema([ + TextEntry::make('admin_user') + ->label(__('Username')) + ->state(fn () => $this->credentials['admin_user'] ?? '') + ->copyable() + ->fontFamily('mono'), + TextEntry::make('admin_password') + ->label(__('Password')) + ->state(fn () => $this->credentials['admin_password'] ?? '') + ->copyable() + ->fontFamily('mono'), + ]), + Section::make(__('Database Credentials')) + ->icon('heroicon-o-circle-stack') + ->columns(1) + ->schema([ + TextEntry::make('db_name') + ->label(__('Database Name')) + ->state(fn () => $this->credentials['db_name'] ?? '') + ->copyable() + ->fontFamily('mono'), + TextEntry::make('db_user') + ->label(__('Database User')) + ->state(fn () => $this->credentials['db_user'] ?? '') + ->copyable() + ->fontFamily('mono'), + TextEntry::make('db_password') + ->label(__('Database Password')) + ->state(fn () => $this->credentials['db_password'] ?? '') + ->copyable() + ->fontFamily('mono'), + ]), + ]); + } + + public function showScanResultsModal(): void + { + $this->mountAction('showScanResultsAction'); + } + + public function showScanResultsAction(): Action + { + return Action::make('showScanResultsAction') + ->modalHeading(__('Found WordPress Sites')) + ->modalDescription(__('The following WordPress installations were found in your home folder and are not yet tracked. Click "Import" to add them to your dashboard.')) + ->modalIcon('heroicon-o-magnifying-glass') + ->modalIconColor('info') + ->modalSubmitAction(false) + ->modalCancelActionLabel(__('Close')) + ->infolist([ + Section::make(__('Discovered Sites')) + ->schema( + collect($this->scannedSites)->map(fn ($site, $index) => + TextEntry::make("site_{$index}") + ->label($site['site_url'] ?? __('WordPress Site')) + ->state($site['path']) + ->helperText(isset($site['version']) ? 'v' . $site['version'] : '') + )->toArray() + ), + ]); + } + + public function showSecurityScanResultsModal(): void + { + $this->mountAction('showSecurityScanResultsAction'); + } + + public function showSecurityScanResultsAction(): Action + { + $results = $this->securityScanResults; + $vulnCount = count($results['vulnerabilities'] ?? []); + + return Action::make('showSecurityScanResultsAction') + ->modalHeading(__('Security Scan Results')) + ->modalDescription($results['url'] ?? '') + ->modalIcon('heroicon-o-shield-check') + ->modalIconColor($vulnCount > 0 ? 'danger' : 'success') + ->modalSubmitAction(false) + ->modalCancelActionLabel(__('Close')) + ->infolist(function () use ($results, $vulnCount): array { + $schema = []; + + // Scan info + $schema[] = Section::make(__('Scan Information')) + ->icon('heroicon-o-information-circle') + ->columns(2) + ->schema([ + TextEntry::make('scanned_url') + ->label(__('Scanned URL')) + ->state($results['url'] ?? __('Unknown')), + TextEntry::make('scan_time') + ->label(__('Scan Time')) + ->state($results['scan_time'] ?? ''), + ]); + + // WordPress version + if (isset($results['wordpress_version'])) { + $schema[] = Section::make(__('WordPress Version')) + ->icon('heroicon-o-code-bracket') + ->schema([ + TextEntry::make('wp_version') + ->label(__('Version')) + ->state($results['wordpress_version']['number']) + ->badge() + ->color(match($results['wordpress_version']['status'] ?? '') { + 'insecure' => 'danger', + 'outdated' => 'warning', + default => 'success', + }), + ]); + } + + // Vulnerabilities + if ($vulnCount > 0) { + $vulnEntries = []; + foreach ($results['vulnerabilities'] as $index => $vuln) { + $vulnEntries[] = TextEntry::make("vuln_{$index}") + ->label($vuln['type']) + ->state($vuln['title']) + ->helperText($vuln['fixed_in'] ? __('Fixed in: :version', ['version' => $vuln['fixed_in']]) : '') + ->color('danger'); + } + $schema[] = Section::make(__('Vulnerabilities Found') . " ({$vulnCount})") + ->icon('heroicon-o-exclamation-triangle') + ->iconColor('danger') + ->schema($vulnEntries); + } else { + $schema[] = Section::make(__('No Vulnerabilities Found')) + ->icon('heroicon-o-check-circle') + ->iconColor('success') + ->description(__('Your WordPress site appears to be secure')); + } + + // Interesting findings + if (!empty($results['interesting_findings'])) { + $findingEntries = []; + foreach (array_slice($results['interesting_findings'], 0, 10) as $index => $finding) { + $findingEntries[] = TextEntry::make("finding_{$index}") + ->hiddenLabel() + ->state($finding['description']); + } + $schema[] = Section::make(__('Interesting Findings')) + ->icon('heroicon-o-eye') + ->collapsed() + ->schema($findingEntries); + } + + // Detected plugins + if (!empty($results['plugins'])) { + $pluginEntries = []; + foreach ($results['plugins'] as $index => $plugin) { + $pluginEntries[] = TextEntry::make("plugin_{$index}") + ->hiddenLabel() + ->state($plugin['name'] . ' v' . $plugin['version']) + ->badge() + ->color('gray'); + } + $schema[] = Section::make(__('Detected Plugins') . ' (' . count($results['plugins']) . ')') + ->icon('heroicon-o-puzzle-piece') + ->collapsed() + ->schema($pluginEntries); + } + + return $schema; + }); + } + + public function captureScreenshot(string $siteId): void + { + $site = collect($this->sites)->firstWhere('id', $siteId); + if (!$site) { + Notification::make() + ->title(__('Site not found')) + ->danger() + ->send(); + return; + } + + $url = $site['url']; + + try { + // Ensure screenshots directory exists + $screenshotDir = storage_path('app/public/screenshots'); + if (!is_dir($screenshotDir)) { + mkdir($screenshotDir, 0755, true); + } + + $filename = 'wp_' . $siteId . '.png'; + $filepath = $screenshotDir . '/' . $filename; + + // Use screenshot wrapper script that handles Chromium crashpad issues + $screenshotBin = base_path('bin/screenshot'); + + if (!file_exists($screenshotBin) || !is_executable($screenshotBin)) { + Notification::make() + ->title(__('Screenshot failed')) + ->body(__('Screenshot script not found.')) + ->warning() + ->send(); + return; + } + + $cmd = sprintf('%s %s %s 2>&1', + escapeshellarg($screenshotBin), + escapeshellarg($url), + escapeshellarg($filepath) + ); + + exec($cmd, $output, $code); + + if (file_exists($filepath) && filesize($filepath) > 1000) { + touch($filepath); + + Notification::make() + ->title(__('Screenshot captured')) + ->body(__('Website screenshot has been updated.')) + ->success() + ->send(); + } else { + Notification::make() + ->title(__('Screenshot failed')) + ->body(__('Could not capture screenshot. Error: ') . implode("\n", $output)) + ->warning() + ->send(); + } + } catch (Exception $e) { + Notification::make() + ->title(__('Screenshot failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function getScreenshotUrl(string $siteId): ?string + { + $filename = 'wp_' . $siteId . '.png'; + $filepath = storage_path('app/public/screenshots/' . $filename); + + if (file_exists($filepath)) { + // Add timestamp to bust cache + return asset('storage/screenshots/' . $filename) . '?t=' . filemtime($filepath); + } + + return null; + } + + public function hasScreenshot(string $siteId): bool + { + $filename = 'wp_' . $siteId . '.png'; + return file_exists(storage_path('app/public/screenshots/' . $filename)); + } +} diff --git a/app/Filament/Jabali/Widgets/DiskUsageWidget.php b/app/Filament/Jabali/Widgets/DiskUsageWidget.php new file mode 100644 index 0000000..45b2be4 --- /dev/null +++ b/app/Filament/Jabali/Widgets/DiskUsageWidget.php @@ -0,0 +1,60 @@ +getDiskUsageBytes(); + $quotaBytes = $user->quota_bytes; + $percent = $user->disk_usage_percent; + + return [ + 'used' => $this->formatBytes($usedBytes), + 'quota' => $quotaBytes > 0 ? $this->formatBytes($quotaBytes) : __('Unlimited'), + 'free' => $quotaBytes > 0 ? $this->formatBytes(max(0, $quotaBytes - $usedBytes)) : null, + 'percent' => $percent, + 'has_quota' => $quotaBytes > 0, + 'home' => $user->home_directory, + 'color' => $this->getColor($percent), + ]; + } + + protected function getColor(float $percent): string + { + if ($percent >= 90) { + return 'danger'; + } + if ($percent >= 70) { + return 'warning'; + } + + return 'success'; + } + + protected function formatBytes(int $bytes, int $precision = 2): string + { + if ($bytes === 0) { + return '0 B'; + } + + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $pow = floor(log($bytes) / log(1024)); + $pow = min($pow, count($units) - 1); + + return round($bytes / pow(1024, $pow), $precision) . ' ' . $units[$pow]; + } +} diff --git a/app/Filament/Jabali/Widgets/DnsPendingAddsTable.php b/app/Filament/Jabali/Widgets/DnsPendingAddsTable.php new file mode 100644 index 0000000..b8c0874 --- /dev/null +++ b/app/Filament/Jabali/Widgets/DnsPendingAddsTable.php @@ -0,0 +1,92 @@ +> + */ + protected function getRecords(): array + { + return collect($this->records)->values()->all(); + } + + public function table(Table $table): Table + { + return $table + ->records(fn () => $this->getRecords()) + ->columns([ + TextColumn::make('type') + ->label(__('Type')) + ->badge() + ->color('success'), + TextColumn::make('name') + ->label(__('Name')) + ->fontFamily(FontFamily::Mono), + TextColumn::make('content') + ->label(__('Content')) + ->fontFamily(FontFamily::Mono) + ->limit(50) + ->tooltip(fn (array $record): string => (string) ($record['content'] ?? '')), + TextColumn::make('ttl') + ->label(__('TTL')), + TextColumn::make('priority') + ->label(__('Priority')) + ->placeholder('-'), + ]) + ->actions([ + Action::make('removePending') + ->label(__('Remove')) + ->icon('heroicon-o-x-mark') + ->color('danger') + ->action(function (array $record): void { + $key = $record['key'] ?? null; + if (! $key) { + return; + } + + $this->dispatch('dns-pending-add-remove', key: $key); + }), + ]) + ->striped() + ->paginated(false) + ->emptyStateHeading(__('No pending records')) + ->emptyStateDescription(__('Queued records will appear here.')) + ->emptyStateIcon('heroicon-o-plus-circle') + ->poll(null); + } + + public function render() + { + return $this->getTable()->render(); + } +} diff --git a/app/Filament/Jabali/Widgets/DomainsWidget.php b/app/Filament/Jabali/Widgets/DomainsWidget.php new file mode 100644 index 0000000..201443e --- /dev/null +++ b/app/Filament/Jabali/Widgets/DomainsWidget.php @@ -0,0 +1,94 @@ +query($this->getTableQuery()) + ->heading(__('Your Domains')) + ->description(__('Recent domains in your account')) + ->columns([ + TextColumn::make('domain') + ->label(__('Domain')) + ->icon('heroicon-o-globe-alt') + ->url(fn (Domain $record): string => "https://{$record->domain}") + ->openUrlInNewTab() + ->weight('medium'), + + IconColumn::make('ssl_active') + ->label(__('SSL')) + ->state(fn (Domain $record): bool => $record->hasSslActive()) + ->boolean() + ->trueIcon('heroicon-o-lock-closed') + ->falseIcon('heroicon-o-lock-open') + ->trueColor('success') + ->falseColor('gray'), + + IconColumn::make('is_active') + ->label(__('Status')) + ->boolean() + ->trueIcon('heroicon-o-check-circle') + ->falseIcon('heroicon-o-x-circle') + ->trueColor('success') + ->falseColor('danger'), + + TextColumn::make('created_at') + ->label(__('Added')) + ->since() + ->dateTimeTooltip(), + ]) + ->actions([ + Action::make('visit') + ->label(__('Visit')) + ->icon('heroicon-o-arrow-top-right-on-square') + ->url(fn (Domain $record): string => "https://{$record->domain}") + ->openUrlInNewTab() + ->size('sm'), + ]) + ->headerActions([ + Action::make('manage') + ->label(__('Manage All')) + ->url(route('filament.jabali.pages.domains')) + ->button() + ->size('sm'), + ]) + ->emptyStateHeading(__('No domains yet')) + ->emptyStateDescription(__('Add your first domain to get started with your hosting.')) + ->emptyStateIcon('heroicon-o-globe-alt') + ->emptyStateActions([ + Action::make('add') + ->label(__('Add Domain')) + ->url(route('filament.jabali.pages.domains')) + ->icon('heroicon-o-plus') + ->button(), + ]) + ->paginated(false); + } + + protected function getTableQuery(): Builder + { + return Domain::query() + ->where('user_id', Auth::id()) + ->with(['sslCertificate']) + ->orderBy('created_at', 'desc') + ->limit(5); + } +} diff --git a/app/Filament/Jabali/Widgets/EmailStatsWidget.php b/app/Filament/Jabali/Widgets/EmailStatsWidget.php new file mode 100644 index 0000000..60c4cd0 --- /dev/null +++ b/app/Filament/Jabali/Widgets/EmailStatsWidget.php @@ -0,0 +1,72 @@ + $q->where('user_id', Auth::id())) + ->with(['mailboxes', 'domain']) + ->get(); + + $totalMailboxes = 0; + $totalUsed = 0; + $totalQuota = 0; + + foreach ($domains as $domain) { + $totalMailboxes += $domain->mailboxes->count(); + $totalUsed += $domain->mailboxes->sum('quota_used_bytes'); + $totalQuota += $domain->mailboxes->sum('quota_bytes'); + } + + $percent = $totalQuota > 0 ? round(($totalUsed / $totalQuota) * 100, 1) : 0; + + return [ + [ + 'value' => $domains->count(), + 'label' => __('Domains'), + 'icon' => 'heroicon-o-globe-alt', + 'color' => 'success', + ], + [ + 'value' => $totalMailboxes, + 'label' => __('Mailboxes'), + 'icon' => 'heroicon-o-envelope', + 'color' => 'info', + ], + [ + 'value' => $this->formatBytes($totalUsed), + 'label' => __('Used'), + 'icon' => 'heroicon-o-server', + 'color' => 'warning', + ], + [ + 'value' => $percent . '%', + 'label' => __('Quota'), + 'icon' => 'heroicon-o-chart-pie', + 'color' => $percent >= 90 ? 'danger' : ($percent >= 80 ? 'warning' : 'gray'), + ], + ]; + } + + protected function formatBytes(int $bytes): string + { + if ($bytes < 1024) return $bytes . ' B'; + if ($bytes < 1048576) return round($bytes / 1024, 1) . ' KB'; + if ($bytes < 1073741824) return round($bytes / 1048576, 1) . ' MB'; + return round($bytes / 1073741824, 1) . ' GB'; + } +} diff --git a/app/Filament/Jabali/Widgets/MailboxesWidget.php b/app/Filament/Jabali/Widgets/MailboxesWidget.php new file mode 100644 index 0000000..98992d4 --- /dev/null +++ b/app/Filament/Jabali/Widgets/MailboxesWidget.php @@ -0,0 +1,86 @@ +query($this->getTableQuery()) + ->heading(__('Your Mailboxes')) + ->description(__('Recent mailboxes in your account')) + ->columns([ + TextColumn::make('email') + ->label(__('Email')) + ->icon('heroicon-o-envelope') + ->weight('medium'), + + IconColumn::make('is_active') + ->label(__('Status')) + ->boolean() + ->trueIcon('heroicon-o-check-circle') + ->falseIcon('heroicon-o-x-circle') + ->trueColor('success') + ->falseColor('danger'), + + TextColumn::make('quota_formatted') + ->label(__('Quota')), + + TextColumn::make('created_at') + ->label(__('Added')) + ->since() + ->dateTimeTooltip(), + ]) + ->actions([ + Action::make('webmail') + ->label(__('Webmail')) + ->icon('heroicon-o-arrow-top-right-on-square') + ->url(fn (): string => '/webmail/') + ->openUrlInNewTab() + ->size('sm'), + ]) + ->headerActions([ + Action::make('manage') + ->label(__('Manage All')) + ->url(route('filament.jabali.pages.email')) + ->button() + ->size('sm'), + ]) + ->emptyStateHeading(__('No mailboxes yet')) + ->emptyStateDescription(__('Create your first mailbox to start receiving emails.')) + ->emptyStateIcon('heroicon-o-envelope') + ->emptyStateActions([ + Action::make('add') + ->label(__('Add Mailbox')) + ->url(route('filament.jabali.pages.email')) + ->icon('heroicon-o-plus') + ->button(), + ]) + ->paginated(false); + } + + protected function getTableQuery(): Builder + { + return Mailbox::query() + ->where('user_id', Auth::id()) + ->with(['emailDomain']) + ->orderBy('created_at', 'desc') + ->limit(5); + } +} diff --git a/app/Filament/Jabali/Widgets/QuickActionsWidget.php b/app/Filament/Jabali/Widgets/QuickActionsWidget.php new file mode 100644 index 0000000..4d8f74c --- /dev/null +++ b/app/Filament/Jabali/Widgets/QuickActionsWidget.php @@ -0,0 +1,72 @@ + __('Domains'), + 'icon' => 'heroicon-o-globe-americas', + 'url' => route('filament.jabali.pages.domains'), + 'primary' => true, + ], + [ + 'label' => __('Files'), + 'icon' => 'heroicon-o-folder', + 'url' => route('filament.jabali.pages.files'), + 'primary' => true, + ], + [ + 'label' => __('Email'), + 'icon' => 'heroicon-o-envelope', + 'url' => route('filament.jabali.pages.email'), + 'primary' => true, + ], + // Secondary actions + [ + 'label' => __('Databases'), + 'icon' => 'heroicon-o-circle-stack', + 'url' => route('filament.jabali.pages.databases'), + ], + [ + 'label' => __('WordPress'), + 'icon' => 'heroicon-o-command-line', + 'url' => route('filament.jabali.pages.wordpress'), + ], + [ + 'label' => __('SSL'), + 'icon' => 'heroicon-o-lock-closed', + 'url' => route('filament.jabali.pages.ssl'), + ], + [ + 'label' => __('Backups'), + 'icon' => 'heroicon-o-archive-box', + 'url' => route('filament.jabali.pages.backups'), + ], + [ + 'label' => __('Cron'), + 'icon' => 'heroicon-o-clock', + 'url' => route('filament.jabali.pages.cron-jobs'), + ], + [ + 'label' => __('SSH'), + 'icon' => 'heroicon-o-key', + 'url' => route('filament.jabali.pages.ssh-keys'), + ], + ]; + } +} diff --git a/app/Filament/Jabali/Widgets/RecentBackupsWidget.php b/app/Filament/Jabali/Widgets/RecentBackupsWidget.php new file mode 100644 index 0000000..30943f8 --- /dev/null +++ b/app/Filament/Jabali/Widgets/RecentBackupsWidget.php @@ -0,0 +1,109 @@ +query($this->getTableQuery()) + ->heading(__('Recent Backups')) + ->description(__('Your latest backup activity')) + ->columns([ + TextColumn::make('name') + ->label(__('Backup')) + ->icon('heroicon-o-archive-box') + ->weight('medium') + ->limit(30), + + TextColumn::make('size_bytes') + ->label(__('Size')) + ->formatStateUsing(fn ($state) => $this->formatBytes((int) $state)) + ->badge() + ->color('gray'), + + IconColumn::make('status') + ->label(__('Status')) + ->icon(fn (string $state): string => match ($state) { + 'completed' => 'heroicon-o-check-circle', + 'failed' => 'heroicon-o-x-circle', + 'running' => 'heroicon-o-arrow-path', + default => 'heroicon-o-clock', + }) + ->color(fn (string $state): string => match ($state) { + 'completed' => 'success', + 'failed' => 'danger', + 'running' => 'warning', + default => 'gray', + }), + + TextColumn::make('created_at') + ->label(__('Created')) + ->since() + ->dateTimeTooltip(), + ]) + ->actions([ + Action::make('download') + ->label(__('Download')) + ->icon('heroicon-o-arrow-down-tray') + ->url(fn (Backup $record): string => route('filament.jabali.pages.backups', ['download' => $record->id])) + ->visible(fn (Backup $record): bool => $record->status === 'completed') + ->size('sm'), + ]) + ->headerActions([ + Action::make('viewAll') + ->label(__('View All')) + ->url(route('filament.jabali.pages.backups')) + ->button() + ->size('sm'), + ]) + ->emptyStateHeading(__('No backups yet')) + ->emptyStateDescription(__('Create your first backup to protect your data.')) + ->emptyStateIcon('heroicon-o-archive-box') + ->emptyStateActions([ + Action::make('create') + ->label(__('Create Backup')) + ->url(route('filament.jabali.pages.backups')) + ->icon('heroicon-o-plus') + ->button(), + ]) + ->paginated(false); + } + + protected function getTableQuery(): Builder + { + return Backup::query() + ->where('user_id', Auth::id()) + ->orderBy('created_at', 'desc') + ->limit(5); + } + + protected function formatBytes(int $bytes, int $precision = 2): string + { + if ($bytes === 0) { + return '0 B'; + } + + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $pow = floor(log($bytes) / log(1024)); + $pow = min($pow, count($units) - 1); + + return round($bytes / pow(1024, $pow), $precision) . ' ' . $units[$pow]; + } +} diff --git a/app/Filament/Jabali/Widgets/StatsOverview.php b/app/Filament/Jabali/Widgets/StatsOverview.php new file mode 100644 index 0000000..643d2b3 --- /dev/null +++ b/app/Filament/Jabali/Widgets/StatsOverview.php @@ -0,0 +1,105 @@ +id)->count(); + + // Count email accounts + $emailCount = Mailbox::whereHas('emailDomain', function ($query) use ($user) { + $query->whereHas('domain', function ($q) use ($user) { + $q->where('user_id', $user->id); + }); + })->count(); + + // Count databases + $dbCount = 0; + $dbCountResolved = false; + + try { + $agent = app(AgentClient::class); + $result = $agent->mysqlListDatabases($user->username); + + if (($result['success'] ?? false) === true) { + $dbCountResolved = true; + $dbCount = count($result['databases'] ?? []); + } + } catch (\Exception $e) { + // Ignore agent errors and fall back to direct DB query + } + + if (! $dbCountResolved) { + $prefix = $user->username.'_'; + $connections = [config('database.default')]; + if ($connections[0] === 'sqlite') { + $connections = ['mysql', 'mariadb', 'sqlite']; + } + + foreach ($connections as $connection) { + try { + $databases = DB::connection($connection)->select('SHOW DATABASES LIKE ?', [$prefix.'%']); + $dbCount = count($databases); + if ($dbCount > 0) { + break; + } + } catch (\Exception $e) { + // Try next connection + } + } + } + + // Count SSL certificates + $sslCount = Domain::where('user_id', $user->id) + ->whereHas('sslCertificate', function ($query) { + $query->where('expires_at', '>', now()); + })->count(); + + return [ + [ + 'value' => $domainCount, + 'label' => __('Domains'), + 'icon' => 'heroicon-o-globe-alt', + 'color' => 'success', + ], + [ + 'value' => $emailCount, + 'label' => __('Mailboxes'), + 'icon' => 'heroicon-o-envelope', + 'color' => 'info', + ], + [ + 'value' => $dbCount, + 'label' => __('Databases'), + 'icon' => 'heroicon-o-circle-stack', + 'color' => 'warning', + ], + [ + 'value' => $sslCount, + 'label' => __('SSL Certificates'), + 'icon' => 'heroicon-o-lock-closed', + 'color' => 'success', + ], + ]; + } +} diff --git a/app/Filament/Jabali/Widgets/TrashTable.php b/app/Filament/Jabali/Widgets/TrashTable.php new file mode 100644 index 0000000..afec28b --- /dev/null +++ b/app/Filament/Jabali/Widgets/TrashTable.php @@ -0,0 +1,198 @@ +loadTrashItems(); + } + + public function getAgent(): AgentClient + { + if ($this->agent === null) { + $this->agent = new AgentClient(); + } + return $this->agent; + } + + public function getUsername(): string + { + return Auth::user()->username; + } + + public function loadTrashItems(): void + { + try { + $result = $this->getAgent()->fileListTrash($this->getUsername()); + $this->trashItems = $result['items'] ?? []; + } catch (Exception) { + $this->trashItems = []; + } + } + + public function table(Table $table): Table + { + return $table + ->records(fn () => $this->trashItems) + ->columns([ + TextColumn::make('name') + ->label(__('Name')) + ->icon(fn (array $record): string => $record['is_dir'] ? 'heroicon-o-folder' : 'heroicon-o-document') + ->iconColor(fn (array $record): string => $record['is_dir'] ? 'warning' : 'primary') + ->weight('medium') + ->searchable(), + TextColumn::make('trashed_at') + ->label(__('Deleted')) + ->formatStateUsing(fn (array $record): string => date('M d, Y H:i', $record['trashed_at'])) + ->color('gray'), + ]) + ->recordActions([ + Action::make('restore') + ->label(__('Restore')) + ->icon('heroicon-o-arrow-uturn-left') + ->color('success') + ->action(function (array $record): void { + $this->restoreItem($record['trash_name']); + }), + Action::make('delete') + ->label(__('Delete')) + ->icon('heroicon-o-x-mark') + ->color('danger') + ->requiresConfirmation() + ->modalHeading(__('Delete Permanently')) + ->modalDescription(__('This item will be permanently deleted. This cannot be undone.')) + ->modalSubmitActionLabel(__('Delete')) + ->action(function (array $record): void { + $this->deleteItem($record['trash_name']); + }), + ]) + ->headerActions([ + Action::make('emptyTrash') + ->label(__('Empty Trash')) + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->modalHeading(__('Empty Trash')) + ->modalDescription(__('All items in trash will be permanently deleted. This cannot be undone.')) + ->modalSubmitActionLabel(__('Empty Trash')) + ->visible(fn () => count($this->trashItems) > 0) + ->action(function (): void { + $this->emptyTrash(); + }), + Action::make('refresh') + ->label(__('Refresh')) + ->icon('heroicon-o-arrow-path') + ->color('gray') + ->action(function (): void { + $this->loadTrashItems(); + $this->resetTable(); + }), + ]) + ->emptyStateHeading(__('Trash is empty')) + ->emptyStateDescription(__('Deleted items will appear here')) + ->emptyStateIcon('heroicon-o-trash') + ->striped(); + } + + public function getTableRecordKey(Model|array $record): string + { + return is_array($record) ? $record['trash_name'] : $record->getKey(); + } + + public function restoreItem(string $trashName): void + { + try { + $result = $this->getAgent()->fileRestore($this->getUsername(), $trashName); + Notification::make() + ->title(__('Restored')) + ->body(__('Restored to: :path', ['path' => $result['restored_path'] ?? ''])) + ->success() + ->send(); + $this->loadTrashItems(); + $this->resetTable(); + $this->dispatch('trash-updated'); + } catch (Exception $e) { + Notification::make() + ->title(__('Error')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function deleteItem(string $trashName): void + { + try { + $trashPath = ".trash/$trashName"; + $this->getAgent()->fileDelete($this->getUsername(), $trashPath); + Notification::make() + ->title(__('Permanently deleted')) + ->success() + ->send(); + $this->loadTrashItems(); + $this->resetTable(); + } catch (Exception $e) { + Notification::make() + ->title(__('Error')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function emptyTrash(): void + { + try { + $result = $this->getAgent()->fileEmptyTrash($this->getUsername()); + Notification::make() + ->title(__('Trash emptied')) + ->body(__(':count items deleted', ['count' => $result['deleted'] ?? 0])) + ->success() + ->send(); + $this->loadTrashItems(); + $this->resetTable(); + $this->dispatch('trash-updated'); + } catch (Exception $e) { + Notification::make() + ->title(__('Error')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function render() + { + return view(static::$view); + } +} diff --git a/app/Http/Controllers/AutoDiscoverController.php b/app/Http/Controllers/AutoDiscoverController.php new file mode 100644 index 0000000..fdb9dff --- /dev/null +++ b/app/Http/Controllers/AutoDiscoverController.php @@ -0,0 +1,244 @@ +getContent(); + + // Parse email address from request + $email = $this->normalizeEmail($this->extractEmailFromRequest($xml)); + + if (! $email) { + return $this->errorResponse('No email address provided'); + } + + // Extract domain from email + $parts = explode('@', $email); + if (count($parts) !== 2) { + return $this->errorResponse('Invalid email address'); + } + + $domain = $parts[1]; + + // Check if domain is managed by our mail server + $emailDomain = EmailDomain::whereHas('domain', function ($query) use ($domain) { + $query->where('domain_name', $domain); + })->where('is_active', true)->first(); + + if (! $emailDomain) { + return $this->errorResponse('Domain not configured for email'); + } + + // Get mail server hostname + $mailServer = $this->getMailServer($emailDomain); + + return $this->autodiscoverResponse($email, $mailServer); + } + + /** + * Handle GET request for autodiscover (some clients use this) + */ + public function discoverGet(Request $request): Response + { + $email = $this->normalizeEmail($request->query('email') ?? $request->query('Email')); + + if (! $email) { + return response('Email parameter required', 400); + } + + $parts = explode('@', $email); + if (count($parts) !== 2) { + return response('Invalid email address', 400); + } + + $domain = $parts[1]; + + $emailDomain = EmailDomain::whereHas('domain', function ($query) use ($domain) { + $query->where('domain_name', $domain); + })->where('is_active', true)->first(); + + if (! $emailDomain) { + return response('Domain not configured', 404); + } + + $mailServer = $this->getMailServer($emailDomain); + + return $this->autodiscoverResponse($email, $mailServer); + } + + /** + * Extract email from autodiscover XML request + */ + private function extractEmailFromRequest(string $xml): ?string + { + if (empty($xml)) { + return null; + } + + // Try to parse XML + libxml_use_internal_errors(true); + $doc = simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NONET); + + if ($doc === false) { + return null; + } + + // Register namespaces + $doc->registerXPathNamespace('a', 'http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006'); + + // Try different XPath expressions + $results = $doc->xpath('//a:EMailAddress'); + if (! empty($results)) { + return trim((string) $results[0]); + } + + // Try without namespace + $results = $doc->xpath('//EMailAddress'); + if (! empty($results)) { + return trim((string) $results[0]); + } + + // Try AcceptableResponseSchema pattern + if (isset($doc->Request->EMailAddress)) { + return trim((string) $doc->Request->EMailAddress); + } + + return null; + } + + /** + * Get mail server hostname for the domain + */ + private function getMailServer(EmailDomain $emailDomain): string + { + // Use setting or fall back to domain-based hostname + $hostname = \App\Models\Setting::get('mail_hostname'); + + if ($hostname) { + return $hostname; + } + + return 'mail.'.$emailDomain->domain->domain_name; + } + + /** + * Generate autodiscover XML response + */ + private function autodiscoverResponse(string $email, string $mailServer): Response + { + $displayName = explode('@', $email)[0]; + $escapedEmail = $this->escapeXml($email); + $escapedServer = $this->escapeXml($mailServer); + $escapedDisplay = $this->escapeXml($displayName); + + $xml = << + + + + email + settings + + IMAP + {$escapedServer} + 993 + on + off + on + {$escapedEmail} + + + SMTP + {$escapedServer} + 587 + on + TLS + off + on + {$escapedEmail} + + + POP3 + {$escapedServer} + 995 + on + off + on + {$escapedEmail} + + + + +XML; + + return response($xml, 200) + ->header('Content-Type', 'application/xml; charset=utf-8'); + } + + /** + * Generate error response + */ + private function errorResponse(string $message): Response + { + $escapedMessage = $this->escapeXml($message); + + $xml = << + + + + 600 + {$escapedMessage} + + + + +XML; + + return response($xml, 200) + ->header('Content-Type', 'application/xml; charset=utf-8'); + } + + private function getTimestamp(): string + { + return date('H:i:s.v'); + } + + private function normalizeEmail(?string $email): ?string + { + if ($email === null) { + return null; + } + + $email = trim($email); + + if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) { + return null; + } + + return $email; + } + + private function escapeXml(string $value): string + { + return htmlspecialchars($value, ENT_XML1 | ENT_QUOTES, 'UTF-8'); + } +} diff --git a/app/Http/Controllers/AutoconfigController.php b/app/Http/Controllers/AutoconfigController.php new file mode 100644 index 0000000..68dde8d --- /dev/null +++ b/app/Http/Controllers/AutoconfigController.php @@ -0,0 +1,304 @@ +normalizeEmail($request->query('emailaddress')); + + if (! $email) { + return response('Email parameter required', 400); + } + + $parts = explode('@', $email); + if (count($parts) !== 2) { + return response('Invalid email address', 400); + } + + $domain = $parts[1]; + + // Check if domain is managed + $emailDomain = EmailDomain::whereHas('domain', function ($query) use ($domain) { + $query->where('domain_name', $domain); + })->where('is_active', true)->first(); + + if (! $emailDomain) { + return response('Domain not configured', 404); + } + + $mailServer = $this->getMailServer($emailDomain); + $displayName = Setting::get('webmail_product_name', 'Jabali Mail'); + + return $this->autoconfigResponse($domain, $mailServer, $displayName); + } + + /** + * Handle autoconfig for specific domain + * URL: /autoconfig/domain.com/config-v1.1.xml + */ + public function configForDomain(Request $request, string $domain): Response + { + if (! $this->isValidDomain($domain)) { + return response('Invalid domain', 400); + } + + $emailDomain = EmailDomain::whereHas('domain', function ($query) use ($domain) { + $query->where('domain_name', $domain); + })->where('is_active', true)->first(); + + if (! $emailDomain) { + return response('Domain not configured', 404); + } + + $mailServer = $this->getMailServer($emailDomain); + $displayName = Setting::get('webmail_product_name', 'Jabali Mail'); + + return $this->autoconfigResponse($domain, $mailServer, $displayName); + } + + /** + * Generate iOS/macOS mobile configuration profile + * URL: /mail/profile/{domain} + */ + public function mobileProfile(Request $request, string $domain): Response + { + if (! $this->isValidDomain($domain)) { + return response('Invalid domain', 400); + } + + $email = $this->normalizeEmail($request->query('email')); + + if (! $email) { + return response('Email parameter required', 400); + } + + $emailDomainName = substr(strrchr($email, '@') ?: '', 1); + if ($emailDomainName !== $domain) { + return response('Email does not match domain', 400); + } + + $emailDomain = EmailDomain::whereHas('domain', function ($query) use ($domain) { + $query->where('domain_name', $domain); + })->where('is_active', true)->first(); + + if (! $emailDomain) { + return response('Domain not configured', 404); + } + + $mailServer = $this->getMailServer($emailDomain); + $displayName = Setting::get('webmail_product_name', 'Jabali Mail'); + $escapedDomain = $this->escapeXml($domain); + $escapedEmail = $this->escapeXml($email); + $escapedMailServer = $this->escapeXml($mailServer); + $escapedDisplayName = $this->escapeXml($displayName); + $uuid = $this->generateUuid(); + $payloadUuid = $this->generateUuid(); + + $profile = << + + + + PayloadContent + + + EmailAccountDescription + {$escapedDomain} Email + EmailAccountName + {$escapedDisplayName} + EmailAccountType + EmailTypeIMAP + EmailAddress + {$escapedEmail} + IncomingMailServerAuthentication + EmailAuthPassword + IncomingMailServerHostName + {$escapedMailServer} + IncomingMailServerPortNumber + 993 + IncomingMailServerUseSSL + + IncomingMailServerUsername + {$escapedEmail} + OutgoingMailServerAuthentication + EmailAuthPassword + OutgoingMailServerHostName + {$escapedMailServer} + OutgoingMailServerPortNumber + 587 + OutgoingMailServerUseSSL + + OutgoingMailServerUsername + {$escapedEmail} + OutgoingPasswordSameAsIncomingPassword + + PayloadDescription + Email account configuration for {$escapedDomain} + PayloadDisplayName + {$escapedDomain} Email + PayloadIdentifier + com.jabali.email.{$escapedDomain} + PayloadType + com.apple.mail.managed + PayloadUUID + {$payloadUuid} + PayloadVersion + 1 + PreventAppSheet + + PreventMove + + SMIMEEnabled + + + + PayloadDescription + Email configuration profile for {$escapedDomain} + PayloadDisplayName + {$escapedDisplayName} - {$escapedDomain} + PayloadIdentifier + com.jabali.mailconfig.{$escapedDomain} + PayloadOrganization + {$escapedDisplayName} + PayloadRemovalDisallowed + + PayloadType + Configuration + PayloadUUID + {$uuid} + PayloadVersion + 1 + + +MOBILECONFIG; + + return response($profile, 200) + ->header('Content-Type', 'application/x-apple-aspen-config') + ->header('Content-Disposition', 'attachment; filename="email-config.mobileconfig"'); + } + + /** + * Get mail server hostname + */ + private function getMailServer(EmailDomain $emailDomain): string + { + $hostname = Setting::get('mail_hostname'); + + if ($hostname) { + return $hostname; + } + + return 'mail.'.$emailDomain->domain->domain_name; + } + + /** + * Generate autoconfig XML response + */ + private function autoconfigResponse(string $domain, string $mailServer, string $displayName): Response + { + $escapedDomain = $this->escapeXml($domain); + $escapedMailServer = $this->escapeXml($mailServer); + $escapedDisplayName = $this->escapeXml($displayName); + + $xml = << + + + {$escapedDomain} + {$escapedDisplayName} + {$escapedDisplayName} + + {$escapedMailServer} + 993 + SSL + password-cleartext + %EMAILADDRESS% + + + {$escapedMailServer} + 995 + SSL + password-cleartext + %EMAILADDRESS% + + + {$escapedMailServer} + 587 + STARTTLS + password-cleartext + %EMAILADDRESS% + + + Webmail access + + + +XML; + + return response($xml, 200) + ->header('Content-Type', 'application/xml; charset=utf-8'); + } + + private function normalizeEmail(?string $email): ?string + { + if ($email === null) { + return null; + } + + $email = trim($email); + + if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) { + return null; + } + + return $email; + } + + private function isValidDomain(string $domain): bool + { + return filter_var($domain, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false; + } + + private function escapeXml(string $value): string + { + return htmlspecialchars($value, ENT_XML1 | ENT_QUOTES, 'UTF-8'); + } + + /** + * Generate UUID v4 + */ + private function generateUuid(): string + { + return sprintf( + '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', + mt_rand(0, 0xFFFF), + mt_rand(0, 0xFFFF), + mt_rand(0, 0xFFFF), + mt_rand(0, 0x0FFF) | 0x4000, + mt_rand(0, 0x3FFF) | 0x8000, + mt_rand(0, 0xFFFF), + mt_rand(0, 0xFFFF), + mt_rand(0, 0xFFFF) + ); + } +} diff --git a/app/Http/Controllers/BackupDownloadController.php b/app/Http/Controllers/BackupDownloadController.php new file mode 100644 index 0000000..8724639 --- /dev/null +++ b/app/Http/Controllers/BackupDownloadController.php @@ -0,0 +1,120 @@ +query('path', ''); + $decodedPath = base64_decode($encodedPath, true); + + if ($decodedPath === false || $decodedPath === '') { + abort(404, 'Backup file not found'); + } + + $realPath = realpath($decodedPath); + + if ($realPath === false || ! is_file($realPath)) { + abort(404, 'Backup file not found'); + } + + // Verify the user owns this backup (path should be in their home directory) + $user = Auth::guard('web')->user(); + if (! $user) { + abort(403, 'Unauthorized'); + } + + $homeDirectory = $user->home_directory ?: "/home/{$user->username}"; + $backupDirectory = rtrim($homeDirectory, '/').'/backups'; + $backupRealPath = realpath($backupDirectory); + + if ($backupRealPath === false) { + abort(404, 'Backup directory not found'); + } + + if ($realPath !== $backupRealPath && ! str_starts_with($realPath, $backupRealPath.DIRECTORY_SEPARATOR)) { + abort(403, 'Unauthorized access to this backup'); + } + + return response()->download($realPath); + } + + public function adminDownload(Request $request): BinaryFileResponse|StreamedResponse + { + $backupId = $request->query('id'); + + if (empty($backupId)) { + abort(404, 'Backup ID required'); + } + + // Verify admin access + $user = Auth::guard('web')->user(); + if (! $user || ! $user->is_admin) { + abort(403, 'Unauthorized'); + } + + $backup = Backup::find($backupId); + if (! $backup) { + abort(404, 'Backup not found'); + } + + $path = $backup->local_path; + + if (empty($path)) { + abort(404, 'Backup file not found on disk'); + } + + $realPath = realpath($path); + + if ($realPath === false) { + abort(404, 'Backup file not found on disk'); + } + + // Handle directory backups by creating a zip archive on-the-fly + if (is_dir($realPath)) { + $backupName = basename($realPath).'.zip'; + + return response()->streamDownload(function () use ($realPath) { + $zip = new \ZipArchive; + $tempFile = tempnam(sys_get_temp_dir(), 'backup_'); + $zip->open($tempFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE); + + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($realPath, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::LEAVES_ONLY + ); + + foreach ($files as $file) { + if (! $file->isDir()) { + $filePath = $file->getRealPath(); + $relativePath = substr($filePath, strlen($realPath) + 1); + $zip->addFile($filePath, $relativePath); + } + } + + $zip->close(); + + readfile($tempFile); + unlink($tempFile); + }, $backupName, [ + 'Content-Type' => 'application/zip', + ]); + } + + // For single file backups (tar.gz) + if (! is_file($realPath)) { + abort(404, 'Backup file not found on disk'); + } + + return response()->download($realPath); + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..8677cd5 --- /dev/null +++ b/app/Http/Controllers/Controller.php @@ -0,0 +1,8 @@ +user(); + + // Verify admin has permission to impersonate + if (! $admin || ! $admin->is_admin) { + abort(403, 'Unauthorized'); + } + + // Cannot impersonate other admins + if ($user->is_admin) { + abort(403, 'Cannot impersonate administrators'); + } + + // Cannot impersonate inactive users + if (! $user->is_active) { + abort(403, 'Cannot impersonate inactive users'); + } + + // Create the impersonation token + $token = ImpersonationToken::createForUser($admin, $user, $request->ip()); + + // Redirect to the impersonation route + return redirect()->route('impersonate', ['token' => $token->token]); + } + + public function impersonate(Request $request, string $token): RedirectResponse + { + $impersonationToken = ImpersonationToken::findValidToken($token, $request->ip()); + + if (! $impersonationToken) { + return redirect()->route('filament.admin.pages.dashboard') + ->with('error', 'Invalid or expired impersonation token.'); + } + + // Mark token as used + $impersonationToken->markAsUsed(); + + // Get the target user + $targetUser = $impersonationToken->targetUser; + + if (! $targetUser || ! $targetUser->is_active) { + return redirect()->route('filament.admin.pages.dashboard') + ->with('error', 'User not found or inactive.'); + } + + // Store the admin ID before any session changes + $adminId = $impersonationToken->admin_id; + + // Clear any previous impersonation session data first + // This prevents session corruption when impersonating multiple users + session()->forget('impersonated_by'); + session()->forget('impersonation_token_id'); + + // Directly set the user on the web guard without full logout + // This avoids session invalidation that would affect the admin guard + Auth::guard('web')->setUser($targetUser); + + // Update the session with the new user's ID for the web guard + session()->put(Auth::guard('web')->getName(), $targetUser->getAuthIdentifier()); + + // Update the password hash in session for AuthenticateSession middleware + // This prevents the middleware from logging out the user due to hash mismatch + session()->put('password_hash_web', $targetUser->getAuthPassword()); + + // Store impersonation info in session + session()->put('impersonated_by', $adminId); + session()->put('impersonation_token_id', $impersonationToken->id); + + // Save the session to persist changes + session()->save(); + + // Redirect to user panel + return redirect()->route('filament.jabali.pages.dashboard'); + } + + /** + * Stop impersonation and return to admin panel + */ + public function stop(): RedirectResponse + { + // Clear impersonation session data + session()->forget('impersonated_by'); + session()->forget('impersonation_token_id'); + + // Clear the web guard session data without full logout + // This preserves the admin session + session()->forget(Auth::guard('web')->getName()); + session()->forget('password_hash_web'); + + // Clear the user from the web guard + Auth::guard('web')->setUser(null); + + // Save session changes + session()->save(); + + // Redirect back to admin panel + return redirect()->route('filament.admin.pages.dashboard') + ->with('success', 'Returned to admin account.'); + } +} diff --git a/app/Http/Controllers/LanguageController.php b/app/Http/Controllers/LanguageController.php new file mode 100644 index 0000000..696efde --- /dev/null +++ b/app/Http/Controllers/LanguageController.php @@ -0,0 +1,31 @@ + []])); + + if (!in_array($locale, $supportedLanguages)) { + abort(400, 'Unsupported language'); + } + + // Store in session + Session::put(config('languages.session_key', 'locale'), $locale); + + // Update user preference if authenticated + if (auth()->check()) { + auth()->user()->update(['locale' => $locale]); + } + + return redirect()->back(); + } +} diff --git a/app/Http/Middleware/RedirectAdminFromUserPanel.php b/app/Http/Middleware/RedirectAdminFromUserPanel.php new file mode 100644 index 0000000..112cf64 --- /dev/null +++ b/app/Http/Middleware/RedirectAdminFromUserPanel.php @@ -0,0 +1,32 @@ +user('web'); + + // If the user on the web guard is an admin AND not being impersonated, + // redirect to admin panel + if ($user && $user->is_admin && !session()->has('impersonated_by')) { + return redirect()->route('filament.admin.pages.dashboard'); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/SecurityHeaders.php b/app/Http/Middleware/SecurityHeaders.php new file mode 100644 index 0000000..09de3d9 --- /dev/null +++ b/app/Http/Middleware/SecurityHeaders.php @@ -0,0 +1,48 @@ +headers->set('Content-Security-Policy', $csp); + $response->headers->set('X-Content-Type-Options', 'nosniff'); + $response->headers->set('X-Frame-Options', 'SAMEORIGIN'); + $response->headers->set('X-XSS-Protection', '1; mode=block'); + $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); + $response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); + + // Only add HSTS in production with HTTPS + if ($request->secure() && app()->environment('production')) { + $response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + } + + return $response; + } +} diff --git a/app/Http/Middleware/SetLocale.php b/app/Http/Middleware/SetLocale.php new file mode 100644 index 0000000..f545c4b --- /dev/null +++ b/app/Http/Middleware/SetLocale.php @@ -0,0 +1,95 @@ + []])); + $defaultLanguage = config('languages.default', 'en'); + $sessionKey = config('languages.session_key', 'locale'); + + // Priority: 1. Session, 2. User preference, 3. Browser, 4. Default + $locale = null; + + // Check session + if (Session::has($sessionKey)) { + $locale = Session::get($sessionKey); + } + + // Check authenticated user's preference + if (!$locale && auth()->check() && auth()->user()->locale) { + $locale = auth()->user()->locale; + Session::put($sessionKey, $locale); + } + + // Check browser's Accept-Language header + if (!$locale) { + $browserLocale = $this->getBrowserLocale($request, $supportedLanguages); + if ($browserLocale) { + $locale = $browserLocale; + } + } + + // Fallback to default + if (!$locale || !in_array($locale, $supportedLanguages)) { + $locale = $defaultLanguage; + } + + // Set the application locale + App::setLocale($locale); + + // Set text direction for RTL languages + $direction = config("languages.supported.{$locale}.direction", 'ltr'); + view()->share('textDirection', $direction); + view()->share('currentLocale', $locale); + + return $next($request); + } + + /** + * Get the browser's preferred locale from Accept-Language header. + */ + protected function getBrowserLocale(Request $request, array $supportedLanguages): ?string + { + $acceptLanguage = $request->header('Accept-Language'); + + if (!$acceptLanguage) { + return null; + } + + // Parse Accept-Language header + $languages = []; + foreach (explode(',', $acceptLanguage) as $lang) { + $parts = explode(';q=', trim($lang)); + $code = strtolower(substr($parts[0], 0, 2)); + $quality = isset($parts[1]) ? (float) $parts[1] : 1.0; + $languages[$code] = $quality; + } + + // Sort by quality + arsort($languages); + + // Find first supported language + foreach (array_keys($languages) as $code) { + if (in_array($code, $supportedLanguages)) { + return $code; + } + } + + return null; + } +} diff --git a/app/Jobs/IndexRemoteBackups.php b/app/Jobs/IndexRemoteBackups.php new file mode 100644 index 0000000..f154bc5 --- /dev/null +++ b/app/Jobs/IndexRemoteBackups.php @@ -0,0 +1,162 @@ +where('is_active', true); + if ($this->destinationId) { + $query->where('id', $this->destinationId); + } + $destinations = $query->get(); + + // Get all users for lookup + $users = User::pluck('id', 'username')->toArray(); + + foreach ($destinations as $destination) { + try { + $this->indexDestination($agent, $destination, $users); + } catch (Exception $e) { + Log::warning("IndexRemoteBackups: Failed to index destination {$destination->name}: " . $e->getMessage()); + } + } + + Log::info('IndexRemoteBackups: Indexing completed'); + } + + protected function indexDestination(AgentClient $agent, BackupDestination $destination, array $users): void + { + $config = array_merge($destination->config ?? [], ['type' => $destination->type]); + + // List root directory to get all backup timestamps + $result = $agent->send('backup.list_remote', [ + 'destination' => $config, + 'path' => '', + ]); + + if (!($result['success'] ?? false) || empty($result['files'])) { + return; + } + + $indexedBackups = []; + + foreach ($result['files'] as $file) { + if (!$file['is_directory']) { + continue; + } + + $backupName = basename($file['name']); + + // Check for timestamp directories (incremental backups: 2026-01-19_210219) + if (!preg_match('/^\d{4}-\d{2}-\d{2}_\d{6}$/', $backupName)) { + continue; + } + + // List the backup directory to find user subdirectories + $backupContents = $agent->send('backup.list_remote', [ + 'destination' => $config, + 'path' => $backupName, + ]); + + if (!($backupContents['success'] ?? false) || empty($backupContents['files'])) { + continue; + } + + // Check each subdirectory to see if it's a user + foreach ($backupContents['files'] as $subFile) { + if (!$subFile['is_directory']) { + continue; + } + + $username = basename($subFile['name']); + + // Skip . and .. + if ($username === '.' || $username === '..') { + continue; + } + + // Check if this is a valid user + if (!isset($users[$username])) { + continue; + } + + $userId = $users[$username]; + $backupPath = $backupName . '/' . $username; + $backupDate = UserRemoteBackup::parseBackupDate($backupName); + + // Upsert the backup record + UserRemoteBackup::updateOrCreate( + [ + 'user_id' => $userId, + 'destination_id' => $destination->id, + 'backup_name' => $backupName, + ], + [ + 'backup_path' => $backupPath, + 'backup_type' => 'incremental', + 'backup_date' => $backupDate, + 'indexed_at' => now(), + ] + ); + + $indexedBackups[] = "$username/$backupName"; + } + } + + Log::info("IndexRemoteBackups: Indexed " . count($indexedBackups) . " backups from {$destination->name}"); + + // Clean up old entries that no longer exist on the remote + // (backups that were deleted via retention policy) + $this->cleanupDeletedBackups($destination->id, $result['files']); + } + + protected function cleanupDeletedBackups(int $destinationId, array $remoteFiles): void + { + // Get all backup names that exist on remote + $remoteBackupNames = []; + foreach ($remoteFiles as $file) { + if ($file['is_directory']) { + $name = basename($file['name']); + if (preg_match('/^\d{4}-\d{2}-\d{2}_\d{6}$/', $name)) { + $remoteBackupNames[] = $name; + } + } + } + + // Delete index entries for backups that no longer exist + if (!empty($remoteBackupNames)) { + $deleted = UserRemoteBackup::where('destination_id', $destinationId) + ->whereNotIn('backup_name', $remoteBackupNames) + ->delete(); + + if ($deleted > 0) { + Log::info("IndexRemoteBackups: Cleaned up {$deleted} deleted backup entries"); + } + } + } +} diff --git a/app/Jobs/IssueSslCertificate.php b/app/Jobs/IssueSslCertificate.php new file mode 100644 index 0000000..ed785b9 --- /dev/null +++ b/app/Jobs/IssueSslCertificate.php @@ -0,0 +1,117 @@ +find($this->domainId); + + if (!$domain) { + Log::warning("IssueSslCertificate: Domain {$this->domainId} not found"); + return; + } + + // Skip if domain already has active SSL + $existingSsl = SslCertificate::where('domain_id', $domain->id) + ->where('status', 'active') + ->first(); + + if ($existingSsl) { + Log::info("IssueSslCertificate: Domain {$domain->domain} already has active SSL"); + return; + } + + if (!$domain->user) { + Log::warning("IssueSslCertificate: Domain {$domain->domain} has no user"); + return; + } + + Log::info("IssueSslCertificate: Issuing SSL for {$domain->domain}"); + + try { + $agent = new AgentClient(); + $result = $agent->sslIssue( + $domain->domain, + $domain->user->username, + $domain->user->email, + true // Force issue + ); + + if ($result['success'] ?? false) { + SslCertificate::updateOrCreate( + ['domain_id' => $domain->id], + [ + 'type' => 'lets_encrypt', + 'status' => 'active', + 'issuer' => "Let's Encrypt", + 'certificate' => $result['certificate'] ?? null, + 'issued_at' => now(), + 'expires_at' => isset($result['valid_to']) ? Carbon::parse($result['valid_to']) : now()->addMonths(3), + 'last_check_at' => now(), + 'last_error' => null, + 'renewal_attempts' => 0, + 'auto_renew' => true, + ] + ); + + $domain->update(['ssl_enabled' => true]); + + Log::info("IssueSslCertificate: SSL issued successfully for {$domain->domain}"); + } else { + $error = $result['error'] ?? 'Unknown error'; + + // Record the failure but don't throw - let the scheduled SSL check retry later + SslCertificate::updateOrCreate( + ['domain_id' => $domain->id], + [ + 'type' => 'lets_encrypt', + 'status' => 'pending', + 'last_check_at' => now(), + 'last_error' => $error, + 'auto_renew' => true, + ] + ); + + Log::warning("IssueSslCertificate: Failed to issue SSL for {$domain->domain}: {$error}"); + } + } catch (\Exception $e) { + Log::error("IssueSslCertificate: Exception for {$domain->domain}: " . $e->getMessage()); + + // Record pending state so scheduled check can retry + SslCertificate::updateOrCreate( + ['domain_id' => $domain->id], + [ + 'type' => 'lets_encrypt', + 'status' => 'pending', + 'last_check_at' => now(), + 'last_error' => $e->getMessage(), + 'auto_renew' => true, + ] + ); + + // Re-throw to trigger job retry + throw $e; + } + } +} diff --git a/app/Jobs/RunCpanelRestore.php b/app/Jobs/RunCpanelRestore.php new file mode 100644 index 0000000..c271910 --- /dev/null +++ b/app/Jobs/RunCpanelRestore.php @@ -0,0 +1,127 @@ +|null $discoveredData + */ + public function __construct( + public string $jobId, + public string $logPath, + public string $backupPath, + public string $username, + public bool $restoreFiles, + public bool $restoreDatabases, + public bool $restoreEmails, + public bool $restoreSsl, + public ?array $discoveredData = null, + ) {} + + public function handle(AgentClient $agent, MigrationDnsSyncService $dnsSyncService): void + { + $this->ensureLogPath(); + $this->appendLog(__('Restore started for user: :user', ['user' => $this->username]), 'pending'); + Cache::put($this->getCacheKey(), ['status' => 'running'], now()->addHours(2)); + + try { + $result = $agent->send('cpanel.restore_backup', [ + 'backup_path' => $this->backupPath, + 'username' => $this->username, + 'restore_files' => $this->restoreFiles, + 'restore_databases' => $this->restoreDatabases, + 'restore_emails' => $this->restoreEmails, + 'restore_ssl' => $this->restoreSsl, + 'discovered_data' => $this->discoveredData, + 'log_path' => $this->logPath, + ]); + + if ($result['success'] ?? false) { + $this->appendLog(__('Migration completed successfully.'), 'success'); + Cache::put($this->getCacheKey(), ['status' => 'completed'], now()->addHours(2)); + $this->syncDnsZones($dnsSyncService); + + return; + } + + $error = $result['error'] ?? __('Migration failed'); + $this->appendLog(__('Migration failed: :error', ['error' => $error]), 'error'); + Cache::put($this->getCacheKey(), ['status' => 'failed'], now()->addHours(2)); + } catch (Exception $e) { + $this->appendLog(__('Migration failed: :error', ['error' => $e->getMessage()]), 'error'); + Cache::put($this->getCacheKey(), ['status' => 'failed'], now()->addHours(2)); + Log::error('RunCpanelRestore failed', [ + 'job_id' => $this->jobId, + 'user' => $this->username, + 'error' => $e->getMessage(), + ]); + } + } + + protected function ensureLogPath(): void + { + File::ensureDirectoryExists(dirname($this->logPath)); + if (! File::exists($this->logPath)) { + File::put($this->logPath, ''); + @chmod($this->logPath, 0644); + } + } + + protected function appendLog(string $message, string $status): void + { + $entry = [ + 'message' => $message, + 'status' => $status, + 'time' => now()->format('H:i:s'), + ]; + + file_put_contents($this->logPath, json_encode($entry).PHP_EOL, FILE_APPEND | LOCK_EX); + @chmod($this->logPath, 0644); + } + + protected function getCacheKey(): string + { + return 'cpanel_restore_status_'.$this->jobId; + } + + protected function syncDnsZones(MigrationDnsSyncService $dnsSyncService): void + { + $user = User::where('username', $this->username)->first(); + if (! $user) { + Log::warning('Unable to sync DNS zones after cPanel restore: user not found', [ + 'username' => $this->username, + ]); + + return; + } + + try { + $domains = $this->discoveredData['domains'] ?? null; + $dnsSyncService->syncDomainsForUser($user, $domains); + } catch (Exception $e) { + Log::warning('Failed to sync DNS zones after cPanel restore', [ + 'user' => $this->username, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Jobs/RunServerBackup.php b/app/Jobs/RunServerBackup.php new file mode 100644 index 0000000..75f8e92 --- /dev/null +++ b/app/Jobs/RunServerBackup.php @@ -0,0 +1,274 @@ +backupId); + + if (!$backup) { + Log::warning("RunServerBackup: Backup {$this->backupId} not found"); + return; + } + + // Skip if already completed or failed + if (in_array($backup->status, ['completed', 'failed'])) { + Log::info("RunServerBackup: Backup {$this->backupId} already {$backup->status}"); + return; + } + + $backup->update(['status' => 'running', 'started_at' => now()]); + + $backupType = $backup->metadata['backup_type'] ?? 'full'; + $isIncrementalRemote = $backupType === 'incremental' && $backup->destination_id; + + try { + $agent = new AgentClient(); + + if ($isIncrementalRemote) { + $destination = $backup->destination; + if (!$destination) { + throw new Exception('Backup destination not found'); + } + + $config = array_merge($destination->config ?? [], ['type' => $destination->type]); + + $result = $agent->send('backup.incremental_direct', [ + 'destination' => $config, + 'users' => $backup->users, + 'include_files' => $backup->include_files, + 'include_databases' => $backup->include_databases, + 'include_mailboxes' => $backup->include_mailboxes, + 'include_dns' => $backup->include_dns, + ]); + + if ($result['success'] ?? false) { + $backup->update([ + 'status' => 'completed', + 'completed_at' => now(), + 'size_bytes' => $result['size'] ?? 0, + 'users' => $result['users'] ?? $backup->users, + 'remote_path' => $result['remote_path'] ?? null, + 'metadata' => array_merge($backup->metadata ?? [], [ + 'user_count' => $result['user_count'] ?? 0, + 'previous_backup' => $result['previous_backup'] ?? null, + 'is_initialization' => $result['is_initialization'] ?? false, + ]), + ]); + + Log::info("RunServerBackup: Incremental backup {$this->backupId} completed"); + + // Re-index remote backups for user discovery + IndexRemoteBackups::dispatch($backup->destination_id); + + // Apply retention policy if this backup is from a schedule + $this->applyRetention($backup); + + // Send success notification + AdminNotificationService::backupSuccess( + $backup->name, + $result['size'] ?? 0, + $backup->destination?->name + ); + } else { + throw new Exception($result['error'] ?? 'Incremental backup failed'); + } + } else { + // Full backup + $outputPath = $backup->local_path; + + $result = $agent->send('backup.create_server', [ + 'output_path' => $outputPath, + 'backup_type' => $backupType, + 'users' => $backup->users, + 'include_files' => $backup->include_files, + 'include_databases' => $backup->include_databases, + 'include_mailboxes' => $backup->include_mailboxes, + 'include_dns' => $backup->include_dns, + ]); + + if ($result['success'] ?? false) { + $backup->update([ + 'status' => 'completed', + 'completed_at' => now(), + 'size_bytes' => $result['size'] ?? 0, + 'users' => $result['users'] ?? $backup->users, + 'metadata' => array_merge($backup->metadata ?? [], [ + 'user_count' => $result['user_count'] ?? 0, + ]), + ]); + + // Upload to remote if destination configured + if ($backup->destination_id) { + $this->uploadToRemote($backup, $agent); + } + + Log::info("RunServerBackup: Full backup {$this->backupId} completed"); + + // Apply retention policy if this backup is from a schedule + $this->applyRetention($backup); + + // Send success notification + AdminNotificationService::backupSuccess( + $backup->name, + $result['size'] ?? 0, + $backup->destination?->name + ); + } else { + throw new Exception($result['error'] ?? 'Backup failed'); + } + } + } catch (Exception $e) { + $backup->update([ + 'status' => 'failed', + 'completed_at' => now(), + 'error_message' => $e->getMessage(), + ]); + + Log::error("RunServerBackup: Backup {$this->backupId} failed: " . $e->getMessage()); + + // Send failure notification + AdminNotificationService::backupFailure($backup->name, $e->getMessage()); + } + } + + protected function uploadToRemote(Backup $backup, AgentClient $agent): void + { + if (!$backup->destination || !$backup->local_path) { + return; + } + + try { + $backup->update(['status' => 'uploading']); + + $config = array_merge( + $backup->destination->config ?? [], + ['type' => $backup->destination->type] + ); + $backupType = $backup->metadata['backup_type'] ?? 'full'; + + $result = $agent->send('backup.upload_remote', [ + 'local_path' => $backup->local_path, + 'destination' => $config, + 'backup_type' => $backupType, + ]); + + if ($result['success'] ?? false) { + $backup->update([ + 'status' => 'completed', + 'remote_path' => $result['remote_path'] ?? null, + ]); + + Log::info("RunServerBackup: Uploaded backup {$this->backupId} to remote"); + + // Re-index remote backups for user discovery + IndexRemoteBackups::dispatch($backup->destination_id); + } else { + // Keep as completed since local exists, just log warning + $backup->update([ + 'status' => 'completed', + 'error_message' => 'Remote upload failed: ' . ($result['error'] ?? 'Unknown error'), + ]); + + Log::warning("RunServerBackup: Remote upload failed for {$this->backupId}"); + } + } catch (Exception $e) { + $backup->update([ + 'status' => 'completed', + 'error_message' => 'Remote upload failed: ' . $e->getMessage(), + ]); + + Log::warning("RunServerBackup: Remote upload exception for {$this->backupId}: " . $e->getMessage()); + } + } + + protected function applyRetention(Backup $backup): void + { + Log::info("RunServerBackup: applyRetention called for backup {$backup->id}, schedule_id: " . ($backup->schedule_id ?? 'NULL')); + + // Only apply retention if backup has a schedule + if (!$backup->schedule_id) { + Log::info("RunServerBackup: No schedule_id, skipping retention"); + return; + } + + $schedule = BackupSchedule::find($backup->schedule_id); + if (!$schedule) { + Log::info("RunServerBackup: Schedule not found for id {$backup->schedule_id}"); + return; + } + + $retentionCount = $schedule->retention_count ?? 7; + Log::info("RunServerBackup: Retention count is {$retentionCount}"); + + // Get backups from this schedule, ordered by date + $backups = Backup::where('schedule_id', $schedule->id) + ->where('status', 'completed') + ->orderByDesc('created_at') + ->get(); + + if ($backups->count() <= $retentionCount) { + return; + } + + // Get backups to delete (keep newest $retentionCount) + $toDelete = $backups->slice($retentionCount); + $agent = new AgentClient(); + + foreach ($toDelete as $oldBackup) { + Log::info("RunServerBackup: Deleting old backup per retention: {$oldBackup->name}"); + + // Delete local file/folder + if ($oldBackup->local_path && file_exists($oldBackup->local_path)) { + if (is_file($oldBackup->local_path)) { + unlink($oldBackup->local_path); + } else { + exec("rm -rf " . escapeshellarg($oldBackup->local_path)); + } + } + + // Delete from remote if exists + if ($oldBackup->remote_path && $oldBackup->destination) { + try { + $config = array_merge( + $oldBackup->destination->config ?? [], + ['type' => $oldBackup->destination->type] + ); + $agent->send('backup.delete_remote', [ + 'remote_path' => $oldBackup->remote_path, + 'destination' => $config, + ]); + } catch (Exception $e) { + Log::warning("RunServerBackup: Failed to delete remote backup: " . $e->getMessage()); + } + } + + $oldBackup->delete(); + } + + Log::info("RunServerBackup: Deleted " . $toDelete->count() . " old backup(s) per retention policy"); + } +} diff --git a/app/Jobs/RunWhmMigrationBatch.php b/app/Jobs/RunWhmMigrationBatch.php new file mode 100644 index 0000000..f6770a9 --- /dev/null +++ b/app/Jobs/RunWhmMigrationBatch.php @@ -0,0 +1,410 @@ +> $accounts + * @param array $selectedAccounts + */ + public function __construct( + public string $cacheKey, + public string $hostname, + public string $whmUsername, + public string $apiToken, + public int $port, + public bool $useSSL, + public array $accounts, + public array $selectedAccounts, + public bool $restoreFiles, + public bool $restoreDatabases, + public bool $restoreEmails, + public bool $restoreSsl, + public bool $createLinuxUsers, + ) {} + + public function handle(AgentClient $agent, MigrationDnsSyncService $dnsSyncService): void + { + $store = new WhmMigrationStatusStore($this->cacheKey); + $store->initialize($this->selectedAccounts); + $reloadDelaySeconds = 15; + + try { + $whm = new WhmApiService( + $this->hostname, + $this->whmUsername, + $this->apiToken, + $this->port, + $this->useSSL, + ); + + $accountsByUser = $this->indexAccountsByUser($this->accounts); + + foreach ($this->selectedAccounts as $cpanelUser) { + $this->migrateAccount($store, $whm, $agent, $dnsSyncService, $cpanelUser, $accountsByUser[$cpanelUser] ?? []); + } + + $store->setMigrating(false); + + try { + $agent->send('service.reload', ['service' => 'nginx']); + } catch (Exception $e) { + Log::warning('Failed to reload nginx after WHM migration', ['error' => $e->getMessage()]); + } + + if ($this->shouldReloadFpm) { + try { + $agent->send('php.reload_all_fpm', [ + 'background' => true, + 'delay' => $reloadDelaySeconds, + ]); + } catch (Exception $e) { + Log::warning('Failed to reload PHP-FPM after WHM migration', ['error' => $e->getMessage()]); + } + } + } catch (Exception $e) { + Log::error('WHM migration batch failed', ['error' => $e->getMessage()]); + } finally { + $state = $store->get(); + if (($state['isMigrating'] ?? false) === true) { + $store->setMigrating(false); + } + } + } + + /** + * @param array $account + */ + protected function migrateAccount(WhmMigrationStatusStore $store, WhmApiService $whm, AgentClient $agent, MigrationDnsSyncService $dnsSyncService, string $cpanelUser, array $account): void + { + $store->updateAccountStatus($cpanelUser, 'processing', __('Starting migration...')); + + try { + $domain = $account['domain'] ?? ''; + $email = $account['email'] ?? ($domain !== '' ? "{$cpanelUser}@{$domain}" : "{$cpanelUser}@example.com"); + + $user = $this->createOrGetUser($agent, $cpanelUser, $email); + if (! $user) { + throw new Exception(__('Failed to create user')); + } + + $store->addAccountLog($cpanelUser, __('User ready: :username', ['username' => $user->username]), 'success'); + + $store->updateAccountStatus($cpanelUser, 'backup_creating', __('Setting up backup transfer...')); + + $keyName = $this->getSshKeyName(); + $destPath = $this->getBackupDestPath(); + + if (! is_dir($destPath)) { + mkdir($destPath, 0755, true); + } + + $agent->send('jabali_ssh.ensure_exists', []); + + $publicKeyResult = $agent->send('jabali_ssh.get_public_key', []); + if (! ($publicKeyResult['success'] ?? false) || ! ($publicKeyResult['exists'] ?? false)) { + throw new Exception(__('Failed to get Jabali public key')); + } + $publicKey = $publicKeyResult['public_key'] ?? null; + + $agent->send('jabali_ssh.add_to_authorized_keys', [ + 'public_key' => $publicKey, + 'comment' => 'whm-migration-'.$cpanelUser, + ]); + + $privateKeyResult = $agent->send('jabali_ssh.get_private_key', []); + if (! ($privateKeyResult['success'] ?? false) || ! ($privateKeyResult['exists'] ?? false)) { + throw new Exception(__('Failed to read Jabali private key')); + } + + $privateKey = $privateKeyResult['private_key'] ?? null; + if (empty($privateKey)) { + throw new Exception(__('Private key is empty')); + } + + $store->addAccountLog($cpanelUser, __('Importing SSH key to cPanel...'), 'pending'); + + $importResult = $whm->importSshPrivateKey($cpanelUser, $keyName, $privateKey); + if (! ($importResult['success'] ?? false)) { + throw new Exception($importResult['message'] ?? __('Failed to import SSH key')); + } + + $actualKeyName = $importResult['actual_key_name'] ?? $keyName; + $store->addAccountLog($cpanelUser, __('SSH key imported'), 'success'); + + $authResult = $whm->authorizeSshKey($cpanelUser, $actualKeyName); + if (! ($authResult['success'] ?? false)) { + $store->addAccountLog($cpanelUser, __('SSH key authorization skipped'), 'info'); + } else { + $store->addAccountLog($cpanelUser, __('SSH key authorized'), 'success'); + } + + $store->addAccountLog($cpanelUser, __('Initiating backup transfer...'), 'pending'); + + $jabaliIp = $this->getJabaliPublicIp(); + + $backupResult = $whm->createBackupToScpWithKey( + $cpanelUser, + $jabaliIp, + 'root', + $destPath, + $actualKeyName, + 22 + ); + + if (! ($backupResult['success'] ?? false)) { + throw new Exception($backupResult['message'] ?? __('Failed to start backup')); + } + + $store->addAccountLog($cpanelUser, __('Backup initiated, transferring via SCP...'), 'success'); + + $store->updateAccountStatus($cpanelUser, 'backup_downloading', __('Waiting for backup file...')); + + $backupPath = $this->waitForBackupFile($agent, $store, $cpanelUser, $destPath); + if (! $backupPath) { + throw new Exception(__('Backup file did not arrive')); + } + + $store->addAccountLog($cpanelUser, __('Backup received: :size', ['size' => $this->formatBytes(filesize($backupPath))]), 'success'); + + $summary = $whm->getUserMigrationSummary($cpanelUser); + $discoveredData = $whm->convertApiDataToAgentFormat($summary); + + $store->updateAccountStatus($cpanelUser, 'restoring', __('Restoring data...')); + + $result = $agent->send('cpanel.restore_backup', [ + 'backup_path' => $backupPath, + 'username' => $user->username, + 'restore_files' => $this->restoreFiles, + 'restore_databases' => $this->restoreDatabases, + 'restore_emails' => $this->restoreEmails, + 'restore_ssl' => $this->restoreSsl, + 'discovered_data' => $discoveredData, + ]); + + if ($result['success'] ?? false) { + foreach ($result['log'] ?? [] as $entry) { + $store->addAccountLog($cpanelUser, $entry['message'], $entry['status'] ?? 'info'); + } + + $this->syncDnsZones($dnsSyncService, $user, $discoveredData); + + $store->updateAccountStatus($cpanelUser, 'completed', __('Migration completed')); + @unlink($backupPath); + } else { + throw new Exception($result['error'] ?? __('Restore failed')); + } + } catch (Exception $e) { + Log::error('WHM migration failed for user', ['user' => $cpanelUser, 'error' => $e->getMessage()]); + $store->updateAccountStatus($cpanelUser, 'error', $e->getMessage(), 'error'); + } + } + + protected function createOrGetUser(AgentClient $agent, string $cpanelUser, string $email): ?User + { + $existingUser = User::where('username', $cpanelUser)->first(); + if ($existingUser) { + return $existingUser; + } + + if (User::where('email', $email)->exists()) { + $email = "{$cpanelUser}.".time().'@'.explode('@', $email)[1]; + } + + $password = bin2hex(random_bytes(12)); + + try { + if ($this->createLinuxUsers) { + exec('id '.escapeshellarg($cpanelUser).' 2>/dev/null', $output, $exitCode); + + if ($exitCode !== 0) { + $result = $agent->send('user.create', [ + 'username' => $cpanelUser, + 'password' => $password, + ]); + + if (! ($result['success'] ?? false)) { + throw new Exception($result['error'] ?? __('Failed to create system user')); + } + + if (($result['fpm_pool_created'] ?? false) === true) { + $this->shouldReloadFpm = true; + } + } + } + + return User::create([ + 'name' => ucfirst($cpanelUser), + 'username' => $cpanelUser, + 'email' => $email, + 'password' => Hash::make($password), + 'home_directory' => '/home/'.$cpanelUser, + 'disk_quota_mb' => null, + 'is_active' => true, + 'is_admin' => false, + ]); + } catch (Exception $e) { + Log::error('Failed to create user', ['username' => $cpanelUser, 'error' => $e->getMessage()]); + + return null; + } + } + + protected function waitForBackupFile(AgentClient $agent, WhmMigrationStatusStore $store, string $cpanelUser, string $destPath): ?string + { + $maxAttempts = 120; + $attempt = 0; + $lastSeenSize = 0; + $sizeStableCount = 0; + + while ($attempt < $maxAttempts) { + $attempt++; + sleep(5); + + $pattern = "{$destPath}/backup-*_{$cpanelUser}.tar.gz"; + $files = glob($pattern); + + if (empty($files)) { + $pattern = "{$destPath}/cpmove-{$cpanelUser}.tar.gz"; + $files = glob($pattern); + } + + if (empty($files)) { + if ($attempt % 6 === 0) { + $store->addAccountLog($cpanelUser, __('Waiting for backup file... (:count s)', ['count' => $attempt * 5]), 'pending'); + } + + continue; + } + + usort($files, fn ($a, $b) => filemtime($b) - filemtime($a)); + $backupFile = $files[0]; + $currentSize = filesize($backupFile); + + if ($currentSize > 0 && $currentSize === $lastSeenSize) { + $sizeStableCount++; + } else { + $sizeStableCount = 0; + } + $lastSeenSize = $currentSize; + + if ($sizeStableCount >= 3 && $currentSize >= 10 * 1024) { + $agent->send('file.chown', [ + 'path' => $backupFile, + 'owner' => 'www-data', + 'group' => 'www-data', + ]); + + $handle = fopen($backupFile, 'rb'); + $magic = $handle ? fread($handle, 2) : ''; + if ($handle) { + fclose($handle); + } + + if ($magic === "\x1f\x8b") { + return $backupFile; + } + + $store->addAccountLog($cpanelUser, __('Invalid backup file format, waiting...'), 'warning'); + $sizeStableCount = 0; + } + + if ($attempt % 6 === 0) { + $store->addAccountLog($cpanelUser, __('Receiving backup... :size', [ + 'size' => $this->formatBytes($currentSize), + ]), 'pending'); + } + } + + return null; + } + + /** + * @param array> $accounts + * @return array> + */ + protected function indexAccountsByUser(array $accounts): array + { + $indexed = []; + + foreach ($accounts as $account) { + if (! isset($account['user'])) { + continue; + } + + $indexed[$account['user']] = $account; + } + + return $indexed; + } + + protected function getBackupDestPath(): string + { + return '/var/backups/jabali/whm-migrations'; + } + + protected function getSshKeyName(): string + { + return 'jabali-system-key'; + } + + /** + * @param array $discoveredData + */ + protected function syncDnsZones(MigrationDnsSyncService $dnsSyncService, User $user, array $discoveredData): void + { + try { + $domains = $discoveredData['domains'] ?? []; + $dnsSyncService->syncDomainsForUser($user, $domains); + } catch (Exception $e) { + Log::warning('Failed to sync DNS zones after WHM migration', [ + 'user' => $user->username, + 'error' => $e->getMessage(), + ]); + } + } + + protected function getJabaliPublicIp(): string + { + $ip = trim(shell_exec('curl -s ifconfig.me 2>/dev/null') ?? ''); + + if (empty($ip)) { + $ip = gethostbyname(gethostname()); + } + + return $ip; + } + + protected 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]; + } +} diff --git a/app/Listeners/AuthEventListener.php b/app/Listeners/AuthEventListener.php new file mode 100644 index 0000000..ef42bdd --- /dev/null +++ b/app/Listeners/AuthEventListener.php @@ -0,0 +1,74 @@ +user); + } + + /** + * Handle user logout events. + */ + public function handleLogout(Logout $event): void + { + if ($event->user) { + AuditLog::logAuth('logout', $event->user); + } + } + + /** + * Handle failed login attempts. + */ + public function handleFailed(Failed $event): void + { + AuditLog::create([ + 'user_id' => $event->user?->id, + 'action' => 'login_failed', + 'category' => 'auth', + 'description' => 'Failed login attempt for: ' . ($event->credentials['email'] ?? 'unknown'), + 'target_type' => 'user', + 'target_id' => $event->user?->id, + 'target_name' => $event->credentials['email'] ?? 'unknown', + 'ip_address' => request()->ip(), + 'user_agent' => request()->userAgent(), + 'metadata' => [ + 'guard' => $event->guard, + ], + ]); + } + + /** + * Handle password reset events. + */ + public function handlePasswordReset(PasswordReset $event): void + { + AuditLog::logAuth('password_reset', $event->user); + } + + /** + * Register the listeners for the subscriber. + */ + public function subscribe($events): array + { + return [ + Login::class => 'handleLogin', + Logout::class => 'handleLogout', + Failed::class => 'handleFailed', + PasswordReset::class => 'handlePasswordReset', + ]; + } +} diff --git a/app/Livewire/DatabaseUsersTable.php b/app/Livewire/DatabaseUsersTable.php new file mode 100644 index 0000000..1bfe5e9 --- /dev/null +++ b/app/Livewire/DatabaseUsersTable.php @@ -0,0 +1,316 @@ + 'refreshData']; + + public function mount(): void + { + $this->loadData(); + } + + public function refreshData(): void + { + $this->loadData(); + $this->resetTable(); + } + + public function getAgent(): AgentClient + { + if ($this->agent === null) { + $this->agent = new AgentClient(); + } + return $this->agent; + } + + public function getUsername(): string + { + return Auth::user()->username; + } + + public function loadData(): void + { + try { + $result = $this->getAgent()->mysqlListUsers($this->getUsername()); + $this->users = $result['users'] ?? []; + + // Filter out the master admin user + $this->users = array_values(array_filter($this->users, function($user) { + return $user['user'] !== $this->getUsername() . '_admin'; + })); + + $this->userGrants = []; + foreach ($this->users as $user) { + $this->loadUserGrants($user['user'], $user['host']); + } + } catch (Exception $e) { + $this->users = []; + } + + try { + $result = $this->getAgent()->mysqlListDatabases($this->getUsername()); + $this->databases = $result['databases'] ?? []; + } catch (Exception $e) { + $this->databases = []; + } + } + + protected function loadUserGrants(string $user, string $host): void + { + try { + $result = $this->getAgent()->mysqlGetPrivileges($this->getUsername(), $user, $host); + $this->userGrants["$user@$host"] = $result['parsed'] ?? []; + } catch (Exception $e) { + $this->userGrants["$user@$host"] = []; + } + } + + public function getUserGrants(string $user, string $host): array + { + return $this->userGrants["$user@$host"] ?? []; + } + + public function table(Table $table): Table + { + return $table + ->records(fn () => $this->users) + ->columns([ + TextColumn::make('user') + ->label(__('User')) + ->icon('heroicon-o-user') + ->iconColor('primary') + ->description(fn (array $record): string => '@ ' . $record['host']) + ->weight('medium') + ->searchable(), + ViewColumn::make('privileges') + ->label(__('Database Privileges')) + ->view('filament.jabali.tables.columns.user-privileges'), + ]) + ->recordActions([ + Action::make('addPrivileges') + ->label(__('Add Access')) + ->icon('heroicon-o-plus') + ->color('success') + ->modalHeading(__('Add Database Access')) + ->modalDescription(fn (array $record): string => __('Grant privileges to :user', ['user' => $record['user'] . '@' . $record['host']])) + ->modalIcon('heroicon-o-shield-check') + ->modalIconColor('success') + ->modalWidth('lg') + ->modalSubmitActionLabel(__('Grant Access')) + ->form(fn (array $record): array => $this->getPrivilegesForm()) + ->action(function (array $data, array $record): void { + $this->grantPrivileges($record['user'], $record['host'], $data); + }), + Action::make('changePassword') + ->label(__('Password')) + ->icon('heroicon-o-key') + ->color('warning') + ->modalHeading(__('Change Password')) + ->modalDescription(fn (array $record): string => $record['user'] . '@' . $record['host']) + ->modalIcon('heroicon-o-key') + ->modalIconColor('warning') + ->modalSubmitActionLabel(__('Change Password')) + ->form([ + TextInput::make('password') + ->label(__('New Password')) + ->password() + ->revealable() + ->required() + ->minLength(8) + ->default(fn () => $this->generateSecurePassword()) + ->suffixActions([ + Action::make('generate') + ->icon('heroicon-o-arrow-path') + ->tooltip(__('Generate secure password')) + ->action(fn ($set) => $set('password', $this->generateSecurePassword())), + Action::make('copy') + ->icon('heroicon-o-clipboard-document') + ->tooltip(__('Copy to clipboard')) + ->action(function ($state, $livewire) { + if ($state) { + $escaped = addslashes($state); + $livewire->js("navigator.clipboard.writeText('{$escaped}')"); + Notification::make() + ->title(__('Copied to clipboard')) + ->success() + ->duration(2000) + ->send(); + } + }), + ]), + ]) + ->action(function (array $data, array $record): void { + $this->changePassword($record['user'], $record['host'], $data['password']); + }), + Action::make('delete') + ->label(__('Delete')) + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->modalHeading(__('Delete User')) + ->modalDescription(fn (array $record): string => __("Delete user ':user'?", ['user' => $record['user'] . '@' . $record['host']])) + ->modalIcon('heroicon-o-trash') + ->modalIconColor('danger') + ->action(function (array $record): void { + $this->deleteUser($record['user'], $record['host']); + }), + ]) + ->emptyStateHeading(__('No database users yet')) + ->emptyStateDescription(__('Click "New User" to create one')) + ->emptyStateIcon('heroicon-o-users') + ->striped(); + } + + public function getTableRecordKey(Model|array $record): string + { + return is_array($record) ? $record['user'] . '@' . $record['host'] : $record->getKey(); + } + + protected function getPrivilegesForm(): array + { + $dbOptions = []; + foreach ($this->databases as $db) { + $dbOptions[$db['name']] = $db['name']; + } + + return [ + Select::make('database') + ->label(__('Database')) + ->options($dbOptions) + ->required() + ->searchable() + ->live(), + Radio::make('privilege_type') + ->label(__('Privilege Type')) + ->options([ + 'all' => __('ALL PRIVILEGES'), + 'specific' => __('Specific privileges'), + ]) + ->default('all') + ->required() + ->live(), + CheckboxList::make('specific_privileges') + ->label(__('Select Privileges')) + ->options([ + 'SELECT' => 'SELECT', + 'INSERT' => 'INSERT', + 'UPDATE' => 'UPDATE', + 'DELETE' => 'DELETE', + 'CREATE' => 'CREATE', + 'DROP' => 'DROP', + 'INDEX' => 'INDEX', + 'ALTER' => 'ALTER', + ]) + ->columns(2) + ->visible(fn (callable $get): bool => $get('privilege_type') === 'specific'), + ]; + } + + public function grantPrivileges(string $user, string $host, array $data): void + { + $privs = $data['privilege_type'] === 'all' ? ['ALL'] : ($data['specific_privileges'] ?? []); + + try { + $this->getAgent()->mysqlGrantPrivileges($this->getUsername(), $user, $data['database'], $privs, $host); + Notification::make()->title(__('Privileges granted'))->success()->send(); + $this->loadData(); + $this->resetTable(); + } catch (Exception $e) { + Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); + } + } + + public function revokePrivileges(string $user, string $host, string $database): void + { + try { + $this->getAgent()->mysqlRevokePrivileges($this->getUsername(), $user, $database, $host); + Notification::make()->title(__('Access revoked'))->success()->send(); + $this->loadData(); + $this->resetTable(); + } catch (Exception $e) { + Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); + } + } + + public function changePassword(string $user, string $host, string $password): void + { + try { + $this->getAgent()->mysqlChangePassword($this->getUsername(), $user, $password, $host); + + MysqlCredential::updateOrCreate( + ['user_id' => Auth::id(), 'mysql_username' => $user], + ['mysql_password_encrypted' => Crypt::encryptString($password)] + ); + + Notification::make()->title(__('Password changed'))->success()->send(); + } catch (Exception $e) { + Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); + } + } + + public function deleteUser(string $user, string $host): void + { + try { + $this->getAgent()->mysqlDeleteUser($this->getUsername(), $user, $host); + MysqlCredential::where('user_id', Auth::id())->where('mysql_username', $user)->delete(); + Notification::make()->title(__('User deleted'))->success()->send(); + $this->loadData(); + $this->resetTable(); + } catch (Exception $e) { + Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); + } + } + + public function generateSecurePassword(int $length = 16): string + { + $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*'; + $password = ''; + for ($i = 0; $i < $length; $i++) { + $password .= $chars[random_int(0, strlen($chars) - 1)]; + } + return $password; + } + + public function render() + { + return view('livewire.database-users-table'); + } +} diff --git a/app/Models/AuditLog.php b/app/Models/AuditLog.php new file mode 100644 index 0000000..84459d4 --- /dev/null +++ b/app/Models/AuditLog.php @@ -0,0 +1,193 @@ + 'array', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Log a privileged action. + */ + public static function log( + string $action, + string $category, + string $description, + ?string $targetType = null, + ?int $targetId = null, + ?string $targetName = null, + ?array $metadata = null + ): self { + return self::create([ + 'user_id' => Auth::id(), + 'action' => $action, + 'category' => $category, + 'description' => $description, + 'target_type' => $targetType, + 'target_id' => $targetId, + 'target_name' => $targetName, + 'ip_address' => request()->ip(), + 'user_agent' => request()->userAgent(), + 'metadata' => $metadata, + ]); + } + + /** + * Log user management actions. + */ + public static function logUserAction(string $action, User $targetUser, ?array $metadata = null): self + { + return self::log( + action: $action, + category: 'user', + description: "{$action} user: {$targetUser->username}", + targetType: 'user', + targetId: $targetUser->id, + targetName: $targetUser->username, + metadata: $metadata + ); + } + + /** + * Log domain management actions. + */ + public static function logDomainAction(string $action, string $domain, ?int $domainId = null, ?array $metadata = null): self + { + return self::log( + action: $action, + category: 'domain', + description: "{$action} domain: {$domain}", + targetType: 'domain', + targetId: $domainId, + targetName: $domain, + metadata: $metadata + ); + } + + /** + * Log database management actions. + */ + public static function logDatabaseAction(string $action, string $database, ?array $metadata = null): self + { + return self::log( + action: $action, + category: 'database', + description: "{$action} database: {$database}", + targetType: 'database', + targetId: null, + targetName: $database, + metadata: $metadata + ); + } + + /** + * Log service management actions. + */ + public static function logServiceAction(string $action, string $service, ?array $metadata = null): self + { + return self::log( + action: $action, + category: 'service', + description: "{$action} service: {$service}", + targetType: 'service', + targetId: null, + targetName: $service, + metadata: $metadata + ); + } + + /** + * Log email/mailbox management actions. + */ + public static function logEmailAction(string $action, string $email, ?array $metadata = null): self + { + return self::log( + action: $action, + category: 'email', + description: "{$action} mailbox: {$email}", + targetType: 'mailbox', + targetId: null, + targetName: $email, + metadata: $metadata + ); + } + + /** + * Log firewall actions. + */ + public static function logFirewallAction(string $action, ?string $rule = null, ?array $metadata = null): self + { + return self::log( + action: $action, + category: 'firewall', + description: $rule ? "{$action} firewall rule: {$rule}" : "{$action} firewall", + targetType: 'firewall', + targetId: null, + targetName: $rule, + metadata: $metadata + ); + } + + /** + * Log authentication events. + */ + public static function logAuth(string $action, ?User $user = null, ?array $metadata = null): self + { + $username = $user?->username ?? Auth::user()?->username ?? 'unknown'; + + return self::create([ + 'user_id' => $user?->id ?? Auth::id(), + 'action' => $action, + 'category' => 'auth', + 'description' => "{$action}: {$username}", + 'target_type' => 'user', + 'target_id' => $user?->id ?? Auth::id(), + 'target_name' => $username, + 'ip_address' => request()->ip(), + 'user_agent' => request()->userAgent(), + 'metadata' => $metadata, + ]); + } + + /** + * Prune audit logs older than the specified number of days. + * + * @param int|null $days Number of days to keep (default: from settings or 90) + * @return int Number of deleted records + */ + public static function prune(?int $days = null): int + { + $days = $days ?? (int) DnsSetting::get('audit_log_retention_days', 90); + + return self::where('created_at', '<', now()->subDays($days))->delete(); + } +} diff --git a/app/Models/Autoresponder.php b/app/Models/Autoresponder.php new file mode 100644 index 0000000..b8db2dd --- /dev/null +++ b/app/Models/Autoresponder.php @@ -0,0 +1,66 @@ + 'date', + 'end_date' => 'date', + 'is_active' => 'boolean', + 'interval_hours' => 'integer', + ]; + } + + public function mailbox(): BelongsTo + { + return $this->belongsTo(Mailbox::class); + } + + /** + * Check if the autoresponder is currently active based on dates. + */ + public function isCurrentlyActive(): bool + { + if (!$this->is_active) { + return false; + } + + $now = now()->startOfDay(); + + if ($this->start_date && $now->lt($this->start_date)) { + return false; + } + + if ($this->end_date && $now->gt($this->end_date)) { + return false; + } + + return true; + } + + /** + * Get the email address for this autoresponder. + */ + public function getEmailAttribute(): string + { + return $this->mailbox?->email ?? ''; + } +} diff --git a/app/Models/Backup.php b/app/Models/Backup.php new file mode 100644 index 0000000..31bc031 --- /dev/null +++ b/app/Models/Backup.php @@ -0,0 +1,262 @@ + 'boolean', + 'include_databases' => 'boolean', + 'include_mailboxes' => 'boolean', + 'include_dns' => 'boolean', + 'domains' => 'array', + 'databases' => 'array', + 'mailboxes' => 'array', + 'users' => 'array', + 'size_bytes' => 'integer', + 'file_count' => 'integer', + 'metadata' => 'array', + 'started_at' => 'datetime', + 'completed_at' => 'datetime', + 'expires_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function destination(): BelongsTo + { + return $this->belongsTo(BackupDestination::class, 'destination_id'); + } + + public function schedule(): BelongsTo + { + return $this->belongsTo(BackupSchedule::class, 'schedule_id'); + } + + public function restores(): HasMany + { + return $this->hasMany(BackupRestore::class); + } + + /** + * Get human-readable file size. + */ + public function getSizeHumanAttribute(): string + { + $bytes = $this->size_bytes; + + if ($bytes >= 1073741824) { + return number_format($bytes / 1073741824, 2) . ' GB'; + } elseif ($bytes >= 1048576) { + return number_format($bytes / 1048576, 2) . ' MB'; + } elseif ($bytes >= 1024) { + return number_format($bytes / 1024, 2) . ' KB'; + } + + return $bytes . ' bytes'; + } + + /** + * Get backup duration in human-readable format. + */ + public function getDurationAttribute(): ?string + { + if (!$this->started_at || !$this->completed_at) { + return null; + } + + $seconds = $this->completed_at->diffInSeconds($this->started_at); + + if ($seconds >= 3600) { + return gmdate('H:i:s', $seconds); + } elseif ($seconds >= 60) { + return gmdate('i:s', $seconds) . ' min'; + } + + return $seconds . ' sec'; + } + + /** + * Check if backup has expired. + */ + public function getIsExpiredAttribute(): bool + { + return $this->expires_at && $this->expires_at->isPast(); + } + + /** + * Check if backup is a server-wide backup. + */ + public function isServerBackup(): bool + { + return $this->type === 'server'; + } + + /** + * Check if backup is stored locally. + */ + public function isLocal(): bool + { + return $this->destination_id === null || $this->destination?->isLocal(); + } + + /** + * Check if backup is stored remotely. + */ + public function isRemote(): bool + { + return $this->destination && $this->destination->isRemote(); + } + + /** + * Check if backup can be downloaded directly. + */ + public function canDownload(): bool + { + return $this->status === 'completed' && $this->local_path && file_exists($this->local_path); + } + + /** + * Get the download path for the backup. + */ + public function getDownloadPath(): ?string + { + if ($this->canDownload()) { + return $this->local_path; + } + + return null; + } + + /** + * Scope for completed backups. + */ + public function scopeCompleted($query) + { + return $query->where('status', 'completed'); + } + + /** + * Scope for failed backups. + */ + public function scopeFailed($query) + { + return $query->where('status', 'failed'); + } + + /** + * Scope for running backups. + */ + public function scopeRunning($query) + { + return $query->whereIn('status', ['pending', 'running', 'uploading']); + } + + /** + * Scope for user backups. + */ + public function scopeForUser($query, int $userId) + { + return $query->where('user_id', $userId); + } + + /** + * Scope for server backups. + */ + public function scopeServerBackups($query) + { + return $query->where('type', 'server'); + } + + /** + * Scope for non-expired backups. + */ + public function scopeNotExpired($query) + { + return $query->where(function ($q) { + $q->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }); + } + + /** + * Scope for expired backups. + */ + public function scopeExpired($query) + { + return $query->whereNotNull('expires_at') + ->where('expires_at', '<=', now()); + } + + /** + * Get status badge color for UI. + */ + public function getStatusColorAttribute(): string + { + return match ($this->status) { + 'completed' => 'success', + 'failed' => 'danger', + 'running', 'uploading' => 'warning', + 'pending' => 'gray', + default => 'gray', + }; + } + + /** + * Get status label for UI. + */ + public function getStatusLabelAttribute(): string + { + return match ($this->status) { + 'pending' => 'Pending', + 'running' => 'Creating...', + 'uploading' => 'Uploading...', + 'completed' => 'Completed', + 'failed' => 'Failed', + default => ucfirst($this->status), + }; + } +} diff --git a/app/Models/BackupDestination.php b/app/Models/BackupDestination.php new file mode 100644 index 0000000..c175e8e --- /dev/null +++ b/app/Models/BackupDestination.php @@ -0,0 +1,116 @@ + 'encrypted:array', + 'is_default' => 'boolean', + 'is_active' => 'boolean', + 'is_server_backup' => 'boolean', + 'last_tested_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function backups(): HasMany + { + return $this->hasMany(Backup::class, 'destination_id'); + } + + public function schedules(): HasMany + { + return $this->hasMany(BackupSchedule::class, 'destination_id'); + } + + /** + * Check if destination is local storage. + */ + public function isLocal(): bool + { + return $this->type === 'local'; + } + + /** + * Check if destination is remote storage. + */ + public function isRemote(): bool + { + return in_array($this->type, ['sftp', 'nfs', 's3']); + } + + /** + * Get the display label for the destination type. + */ + public function getTypeLabelAttribute(): string + { + return match ($this->type) { + 'local' => 'Local Storage', + 'sftp' => 'SFTP Server', + 'nfs' => 'NFS Mount', + 's3' => 'S3-Compatible Storage', + default => ucfirst($this->type), + }; + } + + /** + * Get config value by key. + */ + public function getConfigValue(string $key, mixed $default = null): mixed + { + return $this->config[$key] ?? $default; + } + + /** + * Scope for active destinations. + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * Scope for user destinations. + */ + public function scopeForUser($query, int $userId) + { + return $query->where('user_id', $userId); + } + + /** + * Scope for server backup destinations (admin-level). + */ + public function scopeServerBackups($query) + { + return $query->where('is_server_backup', true); + } +} diff --git a/app/Models/BackupRestore.php b/app/Models/BackupRestore.php new file mode 100644 index 0000000..308920b --- /dev/null +++ b/app/Models/BackupRestore.php @@ -0,0 +1,182 @@ + 'boolean', + 'restore_databases' => 'boolean', + 'restore_mailboxes' => 'boolean', + 'restore_dns' => 'boolean', + 'selected_domains' => 'array', + 'selected_databases' => 'array', + 'selected_mailboxes' => 'array', + 'progress' => 'integer', + 'result' => 'array', + 'started_at' => 'datetime', + 'completed_at' => 'datetime', + ]; + } + + public function backup(): BelongsTo + { + return $this->belongsTo(Backup::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Get duration in human-readable format. + */ + public function getDurationAttribute(): ?string + { + if (!$this->started_at || !$this->completed_at) { + return null; + } + + $seconds = $this->completed_at->diffInSeconds($this->started_at); + + if ($seconds >= 3600) { + return gmdate('H:i:s', $seconds); + } elseif ($seconds >= 60) { + return gmdate('i:s', $seconds) . ' min'; + } + + return $seconds . ' sec'; + } + + /** + * Append a log message. + */ + public function appendLog(string $message): void + { + $timestamp = now()->format('H:i:s'); + $this->log = ($this->log ?? '') . "[{$timestamp}] {$message}\n"; + $this->save(); + } + + /** + * Update progress. + */ + public function updateProgress(int $progress, ?string $message = null): void + { + $this->progress = min(100, max(0, $progress)); + + if ($message) { + $this->appendLog($message); + } else { + $this->save(); + } + } + + /** + * Mark as running. + */ + public function markAsRunning(): void + { + $this->status = 'running'; + $this->started_at = now(); + $this->save(); + } + + /** + * Mark as completed. + */ + public function markAsCompleted(array $result = []): void + { + $this->status = 'completed'; + $this->progress = 100; + $this->completed_at = now(); + $this->result = $result; + $this->save(); + } + + /** + * Mark as failed. + */ + public function markAsFailed(string $error): void + { + $this->status = 'failed'; + $this->completed_at = now(); + $this->error_message = $error; + $this->appendLog("ERROR: {$error}"); + } + + /** + * Scope for running restores. + */ + public function scopeRunning($query) + { + return $query->whereIn('status', ['pending', 'downloading', 'running']); + } + + /** + * Scope for user restores. + */ + public function scopeForUser($query, int $userId) + { + return $query->where('user_id', $userId); + } + + /** + * Get status color for UI. + */ + public function getStatusColorAttribute(): string + { + return match ($this->status) { + 'completed' => 'success', + 'failed' => 'danger', + 'running', 'downloading' => 'warning', + 'pending' => 'gray', + default => 'gray', + }; + } + + /** + * Get status label for UI. + */ + public function getStatusLabelAttribute(): string + { + return match ($this->status) { + 'pending' => 'Pending', + 'downloading' => 'Downloading...', + 'running' => 'Restoring...', + 'completed' => 'Completed', + 'failed' => 'Failed', + default => ucfirst($this->status), + }; + } +} diff --git a/app/Models/BackupSchedule.php b/app/Models/BackupSchedule.php new file mode 100644 index 0000000..9fad271 --- /dev/null +++ b/app/Models/BackupSchedule.php @@ -0,0 +1,215 @@ + 'boolean', + 'is_server_backup' => 'boolean', + 'include_files' => 'boolean', + 'include_databases' => 'boolean', + 'include_mailboxes' => 'boolean', + 'include_dns' => 'boolean', + 'domains' => 'array', + 'databases' => 'array', + 'mailboxes' => 'array', + 'users' => 'array', + 'metadata' => 'array', + 'retention_count' => 'integer', + 'day_of_week' => 'integer', + 'day_of_month' => 'integer', + 'last_run_at' => 'datetime', + 'next_run_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function destination(): BelongsTo + { + return $this->belongsTo(BackupDestination::class, 'destination_id'); + } + + public function backups(): HasMany + { + return $this->hasMany(Backup::class, 'schedule_id'); + } + + /** + * Check if the schedule should run now. + */ + public function shouldRun(): bool + { + if (!$this->is_active) { + return false; + } + + if (!$this->next_run_at) { + return true; + } + + return $this->next_run_at->isPast(); + } + + /** + * Calculate and set the next run time. + */ + public function calculateNextRun(): Carbon + { + $time = explode(':', $this->time); + $hour = (int) ($time[0] ?? 2); + $minute = (int) ($time[1] ?? 0); + + $next = Carbon::now()->setTime($hour, $minute, 0); + + // If time already passed today, start from tomorrow + if ($next->isPast()) { + $next->addDay(); + } + + switch ($this->frequency) { + case 'hourly': + $next = Carbon::now()->addHour()->startOfHour(); + break; + + case 'daily': + // Already set to next occurrence + break; + + case 'weekly': + $targetDay = $this->day_of_week ?? 0; // Default to Sunday + while ($next->dayOfWeek !== $targetDay) { + $next->addDay(); + } + break; + + case 'monthly': + $targetDay = $this->day_of_month ?? 1; + $next->day = min($targetDay, $next->daysInMonth); + if ($next->isPast()) { + $next->addMonth(); + $next->day = min($targetDay, $next->daysInMonth); + } + break; + } + + $this->next_run_at = $next; + + return $next; + } + + /** + * Get frequency label for UI. + */ + public function getFrequencyLabelAttribute(): string + { + $base = match ($this->frequency) { + 'hourly' => 'Every hour', + 'daily' => 'Daily at ' . $this->time, + 'weekly' => 'Weekly on ' . $this->getDayName() . ' at ' . $this->time, + 'monthly' => 'Monthly on day ' . ($this->day_of_month ?? 1) . ' at ' . $this->time, + default => ucfirst($this->frequency), + }; + + return $base; + } + + /** + * Get day name for weekly schedules. + */ + protected function getDayName(): string + { + $days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + return $days[$this->day_of_week ?? 0]; + } + + /** + * Scope for active schedules. + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * Scope for due schedules. + */ + public function scopeDue($query) + { + return $query->active() + ->where(function ($q) { + $q->whereNull('next_run_at') + ->orWhere('next_run_at', '<=', now()); + }); + } + + /** + * Scope for user schedules. + */ + public function scopeForUser($query, int $userId) + { + return $query->where('user_id', $userId); + } + + /** + * Scope for server backup schedules. + */ + public function scopeServerBackups($query) + { + return $query->where('is_server_backup', true); + } + + /** + * Get last status color for UI. + */ + public function getLastStatusColorAttribute(): string + { + return match ($this->last_status) { + 'success' => 'success', + 'failed' => 'danger', + default => 'gray', + }; + } +} diff --git a/app/Models/CronJob.php b/app/Models/CronJob.php new file mode 100644 index 0000000..75fece5 --- /dev/null +++ b/app/Models/CronJob.php @@ -0,0 +1,77 @@ + 'array', + 'is_active' => 'boolean', + 'last_run_at' => 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function getScheduleHumanAttribute(): string + { + $parts = explode(' ', $this->schedule); + if (count($parts) !== 5) { + return $this->schedule; + } + + [$minute, $hour, $day, $month, $weekday] = $parts; + + // Common patterns + if ($this->schedule === '* * * * *') return 'Every minute'; + if ($this->schedule === '*/5 * * * *') return 'Every 5 minutes'; + if ($this->schedule === '*/10 * * * *') return 'Every 10 minutes'; + if ($this->schedule === '*/15 * * * *') return 'Every 15 minutes'; + if ($this->schedule === '*/30 * * * *') return 'Every 30 minutes'; + if ($this->schedule === '0 * * * *') return 'Every hour'; + if ($this->schedule === '0 */2 * * *') return 'Every 2 hours'; + if ($this->schedule === '0 */6 * * *') return 'Every 6 hours'; + if ($this->schedule === '0 */12 * * *') return 'Every 12 hours'; + if ($this->schedule === '0 0 * * *') return 'Daily at midnight'; + if ($this->schedule === '0 0 * * 0') return 'Weekly on Sunday'; + if ($this->schedule === '0 0 1 * *') return 'Monthly on the 1st'; + + return $this->schedule; + } + + public static function scheduleOptions(): array + { + return [ + '* * * * *' => 'Every minute', + '*/5 * * * *' => 'Every 5 minutes', + '*/10 * * * *' => 'Every 10 minutes', + '*/15 * * * *' => 'Every 15 minutes', + '*/30 * * * *' => 'Every 30 minutes', + '0 * * * *' => 'Every hour', + '0 */2 * * *' => 'Every 2 hours', + '0 */6 * * *' => 'Every 6 hours', + '0 */12 * * *' => 'Every 12 hours', + '0 0 * * *' => 'Daily at midnight', + '0 0 * * 0' => 'Weekly on Sunday', + '0 0 1 * *' => 'Monthly on the 1st', + ]; + } +} diff --git a/app/Models/DnsRecord.php b/app/Models/DnsRecord.php new file mode 100644 index 0000000..6b80083 --- /dev/null +++ b/app/Models/DnsRecord.php @@ -0,0 +1,29 @@ + 'integer', 'priority' => 'integer']; + + public function domain(): BelongsTo + { + return $this->belongsTo(Domain::class); + } + + public function getFullNameAttribute(): string + { + return $this->name === '@' ? $this->domain->domain : $this->name . '.' . $this->domain->domain; + } + + public static function getTypes(): array + { + return ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'CAA']; + } +} diff --git a/app/Models/DnsSetting.php b/app/Models/DnsSetting.php new file mode 100644 index 0000000..e9d3e19 --- /dev/null +++ b/app/Models/DnsSetting.php @@ -0,0 +1,43 @@ +first(); + return $setting?->value ?? $default; + }); + } + + public static function set(string $key, mixed $value): void + { + static::updateOrCreate(['key' => $key], ['value' => $value]); + Cache::forget("dns_setting_{$key}"); + } + + public static function getAll(): array + { + return Cache::remember('dns_settings_all', 3600, function () { + return static::pluck('value', 'key')->toArray(); + }); + } + + public static function clearCache(): void + { + $settings = static::pluck('key'); + foreach ($settings as $key) { + Cache::forget("dns_setting_{$key}"); + } + Cache::forget('dns_settings_all'); + } +} diff --git a/app/Models/Domain.php b/app/Models/Domain.php new file mode 100644 index 0000000..3ad5465 --- /dev/null +++ b/app/Models/Domain.php @@ -0,0 +1,146 @@ +loadMissing('user', 'emailDomain.forwarders'); + $username = $domain->user?->username; + + if ($username && $domain->emailDomain) { + foreach ($domain->emailDomain->forwarders as $forwarder) { + $agent->send('email.forwarder_delete', [ + 'username' => $username, + 'email' => $forwarder->email, + ]); + } + } + } catch (\Exception $e) { + \Log::warning("Failed to delete email forwarders for domain {$domain->domain}: ".$e->getMessage()); + } + + // Delete SSL certificate + $domain->sslCertificate?->delete(); + + // Delete DNS records + $domain->dnsRecords()->delete(); + + // Delete redirects + $domain->redirects()->delete(); + + // Delete hotlink settings + $domain->hotlinkSetting?->delete(); + + // Delete email domain and related records + if ($domain->emailDomain) { + // Delete mailboxes (which will cascade to autoresponders) + $domain->emailDomain->mailboxes()->delete(); + // Delete forwarders + $domain->emailDomain->forwarders()->delete(); + // Delete email domain + $domain->emailDomain->delete(); + } + }); + } + + protected $fillable = [ + 'user_id', + 'domain', + 'document_root', + 'ip_address', + 'ipv6_address', + 'is_active', + 'ssl_enabled', + 'directory_index', + 'page_cache_enabled', + ]; + + protected $casts = [ + 'is_active' => 'boolean', + 'ssl_enabled' => 'boolean', + 'page_cache_enabled' => 'boolean', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function dnsRecords(): HasMany + { + return $this->hasMany(DnsRecord::class); + } + + public function emailDomain(): HasOne + { + return $this->hasOne(EmailDomain::class); + } + + public function sslCertificate(): HasOne + { + return $this->hasOne(SslCertificate::class); + } + + public function redirects(): HasMany + { + return $this->hasMany(DomainRedirect::class); + } + + public function hotlinkSetting(): HasOne + { + return $this->hasOne(DomainHotlinkSetting::class); + } + + public function getOrCreateHotlinkSetting(): DomainHotlinkSetting + { + return $this->hotlinkSetting ?? $this->hotlinkSetting()->create([ + 'is_enabled' => false, + 'protected_extensions' => DomainHotlinkSetting::getDefaultExtensions(), + ]); + } + + /** + * Check if email is enabled for this domain + */ + public function hasEmailEnabled(): bool + { + return $this->emailDomain()->exists() && $this->emailDomain->is_active; + } + + /** + * Check if SSL is active for this domain + */ + public function hasSslActive(): bool + { + return $this->sslCertificate()->exists() && $this->sslCertificate->isActive(); + } + + /** + * Get SSL status for display + */ + public function getSslStatusAttribute(): string + { + if (! $this->sslCertificate) { + return 'No SSL'; + } + + return $this->sslCertificate->status_label; + } +} diff --git a/app/Models/DomainHotlinkSetting.php b/app/Models/DomainHotlinkSetting.php new file mode 100644 index 0000000..574a241 --- /dev/null +++ b/app/Models/DomainHotlinkSetting.php @@ -0,0 +1,52 @@ + 'boolean', + 'block_blank_referrer' => 'boolean', + ]; + + public function domain(): BelongsTo + { + return $this->belongsTo(Domain::class); + } + + public function getAllowedDomainsArray(): array + { + if (empty($this->allowed_domains)) { + return []; + } + // Split by newlines (form uses one domain per line) + return array_filter(array_map('trim', preg_split('/[\r\n]+/', $this->allowed_domains))); + } + + public function getProtectedExtensionsArray(): array + { + if (empty($this->protected_extensions)) { + return []; + } + return array_filter(array_map('trim', explode(',', $this->protected_extensions))); + } + + public static function getDefaultExtensions(): string + { + return 'jpg,jpeg,png,gif,webp,svg,mp4,mp3,pdf'; + } +} diff --git a/app/Models/DomainRedirect.php b/app/Models/DomainRedirect.php new file mode 100644 index 0000000..f72078f --- /dev/null +++ b/app/Models/DomainRedirect.php @@ -0,0 +1,44 @@ + 'boolean', + 'is_active' => 'boolean', + ]; + + public function domain(): BelongsTo + { + return $this->belongsTo(Domain::class); + } + + public function getTypeLabel(): string + { + return match ($this->redirect_type) { + '301' => __('Permanent (301)'), + '302' => __('Temporary (302)'), + default => $this->redirect_type, + }; + } + + public function getStatusBadgeColor(): string + { + return $this->is_active ? 'success' : 'gray'; + } +} diff --git a/app/Models/EmailDomain.php b/app/Models/EmailDomain.php new file mode 100644 index 0000000..22be02f --- /dev/null +++ b/app/Models/EmailDomain.php @@ -0,0 +1,77 @@ + 'boolean', + 'catch_all_enabled' => 'boolean', + 'max_mailboxes' => 'integer', + 'max_quota_bytes' => 'integer', + ]; + + protected $hidden = [ + 'dkim_private_key', + ]; + + public function domain(): BelongsTo + { + return $this->belongsTo(Domain::class); + } + + public function mailboxes(): HasMany + { + return $this->hasMany(Mailbox::class); + } + + /** + * Get the domain name (e.g., example.com) + */ + public function getDomainNameAttribute(): string + { + return $this->domain->domain; + } + + /** + * Get total quota used across all mailboxes + */ + public function getTotalQuotaUsedAttribute(): int + { + return (int) $this->mailboxes()->sum('quota_used_bytes'); + } + + /** + * Get the number of mailboxes + */ + public function getMailboxCountAttribute(): int + { + return $this->mailboxes()->count(); + } + + /** + * Check if more mailboxes can be created + */ + public function canCreateMailbox(): bool + { + return $this->mailbox_count < $this->max_mailboxes; + } +} diff --git a/app/Models/EmailForwarder.php b/app/Models/EmailForwarder.php new file mode 100644 index 0000000..cde734d --- /dev/null +++ b/app/Models/EmailForwarder.php @@ -0,0 +1,44 @@ + 'array', + 'is_active' => 'boolean', + ]; + + public function emailDomain(): BelongsTo + { + return $this->belongsTo(EmailDomain::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function getEmailAttribute(): string + { + return $this->local_part . '@' . $this->emailDomain->domain->domain; + } + + public function getDestinationsFormattedAttribute(): string + { + return implode(', ', $this->destinations ?? []); + } +} diff --git a/app/Models/ImpersonationToken.php b/app/Models/ImpersonationToken.php new file mode 100644 index 0000000..85d38d6 --- /dev/null +++ b/app/Models/ImpersonationToken.php @@ -0,0 +1,87 @@ + 'datetime', + 'used_at' => 'datetime', + ]; + + public function admin(): BelongsTo + { + return $this->belongsTo(User::class, 'admin_id'); + } + + public function targetUser(): BelongsTo + { + return $this->belongsTo(User::class, 'target_user_id'); + } + + public static function createForUser(User $admin, User $targetUser, ?string $ipAddress = null): self + { + // Clean up old tokens for this admin/user combination + static::where('admin_id', $admin->id) + ->where('target_user_id', $targetUser->id) + ->delete(); + + return static::create([ + 'admin_id' => $admin->id, + 'target_user_id' => $targetUser->id, + 'token' => Str::random(64), + 'expires_at' => now()->addMinutes(5), + 'ip_address' => $ipAddress, + ]); + } + + public function isValid(): bool + { + return $this->expires_at->isFuture() && $this->used_at === null; + } + + public function markAsUsed(): void + { + $this->update(['used_at' => now()]); + } + + public static function findValidToken(string $token, ?string $ipAddress = null): ?self + { + $record = static::where('token', $token) + ->where('expires_at', '>', now()) + ->whereNull('used_at') + ->first(); + + if (! $record) { + return null; + } + + if ($ipAddress && $record->ip_address && $record->ip_address !== $ipAddress) { + return null; + } + + return $record; + } + + public static function cleanupExpired(): int + { + return static::where('expires_at', '<', now()) + ->orWhereNotNull('used_at') + ->delete(); + } +} diff --git a/app/Models/Mailbox.php b/app/Models/Mailbox.php new file mode 100644 index 0000000..45eb98c --- /dev/null +++ b/app/Models/Mailbox.php @@ -0,0 +1,164 @@ + 'boolean', + 'imap_enabled' => 'boolean', + 'pop3_enabled' => 'boolean', + 'smtp_enabled' => 'boolean', + 'quota_bytes' => 'integer', + 'quota_used_bytes' => 'integer', + 'last_login_at' => 'datetime', + ]; + + protected $hidden = [ + 'password_hash', + 'password_encrypted', + ]; + + /** + * Get decrypted password for SSO + */ + public function getPlainPasswordAttribute(): ?string + { + if (empty($this->password_encrypted)) { + return null; + } + try { + return Crypt::decryptString($this->password_encrypted); + } catch (\Exception $e) { + return null; + } + } + + /** + * Store encrypted password + */ + public function setPlainPassword(string $password): void + { + $this->password_encrypted = Crypt::encryptString($password); + $this->save(); + } + + public function emailDomain(): BelongsTo + { + return $this->belongsTo(EmailDomain::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function autoresponder(): HasOne + { + return $this->hasOne(Autoresponder::class); + } + + /** + * Get the full email address (e.g., user@example.com) + */ + public function getEmailAttribute(): string + { + return $this->local_part . '@' . $this->emailDomain->domain_name; + } + + /** + * Get quota usage as a percentage + */ + public function getQuotaPercentAttribute(): float + { + if ($this->quota_bytes <= 0) { + return 0; + } + + return round(($this->quota_used_bytes / $this->quota_bytes) * 100, 1); + } + + /** + * Get the Maildir path for this mailbox + */ + public function getMaildirPathAttribute(): string + { + return $this->emailDomain->domain_name . '/' . $this->local_part . '/'; + } + + /** + * Format quota for display (e.g., "500 MB", "1 GB") + */ + public function getQuotaFormattedAttribute(): string + { + return $this->formatBytes($this->quota_bytes); + } + + /** + * Format quota used for display + */ + public function getQuotaUsedFormattedAttribute(): string + { + return $this->formatBytes($this->quota_used_bytes); + } + + /** + * Check if mailbox is near quota (>80%) + */ + public function isNearQuota(): bool + { + return $this->quota_percent >= 80; + } + + /** + * Check if mailbox is over quota + */ + public function isOverQuota(): bool + { + return $this->quota_used_bytes >= $this->quota_bytes; + } + + /** + * Format bytes to human readable + */ + protected function formatBytes(int $bytes): string + { + if ($bytes < 1024) { + return $bytes . ' B'; + } + if ($bytes < 1048576) { + return round($bytes / 1024, 1) . ' KB'; + } + if ($bytes < 1073741824) { + return round($bytes / 1048576, 1) . ' MB'; + } + + return round($bytes / 1073741824, 1) . ' GB'; + } +} diff --git a/app/Models/MysqlCredential.php b/app/Models/MysqlCredential.php new file mode 100644 index 0000000..36c01fd --- /dev/null +++ b/app/Models/MysqlCredential.php @@ -0,0 +1,26 @@ +belongsTo(User::class); + } + + public function setPasswordAttribute($value) + { + $this->attributes['mysql_password_encrypted'] = Crypt::encryptString($value); + } + + public function getPasswordAttribute() + { + return Crypt::decryptString($this->attributes['mysql_password_encrypted']); + } +} diff --git a/app/Models/NotificationLog.php b/app/Models/NotificationLog.php new file mode 100644 index 0000000..35f6e05 --- /dev/null +++ b/app/Models/NotificationLog.php @@ -0,0 +1,86 @@ + 'array', + 'recipients' => 'array', + ]; + } + + /** + * Log a notification. + */ + public static function log( + string $type, + string $subject, + string $message, + array $recipients, + string $status = 'sent', + ?array $context = null, + ?string $error = null + ): self { + return self::create([ + 'type' => $type, + 'subject' => $subject, + 'message' => $message, + 'recipients' => $recipients, + 'status' => $status, + 'context' => $context, + 'error' => $error, + ]); + } + + /** + * Get human-readable type label. + */ + public function getTypeLabelAttribute(): string + { + return match ($this->type) { + 'ssl_errors' => __('SSL Certificate'), + 'backup_failures' => __('Backup'), + 'disk_quota' => __('Disk Quota'), + 'login_failures' => __('Login Failure'), + 'ssh_logins' => __('SSH Login'), + 'system_updates' => __('System Updates'), + 'service_health' => __('Service Health'), + 'high_load' => __('High Load'), + 'test' => __('Test'), + default => ucfirst(str_replace('_', ' ', $this->type)), + }; + } + + /** + * Scope to get logs from the last N days. + */ + public function scopeLastDays($query, int $days = 30) + { + return $query->where('created_at', '>=', now()->subDays($days)); + } + + /** + * Clean up old logs. + */ + public static function cleanup(int $daysToKeep = 30): int + { + return self::where('created_at', '<', now()->subDays($daysToKeep))->delete(); + } +} diff --git a/app/Models/ServerImport.php b/app/Models/ServerImport.php new file mode 100644 index 0000000..e96affa --- /dev/null +++ b/app/Models/ServerImport.php @@ -0,0 +1,92 @@ + 'array', + 'selected_accounts' => 'array', + 'import_options' => 'array', + 'import_log' => 'array', + 'errors' => 'array', + 'started_at' => 'datetime', + 'completed_at' => 'datetime', + 'remote_password' => 'encrypted', + 'remote_api_token' => 'encrypted', + ]; + + public function accounts(): HasMany + { + return $this->hasMany(ServerImportAccount::class); + } + + public function addLog(string $message): void + { + $log = $this->import_log ?? []; + $log[] = [ + 'time' => now()->toDateTimeString(), + 'message' => $message, + ]; + $this->update(['import_log' => $log]); + } + + public function addError(string $error): void + { + $errors = $this->errors ?? []; + $errors[] = [ + 'time' => now()->toDateTimeString(), + 'error' => $error, + ]; + $this->update(['errors' => $errors]); + } + + public function getStatusColorAttribute(): string + { + return match ($this->status) { + 'pending' => 'gray', + 'discovering' => 'info', + 'ready' => 'warning', + 'importing' => 'info', + 'completed' => 'success', + 'failed' => 'danger', + 'cancelled' => 'gray', + default => 'gray', + }; + } + + public function getCompletedAccountsCountAttribute(): int + { + return $this->accounts()->where('status', 'completed')->count(); + } + + public function getTotalAccountsCountAttribute(): int + { + return $this->accounts()->count(); + } +} diff --git a/app/Models/ServerImportAccount.php b/app/Models/ServerImportAccount.php new file mode 100644 index 0000000..e0f3e1d --- /dev/null +++ b/app/Models/ServerImportAccount.php @@ -0,0 +1,90 @@ + 'array', + 'subdomains' => 'array', + 'databases' => 'array', + 'email_accounts' => 'array', + 'import_log' => 'array', + ]; + + public function serverImport(): BelongsTo + { + return $this->belongsTo(ServerImport::class); + } + + public function addLog(string $message): void + { + $log = $this->import_log ?? []; + $log[] = [ + 'time' => now()->toDateTimeString(), + 'message' => $message, + ]; + $this->update(['import_log' => $log]); + } + + public function getFormattedDiskUsageAttribute(): string + { + $bytes = $this->disk_usage; + if ($bytes >= 1073741824) { + return number_format($bytes / 1073741824, 2) . ' GB'; + } elseif ($bytes >= 1048576) { + return number_format($bytes / 1048576, 2) . ' MB'; + } elseif ($bytes >= 1024) { + return number_format($bytes / 1024, 2) . ' KB'; + } + return $bytes . ' B'; + } + + public function getStatusColorAttribute(): string + { + return match ($this->status) { + 'pending' => 'gray', + 'importing' => 'info', + 'completed' => 'success', + 'failed' => 'danger', + 'skipped' => 'warning', + default => 'gray', + }; + } + + public function getDomainCountAttribute(): int + { + return 1 + count($this->addon_domains ?? []) + count($this->subdomains ?? []); + } + + public function getDatabaseCountAttribute(): int + { + return count($this->databases ?? []); + } + + public function getEmailCountAttribute(): int + { + return count($this->email_accounts ?? []); + } +} diff --git a/app/Models/ServerProcess.php b/app/Models/ServerProcess.php new file mode 100644 index 0000000..db5b545 --- /dev/null +++ b/app/Models/ServerProcess.php @@ -0,0 +1,65 @@ + 'decimal:2', + 'memory' => 'decimal:2', + 'captured_at' => 'datetime', + ]; + + public function scopeLatestBatch($query) + { + $latestBatchId = static::orderBy('captured_at', 'desc')->value('batch_id'); + + return $query->where('batch_id', $latestBatchId); + } + + public static function captureProcesses(array $processes, int $total = 0): string + { + $batchId = (string) \Illuminate\Support\Str::uuid(); + $now = now(); + + // Delete old snapshots (keep last 10 batches for history) + $keepBatches = static::select('batch_id') + ->distinct() + ->orderBy('captured_at', 'desc') + ->limit(10) + ->pluck('batch_id'); + + static::whereNotIn('batch_id', $keepBatches)->delete(); + + // Insert new processes + foreach ($processes as $index => $proc) { + static::create([ + 'batch_id' => $batchId, + 'rank' => $index + 1, + 'pid' => $proc['pid'] ?? 0, + 'user' => $proc['user'] ?? 'unknown', + 'command' => $proc['command'] ?? '', + 'cpu' => $proc['cpu'] ?? 0, + 'memory' => $proc['memory'] ?? 0, + 'captured_at' => $now, + ]); + } + + return $batchId; + } +} diff --git a/app/Models/Setting.php b/app/Models/Setting.php new file mode 100644 index 0000000..44fecf5 --- /dev/null +++ b/app/Models/Setting.php @@ -0,0 +1,49 @@ +first(); + return $setting?->value ?? $default; + }); + } + + /** + * Set a setting value + */ + public static function set(string $key, ?string $value): void + { + static::updateOrCreate( + ['key' => $key], + ['value' => $value] + ); + Cache::forget("setting.{$key}"); + } + + /** + * Get all settings as key-value array + */ + public static function getAll(): array + { + return static::pluck('value', 'key')->toArray(); + } +} diff --git a/app/Models/SslCertificate.php b/app/Models/SslCertificate.php new file mode 100644 index 0000000..dc015ac --- /dev/null +++ b/app/Models/SslCertificate.php @@ -0,0 +1,148 @@ + 'datetime', + 'expires_at' => 'datetime', + 'last_check_at' => 'datetime', + 'auto_renew' => 'boolean', + 'private_key' => 'encrypted', + ]; + + protected $hidden = [ + 'private_key', + ]; + + public function domain(): BelongsTo + { + return $this->belongsTo(Domain::class); + } + + public function isActive(): bool + { + return $this->status === 'active' && $this->expires_at && $this->expires_at->isFuture(); + } + + public function isExpired(): bool + { + return $this->expires_at && $this->expires_at->isPast(); + } + + public function isExpiringSoon(int $days = 30): bool + { + if (!$this->expires_at) { + return false; + } + $daysUntilExpiry = now()->diffInDays($this->expires_at, false); + return $daysUntilExpiry >= 0 && $daysUntilExpiry <= $days; + } + + public function getDaysUntilExpiryAttribute(): ?int + { + if (!$this->expires_at) { + return null; + } + return (int) now()->diffInDays($this->expires_at, false); + } + + public function getStatusColorAttribute(): string + { + if ($this->isExpired()) { + return 'danger'; + } + + if ($this->isExpiringSoon(7)) { + return 'danger'; + } + + if ($this->isExpiringSoon(30)) { + return 'warning'; + } + + return match ($this->status) { + 'active' => 'success', + 'pending' => 'info', + 'failed' => 'danger', + 'revoked' => 'danger', + 'expired' => 'danger', + default => 'gray', + }; + } + + public function getStatusLabelAttribute(): string + { + if ($this->isExpired()) { + return 'Expired'; + } + + if ($this->status === 'active' && $this->isExpiringSoon(7)) { + return 'Expiring Soon'; + } + + return match ($this->status) { + 'active' => 'Active', + 'pending' => 'Pending', + 'failed' => 'Failed', + 'revoked' => 'Revoked', + 'expired' => 'Expired', + default => 'Unknown', + }; + } + + public function getTypeLabelAttribute(): string + { + return match ($this->type) { + 'none' => 'No SSL', + 'self_signed' => 'Self-Signed', + 'lets_encrypt' => "Let's Encrypt", + 'custom' => 'Custom', + default => 'Unknown', + }; + } + + public function needsRenewal(): bool + { + return $this->auto_renew + && $this->type === 'lets_encrypt' + && $this->status === 'active' + && $this->isExpiringSoon(30); + } + + public function incrementRenewalAttempts(): void + { + $this->increment('renewal_attempts'); + $this->update(['last_check_at' => now()]); + } + + public function resetRenewalAttempts(): void + { + $this->update([ + 'renewal_attempts' => 0, + 'last_error' => null, + ]); + } +} diff --git a/app/Models/User.php b/app/Models/User.php new file mode 100644 index 0000000..1abe61a --- /dev/null +++ b/app/Models/User.php @@ -0,0 +1,225 @@ + 'datetime', + 'password' => 'hashed', + 'is_admin' => 'boolean', + 'is_active' => 'boolean', + 'sftp_password' => 'encrypted', + ]; + } + + public function canAccesJabali(Panel $panel): bool + { + if (! $this->is_active) { + return false; + } + + if ($panel->getId() === 'admin') { + return $this->is_admin; + } + + return true; + } + + public function isAdmin(): bool + { + return $this->is_admin; + } + + public function getHomeDirectoryAttribute($value): string + { + return $value ?? "/home/{$this->username}"; + } + + protected static function booted() + { + static::deleting(function ($user) { + if ($user->is_admin && (int) $user->getKey() === 1) { + throw new \RuntimeException(__('Primary admin account cannot be deleted.')); + } + + // Clean up email forwarders from system maps before DB cascade deletes them + try { + $agent = new \App\Services\Agent\AgentClient; + $domains = $user->domains()->with('emailDomain.forwarders', 'emailDomain.domain')->get(); + + foreach ($domains as $domain) { + $forwarders = $domain->emailDomain?->forwarders ?? collect(); + foreach ($forwarders as $forwarder) { + $agent->send('email.forwarder_delete', [ + 'username' => $user->username, + 'email' => $forwarder->email, + ]); + } + } + } catch (\Exception $e) { + \Log::warning("Failed to delete email forwarders for user {$user->username}: ".$e->getMessage()); + } + + // Delete master MySQL user when Jabali user is deleted + $masterUser = $user->username.'_admin'; + + try { + // Use credentials from environment variables + $mysqli = new \mysqli( + config('database.connections.mysql.host', 'localhost'), + config('database.connections.mysql.username'), + config('database.connections.mysql.password') + ); + + if (! $mysqli->connect_error) { + // Use prepared statement to prevent SQL injection + // MySQL doesn't support prepared statements for DROP USER, + // so we validate the username format strictly + if (! preg_match('/^[a-zA-Z0-9_]+$/', $masterUser)) { + throw new \Exception('Invalid MySQL username format'); + } + + // Escape the username as an additional safety measure + $escapedUser = $mysqli->real_escape_string($masterUser); + $mysqli->query("DROP USER IF EXISTS '{$escapedUser}'@'localhost'"); + $mysqli->close(); + } + + // Delete stored credentials + \App\Models\MysqlCredential::where('user_id', $user->id)->delete(); + } catch (\Exception $e) { + \Log::error('Failed to delete master MySQL user: '.$e->getMessage()); + } + }); + } + + /** + * Determine if the user can access the Filament panel. + */ + public function canAccessPanel(\Filament\Panel $panel): bool + { + if ($panel->getId() === 'admin') { + return $this->is_admin && $this->is_active; + } + + return $this->is_active ?? true; + } + + /** + * Get the domains owned by the user. + */ + public function domains(): HasMany + { + return $this->hasMany(Domain::class); + } + + /** + * Get disk usage in bytes. + */ + public function getDiskUsageBytes(): int + { + // Try to get usage from quota system first (more accurate) + try { + $agent = new \App\Services\Agent\AgentClient; + $result = $agent->quotaGet($this->username, '/'); + + if (($result['success'] ?? false) && isset($result['used_mb'])) { + return (int) ($result['used_mb'] * 1024 * 1024); + } + } catch (\Exception $e) { + // Fall back to du command + } + + // Fallback: try du command (may not work if www-data can't read home dir) + $homeDir = $this->home_directory; + + if (! is_dir($homeDir)) { + return 0; + } + + $output = shell_exec('du -sb '.escapeshellarg($homeDir).' 2>/dev/null | cut -f1'); + + return (int) trim($output ?: '0'); + } + + /** + * Get formatted disk usage string. + */ + public function getDiskUsageFormattedAttribute(): string + { + $bytes = $this->getDiskUsageBytes(); + + return $this->formatBytes($bytes); + } + + /** + * Get quota in bytes. + */ + public function getQuotaBytesAttribute(): int + { + return (int) (($this->disk_quota_mb ?? 0) * 1024 * 1024); + } + + /** + * Get disk usage percentage. + */ + public function getDiskUsagePercentAttribute(): float + { + if (! $this->disk_quota_mb || $this->disk_quota_mb <= 0) { + return 0; + } + + $used = $this->getDiskUsageBytes(); + $quota = $this->quota_bytes; + + return $quota > 0 ? min(100, round(($used / $quota) * 100, 1)) : 0; + } + + /** + * Format bytes to human readable string. + */ + protected function formatBytes(int $bytes, int $precision = 1): 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]; + } +} diff --git a/app/Models/UserRemoteBackup.php b/app/Models/UserRemoteBackup.php new file mode 100644 index 0000000..25ae9d5 --- /dev/null +++ b/app/Models/UserRemoteBackup.php @@ -0,0 +1,75 @@ + 'datetime', + 'indexed_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function destination(): BelongsTo + { + return $this->belongsTo(BackupDestination::class, 'destination_id'); + } + + /** + * Parse backup date from backup name (e.g., 2026-01-19_230002 -> 2026-01-19 23:00:02) + */ + public static function parseBackupDate(string $backupName): ?Carbon + { + if (preg_match('/^(\d{4}-\d{2}-\d{2})_(\d{2})(\d{2})(\d{2})$/', $backupName, $matches)) { + return Carbon::parse("{$matches[1]} {$matches[2]}:{$matches[3]}:{$matches[4]}"); + } + return null; + } + + /** + * Get formatted backup date for display. + */ + public function getFormattedDateAttribute(): string + { + return $this->backup_date?->format('M j, Y H:i') ?? $this->backup_name; + } + + /** + * Scope for a specific user. + */ + public function scopeForUser($query, int $userId) + { + return $query->where('user_id', $userId); + } + + /** + * Scope for a specific destination. + */ + public function scopeForDestination($query, int $destinationId) + { + return $query->where('destination_id', $destinationId); + } +} diff --git a/app/Observers/DomainObserver.php b/app/Observers/DomainObserver.php new file mode 100644 index 0000000..7b38bea --- /dev/null +++ b/app/Observers/DomainObserver.php @@ -0,0 +1,113 @@ +createDefaultDnsRecords($domain); + $this->createDnsZone($domain); + $this->scheduleSSLIssuance($domain); + } + + protected function scheduleSSLIssuance(Domain $domain): void + { + // Dispatch SSL issuance job with a 30 second delay + // This gives time for DNS to propagate and web server to be configured + IssueSslCertificate::dispatch($domain->id)->delay(now()->addSeconds(30)); + Log::info("Scheduled SSL certificate issuance for {$domain->domain}"); + } + + public function deleted(Domain $domain): void + { + try { + $agent = new AgentClient; + $agent->send('dns.delete_zone', ['domain' => $domain->domain]); + } catch (Exception $e) { + Log::warning("Failed to delete DNS zone for {$domain->domain}: ".$e->getMessage()); + } + } + + protected function createDefaultDnsRecords(Domain $domain): void + { + $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}"; + + $defaultRecords = [ + // NS records + ['name' => '@', 'type' => 'NS', 'content' => $ns1, 'ttl' => $defaultTtl], + ['name' => '@', 'type' => 'NS', 'content' => $ns2, 'ttl' => $defaultTtl], + // A records + ['name' => '@', 'type' => 'A', 'content' => $defaultIp, 'ttl' => $defaultTtl], + ['name' => 'www', 'type' => 'A', 'content' => $defaultIp, 'ttl' => $defaultTtl], + ['name' => 'mail', 'type' => 'A', 'content' => $defaultIp, 'ttl' => $defaultTtl], + // MX record + ['name' => '@', 'type' => 'MX', 'content' => "mail.{$domain->domain}", 'ttl' => $defaultTtl, 'priority' => 10], + // SPF record - allows mail from this server + ['name' => '@', 'type' => 'TXT', 'content' => 'v=spf1 mx a ~all', 'ttl' => $defaultTtl], + // DMARC record - basic policy + ['name' => '_dmarc', 'type' => 'TXT', 'content' => 'v=DMARC1; p=none; rua=mailto:postmaster@'.$domain->domain, 'ttl' => $defaultTtl], + ]; + + if (! empty($defaultIpv6)) { + $defaultRecords[] = ['name' => '@', 'type' => 'AAAA', 'content' => $defaultIpv6, 'ttl' => $defaultTtl]; + $defaultRecords[] = ['name' => 'www', 'type' => 'AAAA', 'content' => $defaultIpv6, 'ttl' => $defaultTtl]; + $defaultRecords[] = ['name' => 'mail', 'type' => 'AAAA', 'content' => $defaultIpv6, 'ttl' => $defaultTtl]; + } + + foreach ($defaultRecords as $record) { + DnsRecord::create(array_merge(['domain_id' => $domain->id], $record)); + } + } + + protected function createDnsZone(Domain $domain): void + { + try { + $settings = DnsSetting::getAll(); + $records = DnsRecord::where('domain_id', $domain->id)->get()->toArray(); + $hostname = $this->getServerHostname(); + $serverIp = $this->getServerIp(); + + $agent = new AgentClient; + $agent->send('dns.sync_zone', [ + 'domain' => $domain->domain, + 'records' => $records, + 'ns1' => $settings['ns1'] ?? "ns1.{$hostname}", + 'ns2' => $settings['ns2'] ?? "ns2.{$hostname}", + 'admin_email' => $settings['admin_email'] ?? "admin.{$hostname}", + 'default_ip' => $settings['default_ip'] ?? $serverIp, + 'default_ttl' => $settings['default_ttl'] ?? 3600, + ]); + } catch (Exception $e) { + Log::warning("Failed to create DNS zone for {$domain->domain}: ".$e->getMessage()); + } + } + + protected function getServerHostname(): string + { + return gethostname() ?: 'localhost'; + } + + protected function getServerIp(): string + { + $ip = trim(shell_exec("hostname -I | awk '{print $1}'") ?? ''); + + return $ip ?: '127.0.0.1'; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php new file mode 100644 index 0000000..0c5cf5c --- /dev/null +++ b/app/Providers/AppServiceProvider.php @@ -0,0 +1,29 @@ +id('admin') + ->path('jabali-admin') + ->authGuard('admin') + ->login(AdminLogin::class) + ->passwordReset() + ->defaultAvatarProvider(InitialsAvatarProvider::class) + ->colors([ + 'primary' => Color::Red, + ]) + ->darkMode() + ->brandName(fn () => DnsSetting::get('panel_name', 'Jabali') . ' Admin') + ->favicon(asset('favicon.ico')) + ->renderHook( + PanelsRenderHook::HEAD_END, + fn () => $this->getOpenGraphTags('Jabali Admin', 'Server administration panel for Jabali - Manage your hosting infrastructure') . + '' . + \Illuminate\Support\Facades\Vite::useBuildDirectory('build')->withEntryPoints(['resources/js/admin-tour.js', 'resources/js/server-charts.js'])->toHtml() . +$this->getRtlScript() + ) + ->renderHook( + PanelsRenderHook::BODY_START, + fn () => request()->routeIs('filament.admin.auth.login') ? $this->getLoginWordCloud() : '' + ) + ->renderHook( + PanelsRenderHook::FOOTER, + fn () => view('vendor.filament-panels.components.footer') + ) + ->renderHook( + PanelsRenderHook::USER_MENU_BEFORE, + fn () => view('components.language-switcher') + ) + ->renderHook( + PanelsRenderHook::BODY_END, + fn () => view('components.admin-tour') + ) + ->discoverResources(in: app_path('Filament/Admin/Resources'), for: 'App\\Filament\\Admin\\Resources') + ->discoverPages(in: app_path('Filament/Admin/Pages'), for: 'App\\Filament\\Admin\\Pages') + ->pages([ + Dashboard::class, + ]) + ->discoverWidgets(in: app_path('Filament/Admin/Widgets'), for: 'App\\Filament\\Admin\\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, + ]); + } + + protected function getRtlScript(): string + { + $locale = app()->getLocale(); + $direction = config("languages.supported.{$locale}.direction", 'ltr'); + + if ($direction === 'rtl') { + return ''; + } + + return ''; + } + + 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..848d355 --- /dev/null +++ b/app/Providers/Filament/JabaliPanelProvider.php @@ -0,0 +1,228 @@ +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')) + ->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/js/admin-tour.js', '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') + ) + ->renderHook( + PanelsRenderHook::BODY_END, + fn () => view('components.user-tour') + ) + ->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..88aa61b --- /dev/null +++ b/app/Services/Agent/AgentClient.php @@ -0,0 +1,1154 @@ +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 ($buf = socket_read($socket, 8192)) { + $response .= $buf; + if (strlen($buf) < 8192) { + break; + } + } + + 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; + } + + /** + * 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]); + } + + // 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]); + } + + // Domain operations + public function domainCreate(string $username, string $domain): array + { + return $this->send('domain.create', ['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); + } + + // 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 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->send('metrics.overview', []); + } + + /** + * Get CPU metrics. + */ + public function metricsCpu(): array + { + return $this->send('metrics.cpu', []); + } + + /** + * Get memory metrics. + */ + public function metricsMemory(): array + { + return $this->send('metrics.memory', []); + } + + /** + * Get disk metrics. + */ + public function metricsDisk(): array + { + return $this->send('metrics.disk', []); + } + + /** + * Get network metrics. + */ + public function metricsNetwork(): array + { + return $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, + ]); + } + + /** + * Get metrics history. + */ + public function metricsHistory(int $points = 60): array + { + return $this->send('metrics.history', [ + 'points' => $points, + ]); + } + + // ============ 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]); + } +} 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..50cbf6e --- /dev/null +++ b/app/Services/Migration/MigrationDnsSyncService.php @@ -0,0 +1,206 @@ +>|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(); + + $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_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]; + } + + 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 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/System/LinuxUserService.php b/app/Services/System/LinuxUserService.php new file mode 100644 index 0000000..a955151 --- /dev/null +++ b/app/Services/System/LinuxUserService.php @@ -0,0 +1,70 @@ +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 + */ + public function deleteUser(string $username, bool $removeHome = false): bool + { + $response = $this->agent->deleteUser($username, $removeHome); + return $response['success'] ?? false; + } + + /** + * 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/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..2f7c96d --- /dev/null +++ b/bin/jabali-agent @@ -0,0 +1,22622 @@ +#!/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.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.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), + '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), + '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), + '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), + '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.get_logs' => emailGetLogs($params), + 'email.autoresponder_set' => emailAutoresponderSet($params), + 'email.autoresponder_toggle' => emailAutoresponderToggle($params), + 'email.autoresponder_delete' => emailAutoresponderDelete($params), + 'email.hash_password' => emailHashPassword($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), + // 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), + 'metrics.history' => metricsHistory($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"], + }; +} + +// ============ 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 + + 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); + } + } + + // Clean up domain-related files for each domain + foreach ($domains as $domain) { + if (!validateDomain($domain)) { + continue; + } + + // 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"); + } + } + foreach ([$nginxAvailable, $nginxAvailableOld] as $file) { + if (file_exists($file)) { + @unlink($file); + logger("Removed nginx config: $file"); + } + } + + // Remove DNS zone file + $zoneFile = "/etc/bind/zones/db.$domain"; + if (file_exists($zoneFile)) { + @unlink($zoneFile); + logger("Removed DNS zone: $zoneFile"); + + // 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"); + } + $vmailDir = "/var/vmail/$domain"; + if (is_dir($vmailDir)) { + exec("rm -rf " . escapeshellarg($vmailDir)); + logger("Removed vmail directory: $vmailDir"); + } + + // 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"); + } + if (is_dir($certArchive)) { + exec("rm -rf " . escapeshellarg($certArchive)); + logger("Removed SSL archive: $certArchive"); + } + if (file_exists($certRenewal)) { + @unlink($certRenewal); + logger("Removed SSL renewal config: $certRenewal"); + } + } + + // Delete MySQL databases and users belonging to this user + $dbPrefix = $username . '_'; + $mysqli = getMysqlConnection(); + if ($mysqli) { + // 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"); + } + } + $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"); + } + } + $result->free(); + } + $mysqli->query("FLUSH PRIVILEGES"); + $mysqli->close(); + } + + // Remove PHP-FPM pool config + foreach (glob("/etc/php/*/fpm/pool.d/$username.conf") as $poolConf) { + @unlink($poolConf); + logger("Removed PHP-FPM pool: $poolConf"); + } + + // 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)]; + } + + // 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"); + } + + // 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"); + } + } + + // 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'); + + logger("Deleted user $username" . ($removeHome ? " with home directory" : "") . " and cleaned up " . count($domains) . " domain(s)"); + + return ['success' => true, 'message' => "User $username deleted successfully"]; +} + +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; + + # 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; + + # 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_502 http_503 http_504; + 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, + ]; +} + +// ============ 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']; + + // 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 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 .= <<