#!/usr/bin/env php '', // SET GLOBAL statements '/^\s*SET\s+GLOBAL\s+/im' => '-- BLOCKED: SET GLOBAL ', // GRANT statements embedded in dumps '/^\s*GRANT\s+/im' => '-- BLOCKED: GRANT ', // REVOKE statements '/^\s*REVOKE\s+/im' => '-- BLOCKED: REVOKE ', // CREATE USER statements '/^\s*CREATE\s+USER\s+/im' => '-- BLOCKED: CREATE USER ', // DROP USER statements '/^\s*DROP\s+USER\s+/im' => '-- BLOCKED: DROP USER ', // LOAD DATA INFILE (file reading) '/LOAD\s+DATA\s+(LOCAL\s+)?INFILE/i' => '-- BLOCKED: LOAD DATA INFILE', // SELECT INTO OUTFILE/DUMPFILE (file writing) '/SELECT\s+.*\s+INTO\s+(OUTFILE|DUMPFILE)/i' => '-- BLOCKED: SELECT INTO OUTFILE', // INSTALL/UNINSTALL PLUGIN '/^\s*(INSTALL|UNINSTALL)\s+PLUGIN/im' => '-- BLOCKED: PLUGIN ', ]; try { if ($isGzipped) { $content = gzdecode(file_get_contents($filePath)); if ($content === false) { return false; } } else { $content = file_get_contents($filePath); } // Apply sanitization $modified = false; foreach ($dangerousPatterns as $pattern => $replacement) { $newContent = preg_replace($pattern, $replacement, $content); if ($newContent !== $content) { $modified = true; $content = $newContent; logger( "Sanitized dangerous pattern in database dump: $filePath"); } } // Check for USE statements pointing to other users' databases if (preg_match_all('/^\s*USE\s+[`\'"]?([a-zA-Z0-9_]+)[`\'"]?\s*;/im', $content, $matches)) { foreach ($matches[1] as $dbName) { if (strpos($dbName, $prefix) !== 0) { // USE statement for a database not owned by this user $content = preg_replace( '/^\s*USE\s+[`\'"]?' . preg_quote($dbName, '/') . '[`\'"]?\s*;/im', "-- BLOCKED: USE $dbName (not owned by user)", $content ); $modified = true; logger( "Blocked USE statement for foreign database: $dbName"); } } } if ($modified) { if ($isGzipped) { file_put_contents($filePath, gzencode($content)); } else { file_put_contents($filePath, $content); } } return true; } catch (Exception $e) { logger( "Failed to sanitize database dump: " . $e->getMessage()); return false; } } /** * Check a directory for dangerous symlinks that point outside allowed paths. * Returns array of dangerous symlink paths found. */ function findDangerousSymlinks(string $directory, string $allowedBase): array { $dangerous = []; if (!is_dir($directory)) { return $dangerous; } $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST ); foreach ($iterator as $file) { if (is_link($file->getPathname())) { $target = readlink($file->getPathname()); if ($target === false) { continue; } // Resolve absolute path of symlink target if ($target[0] !== '/') { $target = dirname($file->getPathname()) . '/' . $target; } $realTarget = realpath($target); // If target doesn't exist or is outside allowed base, it's dangerous if ($realTarget === false || strpos($realTarget, $allowedBase) !== 0) { $dangerous[] = $file->getPathname(); logger( "Found dangerous symlink: {$file->getPathname()} -> $target"); } } } return $dangerous; } /** * Remove dangerous symlinks from a directory. */ function removeDangerousSymlinks(string $directory, string $allowedBase): int { $dangerous = findDangerousSymlinks($directory, $allowedBase); $removed = 0; foreach ($dangerous as $symlink) { if (unlink($symlink)) { $removed++; logger( "Removed dangerous symlink: $symlink"); } } return $removed; } function handleAction(array $request): array { $action = $request['action'] ?? ''; $params = $request['params'] ?? $request; return match ($action) { 'ping' => ['success' => true, 'message' => 'pong', 'version' => '1.0.0'], 'user.create' => createUser($params), 'user.delete' => deleteUser($params), 'user.exists' => userExists($params), 'ufw.status' => ufwStatus($params), 'ufw.list_rules' => ufwListRules($params), 'ufw.enable' => ufwEnable($params), 'ufw.disable' => ufwDisable($params), 'ufw.allow_port' => ufwAllowPort($params), 'ufw.deny_port' => ufwDenyPort($params), 'ufw.allow_ip' => ufwAllowIp($params), 'ufw.deny_ip' => ufwDenyIp($params), 'ufw.delete_rule' => ufwDeleteRule($params), 'ufw.set_default' => ufwSetDefault($params), 'ufw.limit_port' => ufwLimitPort($params), 'ufw.reset' => ufwReset($params), 'ufw.reload' => ufwReload($params), 'ufw.allow_service' => ufwAllowService($params), 'user.password' => setUserPassword($params), 'user.exists' => userExists($params), 'ufw.status' => ufwStatus($params), 'ufw.list_rules' => ufwListRules($params), 'ufw.enable' => ufwEnable($params), 'ufw.disable' => ufwDisable($params), 'ufw.allow_port' => ufwAllowPort($params), 'ufw.deny_port' => ufwDenyPort($params), 'ufw.allow_ip' => ufwAllowIp($params), 'ufw.deny_ip' => ufwDenyIp($params), 'ufw.delete_rule' => ufwDeleteRule($params), 'ufw.set_default' => ufwSetDefault($params), 'ufw.limit_port' => ufwLimitPort($params), 'ufw.reset' => ufwReset($params), 'ufw.reload' => ufwReload($params), 'ufw.allow_service' => ufwAllowService($params), 'domain.create' => domainCreate($params), 'domain.alias_add' => domainAliasAdd($params), 'domain.alias_remove' => domainAliasRemove($params), 'domain.ensure_error_pages' => domainEnsureErrorPages($params), 'domain.delete' => domainDelete($params), 'domain.list' => domainList($params), 'domain.toggle' => domainToggle($params), 'domain.set_redirects' => domainSetRedirects($params), 'domain.set_hotlink_protection' => domainSetHotlinkProtection($params), 'domain.set_directory_index' => domainSetDirectoryIndex($params), 'domain.list_protected_dirs' => domainListProtectedDirs($params), 'domain.add_protected_dir' => domainAddProtectedDir($params), 'domain.remove_protected_dir' => domainRemoveProtectedDir($params), 'domain.add_protected_dir_user' => domainAddProtectedDirUser($params), 'domain.remove_protected_dir_user' => domainRemoveProtectedDirUser($params), 'php.getSettings' => phpGetSettings($params), 'php.setSettings' => phpSetSettings($params), 'php.update_pool_limits' => phpUpdatePoolLimits($params), 'php.update_all_pool_limits' => phpUpdateAllPoolLimits($params), 'wp.install' => wpInstall($params), 'wp.list' => wpList($params), 'wp.delete' => wpDelete($params), 'wp.auto_login' => wpAutoLogin($params), 'wp.update' => wpUpdate($params), 'wp.scan' => wpScan($params), 'wp.import' => wpImport($params), 'wp.cache_enable' => wpCacheEnable($params), 'wp.cache_disable' => wpCacheDisable($params), 'wp.cache_flush' => wpCacheFlush($params), 'wp.cache_status' => wpCacheStatus($params), 'wp.toggle_debug' => wpToggleDebug($params), 'wp.toggle_auto_update' => wpToggleAutoUpdate($params), 'wp.create_staging' => wpCreateStaging($params), 'wp.push_staging' => wpPushStaging($params), 'wp.page_cache_enable' => wpPageCacheEnable($params), 'wp.page_cache_disable' => wpPageCacheDisable($params), 'wp.page_cache_purge' => wpPageCachePurge($params), 'wp.page_cache_status' => wpPageCacheStatus($params), 'ssh.list_keys' => sshListKeys($params), 'ssh.add_key' => sshAddKey($params), 'ssh.delete_key' => sshDeleteKey($params), 'ssh.enable_shell' => sshEnableShell($params), 'ssh.disable_shell' => sshDisableShell($params), 'ssh.shell_status' => sshGetShellStatus($params), 'file.list' => fileList($params), 'file.read' => fileRead($params), 'file.write' => fileWrite($params), 'file.delete' => fileDelete($params), 'file.mkdir' => fileMkdir($params), 'file.rename' => fileRename($params), 'file.move' => fileMove($params), 'file.copy' => fileCopy($params), 'file.upload' => fileUpload($params), 'file.upload_temp' => fileUploadTemp($params), 'file.download' => fileDownload($params), 'file.exists' => fileExists($params), 'file.info' => fileInfo($params), 'file.extract' => fileExtract($params), 'file.chmod' => fileChmod($params), 'file.chown' => fileChown($params), 'file.trash' => fileTrash($params), 'file.restore' => fileRestore($params), 'file.empty_trash' => fileEmptyTrash($params), 'file.list_trash' => fileListTrash($params), 'image.optimize' => imageOptimize($params), 'mysql.list_databases' => mysqlListDatabases($params), 'mysql.create_database' => mysqlCreateDatabase($params), 'mysql.delete_database' => mysqlDeleteDatabase($params), 'mysql.list_users' => mysqlListUsers($params), 'mysql.create_user' => mysqlCreateUser($params), 'mysql.delete_user' => mysqlDeleteUser($params), 'mysql.change_password' => mysqlChangePassword($params), 'mysql.grant_privileges' => mysqlGrantPrivileges($params), 'mysql.revoke_privileges' => mysqlRevokePrivileges($params), 'mysql.get_privileges' => mysqlGetPrivileges($params), 'mysql.create_master_user' => mysqlCreateMasterUser($params), 'mysql.import_database' => mysqlImportDatabase($params), 'mysql.export_database' => mysqlExportDatabase($params), 'postgres.list_databases' => postgresListDatabases($params), 'postgres.list_users' => postgresListUsers($params), 'postgres.create_database' => postgresCreateDatabase($params), 'postgres.delete_database' => postgresDeleteDatabase($params), 'postgres.create_user' => postgresCreateUser($params), 'postgres.delete_user' => postgresDeleteUser($params), 'postgres.change_password' => postgresChangePassword($params), 'postgres.grant_privileges' => postgresGrantPrivileges($params), 'service.restart' => restartService($params), 'service.reload' => reloadService($params), 'service.status' => getServiceStatus($params), 'dns.create_zone' => dnsCreateZone($params), 'dns.sync_zone' => dnsSyncZone($params), 'dns.delete_zone' => dnsDeleteZone($params), 'dns.reload' => dnsReload($params), 'dns.enable_dnssec' => dnsEnableDnssec($params), 'dns.disable_dnssec' => dnsDisableDnssec($params), 'dns.get_dnssec_status' => dnsGetDnssecStatus($params), 'dns.get_ds_records' => dnsGetDsRecords($params), 'php.install' => phpInstall($params), 'php.uninstall' => phpUninstall($params), 'php.set_default' => phpSetDefaultVersion($params), 'php.restart_fpm' => phpRestartFpm($params), 'php.reload_fpm' => phpReloadFpm($params), 'php.restart_all_fpm' => phpRestartAllFpm($params), 'php.reload_all_fpm' => phpReloadAllFpm($params), 'php.list_versions' => phpListVersions($params), 'php.install_wp_modules' => phpInstallWordPressModules($params), 'ssh.generate_key' => sshGenerateKey($params), 'git.generate_key' => gitGenerateKey($params), 'git.deploy' => gitDeploy($params), 'rspamd.user_settings' => rspamdUserSettings($params), 'usage.bandwidth_total' => usageBandwidthTotal($params), 'usage.user_resources' => usageUserResources($params), 'server.set_hostname' => setHostname($params), 'server.set_upload_limits' => setUploadLimits($params), 'server.update_bind' => updateBindConfig($params), 'server.info' => getServerInfo($params), 'server.create_zone' => createServerZone($params), 'updates.list' => updatesList($params), 'updates.run' => updatesRun($params), 'waf.apply' => wafApplySettings($params), 'geo.apply_rules' => geoApplyRules($params), 'geo.update_database' => geoUpdateDatabase($params), 'geo.upload_database' => geoUploadDatabase($params), 'database.persist_tuning' => databasePersistTuning($params), 'database.get_variables' => databaseGetVariables($params), 'database.set_global' => databaseSetGlobal($params), 'server.export_config' => serverExportConfig($params), 'server.import_config' => serverImportConfig($params), 'server.get_resolvers' => serverGetResolvers($params), 'server.set_resolvers' => serverSetResolvers($params), 'php.install' => phpInstall($params), 'php.uninstall' => phpUninstall($params), 'php.set_default' => phpSetDefault($params), 'php.restart_fpm' => phpRestartFpm($params), 'php.reload_fpm' => phpReloadFpm($params), 'php.restart_all_fpm' => phpRestartAllFpm($params), 'php.reload_all_fpm' => phpReloadAllFpm($params), 'php.list_versions' => phpListVersions($params), 'php.install_wp_modules' => phpInstallWordPressModules($params), 'ssh.generate_key' => sshGenerateKey($params), 'server.set_hostname' => setHostname($params), 'server.set_upload_limits' => setUploadLimits($params), 'server.update_bind' => updateBindConfig($params), 'server.info' => getServerInfo($params), 'server.create_zone' => createServerZone($params), 'nginx.enable_compression' => nginxEnableCompression($params), 'nginx.get_compression_status' => nginxGetCompressionStatus($params), // Email operations 'email.enable_domain' => emailEnableDomain($params), 'email.disable_domain' => emailDisableDomain($params), 'email.generate_dkim' => emailGenerateDkim($params), 'email.domain_info' => emailGetDomainInfo($params), 'email.mailbox_create' => emailMailboxCreate($params), 'email.mailbox_delete' => emailMailboxDelete($params), 'email.mailbox_change_password' => emailMailboxChangePassword($params), 'email.mailbox_set_quota' => emailMailboxSetQuota($params), 'email.mailbox_quota_usage' => emailMailboxGetQuotaUsage($params), 'email.mailbox_toggle' => emailMailboxToggle($params), 'email.sync_virtual_users' => emailSyncVirtualUsers($params), 'email.reload_services' => emailReloadServices($params), 'email.forwarder_create' => emailForwarderCreate($params), 'email.forwarder_delete' => emailForwarderDelete($params), 'email.forwarder_update' => emailForwarderUpdate($params), 'email.forwarder_toggle' => emailForwarderToggle($params), 'email.catchall_update' => emailCatchallUpdate($params), 'email.sync_maps' => emailSyncMaps($params), 'email.get_logs' => emailGetLogs($params), 'email.autoresponder_set' => emailAutoresponderSet($params), 'email.autoresponder_toggle' => emailAutoresponderToggle($params), 'email.autoresponder_delete' => emailAutoresponderDelete($params), 'email.hash_password' => emailHashPassword($params), // Mail queue operations 'mail.queue_list' => mailQueueList($params), 'mail.queue_retry' => mailQueueRetry($params), 'mail.queue_delete' => mailQueueDelete($params), 'service.list' => serviceList($params), 'service.start' => serviceStart($params), 'service.stop' => serviceStop($params), 'service.restart' => serviceRestart($params), 'service.enable' => serviceEnable($params), 'service.disable' => serviceDisable($params), // Server Import operations 'import.discover' => importDiscover($params), 'import.start' => importStart($params), // SSL Certificate operations 'ssl.check' => sslCheck($params), 'ssl.issue' => sslIssue($params), 'ssl.install' => sslInstall($params), 'ssl.renew' => sslRenew($params), 'ssl.generate_self_signed' => sslGenerateSelfSigned($params), 'ssl.delete' => sslDelete($params), // Backup operations 'backup.create' => backupCreate($params), 'backup.create_server' => backupCreateServer($params), 'backup.incremental_direct' => backupServerIncrementalDirect($params), 'backup.restore' => backupRestore($params), 'backup.list' => backupList($params), 'backup.delete' => backupDelete($params), 'backup.delete_server' => backupDeleteServer($params), 'backup.verify' => backupVerify($params), 'backup.get_info' => backupGetInfo($params), 'backup.upload_remote' => backupUploadRemote($params), 'backup.download_remote' => backupDownloadRemote($params), 'backup.list_remote' => backupListRemote($params), 'backup.delete_remote' => backupDeleteRemote($params), 'backup.test_destination' => backupTestDestination($params), 'backup.download_user_archive' => backupDownloadUserArchive($params), // cPanel migration operations 'cpanel.analyze_backup' => cpanelAnalyzeBackup($params), 'cpanel.restore_backup' => cpanelRestoreBackup($params), 'cpanel.fix_backup_permissions' => cpanelFixBackupPermissions($params), // WHM migration operations 'whm.download_backup_scp' => whmDownloadBackupScp($params), // Jabali system SSH key operations 'jabali_ssh.get_public_key' => jabaliSshGetPublicKey($params), 'jabali_ssh.get_private_key' => jabaliSshGetPrivateKey($params), 'jabali_ssh.ensure_exists' => jabaliSshEnsureExists($params), 'jabali_ssh.add_to_authorized_keys' => jabaliSshAddToAuthorizedKeys($params), // Fail2ban operations 'fail2ban.status' => fail2banStatus($params), 'fail2ban.status_light' => fail2banStatusLight($params), 'fail2ban.install' => fail2banInstall($params), 'fail2ban.start' => fail2banStart($params), 'fail2ban.stop' => fail2banStop($params), 'fail2ban.restart' => fail2banRestart($params), 'fail2ban.save_settings' => fail2banSaveSettings($params), 'fail2ban.unban_ip' => fail2banUnbanIp($params), 'fail2ban.ban_ip' => fail2banBanIp($params), 'fail2ban.list_jails' => fail2banListJails($params), 'fail2ban.enable_jail' => fail2banEnableJail($params), 'fail2ban.disable_jail' => fail2banDisableJail($params), 'fail2ban.logs' => fail2banLogs($params), // ClamAV operations 'clamav.status' => clamavStatus($params), 'clamav.status_light' => clamavStatusLight($params), 'clamav.install' => clamavInstall($params), 'clamav.start' => clamavStart($params), 'clamav.stop' => clamavStop($params), 'clamav.update_signatures' => clamavUpdateSignatures($params), 'clamav.scan' => clamavScan($params), 'clamav.realtime_start' => clamavRealtimeStart($params), 'clamav.realtime_stop' => clamavRealtimeStop($params), 'clamav.realtime_enable' => clamavRealtimeEnable($params), 'clamav.realtime_disable' => clamavRealtimeDisable($params), 'clamav.delete_quarantined' => clamavDeleteQuarantined($params), 'clamav.clear_threats' => clamavClearThreats($params), 'clamav.set_light_mode' => clamavSetLightMode($params), 'clamav.set_full_mode' => clamavSetFullMode($params), 'clamav.force_update_signatures' => clamavForceUpdateSignatures($params), 'ssh.get_settings' => sshGetSettings($params), 'ssh.save_settings' => sshSaveSettings($params), // Cron job operations 'cron.list' => cronList($params), 'cron.create' => cronCreate($params), 'cron.delete' => cronDelete($params), 'cron.toggle' => cronToggle($params), 'cron.run' => cronRun($params), 'cron.wp_setup' => cronWordPressSetup($params), // Server metrics operations 'metrics.overview' => metricsOverview($params), 'metrics.cpu' => metricsCpu($params), 'metrics.memory' => metricsMemory($params), 'metrics.disk' => metricsDisk($params), 'metrics.network' => metricsNetwork($params), 'metrics.processes' => metricsProcesses($params), '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; include /etc/nginx/jabali/includes/waf.conf; include /etc/nginx/jabali/includes/geo.conf; # Allow ACME challenge for SSL certificate issuance/renewal location /.well-known/acme-challenge/ { try_files $uri =404; } # Redirect all other HTTP traffic to HTTPS location / { return 301 https://$host$request_uri; } } server { listen 443 ssl; listen [::]:443 ssl; http2 on; server_name DOMAIN_PLACEHOLDER www.DOMAIN_PLACEHOLDER; root DOCROOT_PLACEHOLDER; include /etc/nginx/jabali/includes/waf.conf; include /etc/nginx/jabali/includes/geo.conf; # Symlink protection - prevent following symlinks outside document root disable_symlinks if_not_owner from=$document_root; ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem; ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key; index index.php index.html; client_max_body_size 50M; location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { fastcgi_pass unix:SOCKET_PLACEHOLDER; fastcgi_next_upstream error timeout invalid_header http_500 http_503; fastcgi_next_upstream_tries 2; fastcgi_next_upstream_timeout 5s; fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; include fastcgi_params; } # GoAccess statistics reports location /stats/ { alias STATS_PLACEHOLDER/; index report.html; } location ~ /\.(?!well-known).* { deny all; } access_log LOGS_PLACEHOLDER/access.log combined; error_log LOGS_PLACEHOLDER/error.log; } NGINXCONF; // Stats directory lives under the document root for web access $stats = rtrim($publicHtml, '/') . '/stats'; $config = str_replace('DOMAIN_PLACEHOLDER', $domain, $config); $config = str_replace('DOCROOT_PLACEHOLDER', $publicHtml, $config); $config = str_replace('SOCKET_PLACEHOLDER', $fpmSocket, $config); $config = str_replace('LOGS_PLACEHOLDER', $logs, $config); $config = str_replace('STATS_PLACEHOLDER', $stats, $config); return $config; } function createFpmPool(string $username, bool $reload = true): array { $phpVersion = '8.4'; $poolFile = "/etc/php/{$phpVersion}/fpm/pool.d/{$username}.conf"; // Check if pool already exists if (file_exists($poolFile)) { return [ 'success' => true, 'message' => 'Pool already exists', 'socket' => getFpmSocketPath($username), 'pool_created' => false, ]; } $userInfo = posix_getpwnam($username); if (!$userInfo) { return ['success' => false, 'error' => 'User not found']; } $userHome = $userInfo['dir']; // Create required directories $dirs = ["{$userHome}/tmp", "{$userHome}/logs"]; foreach ($dirs as $dir) { if (!is_dir($dir)) { mkdir($dir, 0755, true); chown($dir, $username); chgrp($dir, $username); } } // Default PHP settings - can be overridden via admin settings $memoryLimit = '512M'; $uploadMaxFilesize = '64M'; $postMaxSize = '64M'; $maxExecutionTime = '300'; $maxInputTime = '300'; $maxInputVars = '3000'; // Resource limits - configurable via admin settings $pmMaxChildren = (int)($params['pm_max_children'] ?? 5); $pmStartServers = max(1, (int)($pmMaxChildren / 5)); $pmMinSpareServers = max(1, (int)($pmMaxChildren / 5)); $pmMaxSpareServers = max(2, (int)($pmMaxChildren / 2)); $pmMaxRequests = (int)($params['pm_max_requests'] ?? 200); $rlimitFiles = (int)($params['rlimit_files'] ?? 1024); $processPriority = (int)($params['process_priority'] ?? 0); $requestTerminateTimeout = (int)($params['request_terminate_timeout'] ?? 300); $poolConfig = "[{$username}] user = {$username} group = {$username} listen = /run/php/php{$phpVersion}-fpm-{$username}.sock listen.owner = {$username} listen.group = www-data listen.mode = 0660 ; Process manager settings pm = dynamic pm.max_children = {$pmMaxChildren} pm.start_servers = {$pmStartServers} pm.min_spare_servers = {$pmMinSpareServers} pm.max_spare_servers = {$pmMaxSpareServers} pm.max_requests = {$pmMaxRequests} ; Resource limits rlimit_files = {$rlimitFiles} process.priority = {$processPriority} request_terminate_timeout = {$requestTerminateTimeout}s ; slowlog disabled by default to avoid startup failures when logs dir missing ; request_slowlog_timeout = 30s ; slowlog = {$userHome}/logs/php-slow.log chdir = / ; PHP Settings (defaults) php_admin_value[memory_limit] = {$memoryLimit} php_admin_value[upload_max_filesize] = {$uploadMaxFilesize} php_admin_value[post_max_size] = {$postMaxSize} php_admin_value[max_execution_time] = {$maxExecutionTime} php_admin_value[max_input_time] = {$maxInputTime} php_admin_value[max_input_vars] = {$maxInputVars} ; Security php_admin_value[open_basedir] = {$userHome}/:/tmp/:/usr/share/php/ php_admin_value[upload_tmp_dir] = {$userHome}/tmp php_admin_value[session.save_path] = {$userHome}/tmp php_admin_value[sys_temp_dir] = {$userHome}/tmp php_admin_value[disable_functions] = symlink,link,exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec ; Logging php_admin_flag[log_errors] = on php_admin_value[error_log] = {$userHome}/logs/php-error.log security.limit_extensions = .php "; if (file_put_contents($poolFile, $poolConfig) === false) { return [ 'success' => false, 'error' => 'Failed to create pool configuration', 'pool_created' => false, ]; } // Reload PHP-FPM if requested (default behavior for normal operations) // Pass reload=false during migrations to avoid unnecessary reloads during batches if ($reload) { exec("systemctl reload php{$phpVersion}-fpm 2>&1", $output, $code); if ($code !== 0) { logger("Warning: PHP-FPM reload failed: " . implode("\n", $output)); } } return [ 'success' => true, 'socket' => getFpmSocketPath($username), 'needs_reload' => !$reload, 'pool_created' => true, ]; } function deleteFpmPool(string $username): array { $phpVersion = '8.4'; $poolFile = "/etc/php/{$phpVersion}/fpm/pool.d/{$username}.conf"; if (file_exists($poolFile)) { unlink($poolFile); exec("(sleep 1 && systemctl reload php{$phpVersion}-fpm) > /dev/null 2>&1 &"); } return ['success' => true]; } /** * Update FPM pool limits for a specific user */ function phpUpdatePoolLimits(array $params): array { $username = $params['username'] ?? ''; $phpVersion = '8.4'; if (!validateUsername($username)) { return ['success' => false, 'error' => 'Invalid username']; } $poolFile = "/etc/php/{$phpVersion}/fpm/pool.d/{$username}.conf"; if (!file_exists($poolFile)) { return ['success' => false, 'error' => 'Pool configuration not found']; } $userHome = "/home/{$username}"; // Ensure logs directory exists (for error logs) $logsDir = "{$userHome}/logs"; if (!is_dir($logsDir)) { mkdir($logsDir, 0755, true); chown($logsDir, $username); chgrp($logsDir, $username); } // Get limits from params with defaults $pmMaxChildren = (int)($params['pm_max_children'] ?? 5); $pmStartServers = max(1, (int)($pmMaxChildren / 5)); $pmMinSpareServers = max(1, (int)($pmMaxChildren / 5)); $pmMaxSpareServers = max(2, (int)($pmMaxChildren / 2)); $pmMaxRequests = (int)($params['pm_max_requests'] ?? 200); $rlimitFiles = (int)($params['rlimit_files'] ?? 1024); $processPriority = (int)($params['process_priority'] ?? 0); $requestTerminateTimeout = (int)($params['request_terminate_timeout'] ?? 300); // PHP settings $memoryLimit = $params['memory_limit'] ?? '512M'; $uploadMaxFilesize = $params['upload_max_filesize'] ?? '64M'; $postMaxSize = $params['post_max_size'] ?? '64M'; $maxExecutionTime = $params['max_execution_time'] ?? '300'; $maxInputTime = $params['max_input_time'] ?? '300'; $maxInputVars = $params['max_input_vars'] ?? '3000'; $poolConfig = "[{$username}] user = {$username} group = {$username} listen = /run/php/php{$phpVersion}-fpm-{$username}.sock listen.owner = {$username} listen.group = www-data listen.mode = 0660 ; Process manager settings pm = dynamic pm.max_children = {$pmMaxChildren} pm.start_servers = {$pmStartServers} pm.min_spare_servers = {$pmMinSpareServers} pm.max_spare_servers = {$pmMaxSpareServers} pm.max_requests = {$pmMaxRequests} ; Resource limits rlimit_files = {$rlimitFiles} process.priority = {$processPriority} request_terminate_timeout = {$requestTerminateTimeout}s ; slowlog disabled by default to avoid startup failures when logs dir missing ; request_slowlog_timeout = 30s ; slowlog = {$userHome}/logs/php-slow.log chdir = / ; PHP Settings php_admin_value[memory_limit] = {$memoryLimit} php_admin_value[upload_max_filesize] = {$uploadMaxFilesize} php_admin_value[post_max_size] = {$postMaxSize} php_admin_value[max_execution_time] = {$maxExecutionTime} php_admin_value[max_input_time] = {$maxInputTime} php_admin_value[max_input_vars] = {$maxInputVars} ; Security php_admin_value[open_basedir] = {$userHome}/:/tmp/:/usr/share/php/ php_admin_value[upload_tmp_dir] = {$userHome}/tmp php_admin_value[session.save_path] = {$userHome}/tmp php_admin_value[sys_temp_dir] = {$userHome}/tmp php_admin_value[disable_functions] = symlink,link,exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec ; Logging php_admin_flag[log_errors] = on php_admin_value[error_log] = {$userHome}/logs/php-error.log security.limit_extensions = .php "; if (file_put_contents($poolFile, $poolConfig) === false) { return ['success' => false, 'error' => 'Failed to update pool configuration']; } return ['success' => true]; } /** * Update FPM pool limits for all users */ function phpUpdateAllPoolLimits(array $params): array { $phpVersion = '8.4'; $poolDir = "/etc/php/{$phpVersion}/fpm/pool.d"; $pools = glob("{$poolDir}/*.conf"); $updated = []; $errors = []; foreach ($pools as $poolFile) { $username = basename($poolFile, '.conf'); // Skip www.conf (default pool) if ($username === 'www') { continue; } // Verify user exists exec("id " . escapeshellarg($username) . " 2>/dev/null", $output, $exitCode); if ($exitCode !== 0) { continue; } $result = phpUpdatePoolLimits(array_merge($params, ['username' => $username])); if ($result['success']) { $updated[] = $username; } else { $errors[$username] = $result['error']; } } // Reload PHP-FPM after all updates exec("(sleep 2 && systemctl reload php{$phpVersion}-fpm) > /dev/null 2>&1 &"); return [ 'success' => true, 'updated' => $updated, 'errors' => $errors, ]; } // ============ DOMAIN MANAGEMENT ============ function createDomain(array $params): array { $username = $params['username'] ?? ''; $domain = $params['domain'] ?? ''; if (!validateUsername($username) || !validateDomain($domain)) { return ['success' => false, 'error' => 'Invalid username or domain format']; } exec("id " . escapeshellarg($username) . " 2>/dev/null", $output, $exitCode); if ($exitCode !== 0) { return ['success' => false, 'error' => 'User does not exist']; } $homeDir = "/home/$username"; $domainDir = "$homeDir/domains/$domain"; $publicDir = "$domainDir/public_html"; if (is_dir($domainDir)) { return ['success' => false, 'error' => 'Domain directory already exists']; } if (!mkdir($publicDir, 0755, true)) { return ['success' => false, 'error' => 'Failed to create domain directory']; } // Set ownership to user (NO www-data access) exec(sprintf('chown -R %s:%s %s', escapeshellarg($username), escapeshellarg($username), escapeshellarg($domainDir))); // Create default index.html $indexContent = "\n\nWelcome to $domain\n

Welcome to $domain

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

Welcome!

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

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