#!/usr/bin/env php
'',
// SET GLOBAL statements
'/^\s*SET\s+GLOBAL\s+/im' => '-- BLOCKED: SET GLOBAL ',
// GRANT statements embedded in dumps
'/^\s*GRANT\s+/im' => '-- BLOCKED: GRANT ',
// REVOKE statements
'/^\s*REVOKE\s+/im' => '-- BLOCKED: REVOKE ',
// CREATE USER statements
'/^\s*CREATE\s+USER\s+/im' => '-- BLOCKED: CREATE USER ',
// DROP USER statements
'/^\s*DROP\s+USER\s+/im' => '-- BLOCKED: DROP USER ',
// LOAD DATA INFILE (file reading)
'/LOAD\s+DATA\s+(LOCAL\s+)?INFILE/i' => '-- BLOCKED: LOAD DATA INFILE',
// SELECT INTO OUTFILE/DUMPFILE (file writing)
'/SELECT\s+.*\s+INTO\s+(OUTFILE|DUMPFILE)/i' => '-- BLOCKED: SELECT INTO OUTFILE',
// INSTALL/UNINSTALL PLUGIN
'/^\s*(INSTALL|UNINSTALL)\s+PLUGIN/im' => '-- BLOCKED: PLUGIN ',
];
try {
if ($isGzipped) {
$content = gzdecode(file_get_contents($filePath));
if ($content === false) {
return false;
}
} else {
$content = file_get_contents($filePath);
}
// Apply sanitization
$modified = false;
foreach ($dangerousPatterns as $pattern => $replacement) {
$newContent = preg_replace($pattern, $replacement, $content);
if ($newContent !== $content) {
$modified = true;
$content = $newContent;
logger( "Sanitized dangerous pattern in database dump: $filePath");
}
}
// Check for USE statements pointing to other users' databases
if (preg_match_all('/^\s*USE\s+[`\'"]?([a-zA-Z0-9_]+)[`\'"]?\s*;/im', $content, $matches)) {
foreach ($matches[1] as $dbName) {
if (strpos($dbName, $prefix) !== 0) {
// USE statement for a database not owned by this user
$content = preg_replace(
'/^\s*USE\s+[`\'"]?' . preg_quote($dbName, '/') . '[`\'"]?\s*;/im',
"-- BLOCKED: USE $dbName (not owned by user)",
$content
);
$modified = true;
logger( "Blocked USE statement for foreign database: $dbName");
}
}
}
if ($modified) {
if ($isGzipped) {
file_put_contents($filePath, gzencode($content));
} else {
file_put_contents($filePath, $content);
}
}
return true;
} catch (Exception $e) {
logger( "Failed to sanitize database dump: " . $e->getMessage());
return false;
}
}
/**
* Check a directory for dangerous symlinks that point outside allowed paths.
* Returns array of dangerous symlink paths found.
*/
function findDangerousSymlinks(string $directory, string $allowedBase): array
{
$dangerous = [];
if (!is_dir($directory)) {
return $dangerous;
}
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $file) {
if (is_link($file->getPathname())) {
$target = readlink($file->getPathname());
if ($target === false) {
continue;
}
// Resolve absolute path of symlink target
if ($target[0] !== '/') {
$target = dirname($file->getPathname()) . '/' . $target;
}
$realTarget = realpath($target);
// If target doesn't exist or is outside allowed base, it's dangerous
if ($realTarget === false || strpos($realTarget, $allowedBase) !== 0) {
$dangerous[] = $file->getPathname();
logger( "Found dangerous symlink: {$file->getPathname()} -> $target");
}
}
}
return $dangerous;
}
/**
* Remove dangerous symlinks from a directory.
*/
function removeDangerousSymlinks(string $directory, string $allowedBase): int
{
$dangerous = findDangerousSymlinks($directory, $allowedBase);
$removed = 0;
foreach ($dangerous as $symlink) {
if (unlink($symlink)) {
$removed++;
logger( "Removed dangerous symlink: $symlink");
}
}
return $removed;
}
function handleAction(array $request): array
{
$action = $request['action'] ?? '';
$params = $request['params'] ?? $request;
return match ($action) {
'ping' => ['success' => true, 'message' => 'pong', 'version' => '1.0.0'],
'user.create' => createUser($params),
'user.delete' => deleteUser($params),
'user.exists' => userExists($params),
'ufw.status' => ufwStatus($params),
'ufw.list_rules' => ufwListRules($params),
'ufw.enable' => ufwEnable($params),
'ufw.disable' => ufwDisable($params),
'ufw.allow_port' => ufwAllowPort($params),
'ufw.deny_port' => ufwDenyPort($params),
'ufw.allow_ip' => ufwAllowIp($params),
'ufw.deny_ip' => ufwDenyIp($params),
'ufw.delete_rule' => ufwDeleteRule($params),
'ufw.set_default' => ufwSetDefault($params),
'ufw.limit_port' => ufwLimitPort($params),
'ufw.reset' => ufwReset($params),
'ufw.reload' => ufwReload($params),
'ufw.allow_service' => ufwAllowService($params),
'user.password' => setUserPassword($params),
'user.exists' => userExists($params),
'ufw.status' => ufwStatus($params),
'ufw.list_rules' => ufwListRules($params),
'ufw.enable' => ufwEnable($params),
'ufw.disable' => ufwDisable($params),
'ufw.allow_port' => ufwAllowPort($params),
'ufw.deny_port' => ufwDenyPort($params),
'ufw.allow_ip' => ufwAllowIp($params),
'ufw.deny_ip' => ufwDenyIp($params),
'ufw.delete_rule' => ufwDeleteRule($params),
'ufw.set_default' => ufwSetDefault($params),
'ufw.limit_port' => ufwLimitPort($params),
'ufw.reset' => ufwReset($params),
'ufw.reload' => ufwReload($params),
'ufw.allow_service' => ufwAllowService($params),
'domain.create' => domainCreate($params),
'domain.delete' => domainDelete($params),
'domain.list' => domainList($params),
'domain.toggle' => domainToggle($params),
'domain.set_redirects' => domainSetRedirects($params),
'domain.set_hotlink_protection' => domainSetHotlinkProtection($params),
'domain.set_directory_index' => domainSetDirectoryIndex($params),
'domain.list_protected_dirs' => domainListProtectedDirs($params),
'domain.add_protected_dir' => domainAddProtectedDir($params),
'domain.remove_protected_dir' => domainRemoveProtectedDir($params),
'domain.add_protected_dir_user' => domainAddProtectedDirUser($params),
'domain.remove_protected_dir_user' => domainRemoveProtectedDirUser($params),
'php.getSettings' => phpGetSettings($params),
'php.setSettings' => phpSetSettings($params),
'php.update_pool_limits' => phpUpdatePoolLimits($params),
'php.update_all_pool_limits' => phpUpdateAllPoolLimits($params),
'wp.install' => wpInstall($params),
'wp.list' => wpList($params),
'wp.delete' => wpDelete($params),
'wp.auto_login' => wpAutoLogin($params),
'wp.update' => wpUpdate($params),
'wp.scan' => wpScan($params),
'wp.import' => wpImport($params),
'wp.cache_enable' => wpCacheEnable($params),
'wp.cache_disable' => wpCacheDisable($params),
'wp.cache_flush' => wpCacheFlush($params),
'wp.cache_status' => wpCacheStatus($params),
'wp.toggle_debug' => wpToggleDebug($params),
'wp.toggle_auto_update' => wpToggleAutoUpdate($params),
'wp.create_staging' => wpCreateStaging($params),
'wp.page_cache_enable' => wpPageCacheEnable($params),
'wp.page_cache_disable' => wpPageCacheDisable($params),
'wp.page_cache_purge' => wpPageCachePurge($params),
'wp.page_cache_status' => wpPageCacheStatus($params),
'ssh.list_keys' => sshListKeys($params),
'ssh.add_key' => sshAddKey($params),
'ssh.delete_key' => sshDeleteKey($params),
'ssh.enable_shell' => sshEnableShell($params),
'ssh.disable_shell' => sshDisableShell($params),
'ssh.shell_status' => sshGetShellStatus($params),
'file.list' => fileList($params),
'file.read' => fileRead($params),
'file.write' => fileWrite($params),
'file.delete' => fileDelete($params),
'file.mkdir' => fileMkdir($params),
'file.rename' => fileRename($params),
'file.move' => fileMove($params),
'file.copy' => fileCopy($params),
'file.upload' => fileUpload($params),
'file.upload_temp' => fileUploadTemp($params),
'file.download' => fileDownload($params),
'file.exists' => fileExists($params),
'file.info' => fileInfo($params),
'file.extract' => fileExtract($params),
'file.chmod' => fileChmod($params),
'file.chown' => fileChown($params),
'file.trash' => fileTrash($params),
'file.restore' => fileRestore($params),
'file.empty_trash' => fileEmptyTrash($params),
'file.list_trash' => fileListTrash($params),
'mysql.list_databases' => mysqlListDatabases($params),
'mysql.create_database' => mysqlCreateDatabase($params),
'mysql.delete_database' => mysqlDeleteDatabase($params),
'mysql.list_users' => mysqlListUsers($params),
'mysql.create_user' => mysqlCreateUser($params),
'mysql.delete_user' => mysqlDeleteUser($params),
'mysql.change_password' => mysqlChangePassword($params),
'mysql.grant_privileges' => mysqlGrantPrivileges($params),
'mysql.revoke_privileges' => mysqlRevokePrivileges($params),
'mysql.get_privileges' => mysqlGetPrivileges($params),
'mysql.create_master_user' => mysqlCreateMasterUser($params),
'mysql.import_database' => mysqlImportDatabase($params),
'mysql.export_database' => mysqlExportDatabase($params),
'service.restart' => restartService($params),
'service.reload' => reloadService($params),
'service.status' => getServiceStatus($params),
'dns.create_zone' => dnsCreateZone($params),
'dns.sync_zone' => dnsSyncZone($params),
'dns.delete_zone' => dnsDeleteZone($params),
'dns.reload' => dnsReload($params),
'dns.enable_dnssec' => dnsEnableDnssec($params),
'dns.disable_dnssec' => dnsDisableDnssec($params),
'dns.get_dnssec_status' => dnsGetDnssecStatus($params),
'dns.get_ds_records' => dnsGetDsRecords($params),
'php.install' => phpInstall($params),
'php.uninstall' => phpUninstall($params),
'php.set_default' => phpSetDefaultVersion($params),
'php.restart_fpm' => phpRestartFpm($params),
'php.reload_fpm' => phpReloadFpm($params),
'php.restart_all_fpm' => phpRestartAllFpm($params),
'php.reload_all_fpm' => phpReloadAllFpm($params),
'php.list_versions' => phpListVersions($params),
'php.install_wp_modules' => phpInstallWordPressModules($params),
'ssh.generate_key' => sshGenerateKey($params),
'server.set_hostname' => setHostname($params),
'server.set_upload_limits' => setUploadLimits($params),
'server.update_bind' => updateBindConfig($params),
'server.info' => getServerInfo($params),
'server.create_zone' => createServerZone($params),
'server.export_config' => serverExportConfig($params),
'server.import_config' => serverImportConfig($params),
'server.get_resolvers' => serverGetResolvers($params),
'server.set_resolvers' => serverSetResolvers($params),
'php.install' => phpInstall($params),
'php.uninstall' => phpUninstall($params),
'php.set_default' => phpSetDefault($params),
'php.restart_fpm' => phpRestartFpm($params),
'php.reload_fpm' => phpReloadFpm($params),
'php.restart_all_fpm' => phpRestartAllFpm($params),
'php.reload_all_fpm' => phpReloadAllFpm($params),
'php.list_versions' => phpListVersions($params),
'php.install_wp_modules' => phpInstallWordPressModules($params),
'ssh.generate_key' => sshGenerateKey($params),
'server.set_hostname' => setHostname($params),
'server.set_upload_limits' => setUploadLimits($params),
'server.update_bind' => updateBindConfig($params),
'server.info' => getServerInfo($params),
'server.create_zone' => createServerZone($params),
'nginx.enable_compression' => nginxEnableCompression($params),
'nginx.get_compression_status' => nginxGetCompressionStatus($params),
// Email operations
'email.enable_domain' => emailEnableDomain($params),
'email.disable_domain' => emailDisableDomain($params),
'email.generate_dkim' => emailGenerateDkim($params),
'email.domain_info' => emailGetDomainInfo($params),
'email.mailbox_create' => emailMailboxCreate($params),
'email.mailbox_delete' => emailMailboxDelete($params),
'email.mailbox_change_password' => emailMailboxChangePassword($params),
'email.mailbox_set_quota' => emailMailboxSetQuota($params),
'email.mailbox_quota_usage' => emailMailboxGetQuotaUsage($params),
'email.mailbox_toggle' => emailMailboxToggle($params),
'email.sync_virtual_users' => emailSyncVirtualUsers($params),
'email.reload_services' => emailReloadServices($params),
'email.forwarder_create' => emailForwarderCreate($params),
'email.forwarder_delete' => emailForwarderDelete($params),
'email.forwarder_update' => emailForwarderUpdate($params),
'email.forwarder_toggle' => emailForwarderToggle($params),
'email.catchall_update' => emailCatchallUpdate($params),
'email.get_logs' => emailGetLogs($params),
'email.autoresponder_set' => emailAutoresponderSet($params),
'email.autoresponder_toggle' => emailAutoresponderToggle($params),
'email.autoresponder_delete' => emailAutoresponderDelete($params),
'email.hash_password' => emailHashPassword($params),
'service.list' => serviceList($params),
'service.start' => serviceStart($params),
'service.stop' => serviceStop($params),
'service.restart' => serviceRestart($params),
'service.enable' => serviceEnable($params),
'service.disable' => serviceDisable($params),
// Server Import operations
'import.discover' => importDiscover($params),
'import.start' => importStart($params),
// SSL Certificate operations
'ssl.check' => sslCheck($params),
'ssl.issue' => sslIssue($params),
'ssl.install' => sslInstall($params),
'ssl.renew' => sslRenew($params),
'ssl.generate_self_signed' => sslGenerateSelfSigned($params),
'ssl.delete' => sslDelete($params),
// Backup operations
'backup.create' => backupCreate($params),
'backup.create_server' => backupCreateServer($params),
'backup.incremental_direct' => backupServerIncrementalDirect($params),
'backup.restore' => backupRestore($params),
'backup.list' => backupList($params),
'backup.delete' => backupDelete($params),
'backup.delete_server' => backupDeleteServer($params),
'backup.verify' => backupVerify($params),
'backup.get_info' => backupGetInfo($params),
'backup.upload_remote' => backupUploadRemote($params),
'backup.download_remote' => backupDownloadRemote($params),
'backup.list_remote' => backupListRemote($params),
'backup.delete_remote' => backupDeleteRemote($params),
'backup.test_destination' => backupTestDestination($params),
'backup.download_user_archive' => backupDownloadUserArchive($params),
// cPanel migration operations
'cpanel.analyze_backup' => cpanelAnalyzeBackup($params),
'cpanel.restore_backup' => cpanelRestoreBackup($params),
'cpanel.fix_backup_permissions' => cpanelFixBackupPermissions($params),
// WHM migration operations
'whm.download_backup_scp' => whmDownloadBackupScp($params),
// Jabali system SSH key operations
'jabali_ssh.get_public_key' => jabaliSshGetPublicKey($params),
'jabali_ssh.get_private_key' => jabaliSshGetPrivateKey($params),
'jabali_ssh.ensure_exists' => jabaliSshEnsureExists($params),
'jabali_ssh.add_to_authorized_keys' => jabaliSshAddToAuthorizedKeys($params),
// Fail2ban operations
'fail2ban.status' => fail2banStatus($params),
'fail2ban.status_light' => fail2banStatusLight($params),
'fail2ban.install' => fail2banInstall($params),
'fail2ban.start' => fail2banStart($params),
'fail2ban.stop' => fail2banStop($params),
'fail2ban.restart' => fail2banRestart($params),
'fail2ban.save_settings' => fail2banSaveSettings($params),
'fail2ban.unban_ip' => fail2banUnbanIp($params),
'fail2ban.ban_ip' => fail2banBanIp($params),
'fail2ban.list_jails' => fail2banListJails($params),
'fail2ban.enable_jail' => fail2banEnableJail($params),
'fail2ban.disable_jail' => fail2banDisableJail($params),
// ClamAV operations
'clamav.status' => clamavStatus($params),
'clamav.status_light' => clamavStatusLight($params),
'clamav.install' => clamavInstall($params),
'clamav.start' => clamavStart($params),
'clamav.stop' => clamavStop($params),
'clamav.update_signatures' => clamavUpdateSignatures($params),
'clamav.scan' => clamavScan($params),
'clamav.realtime_start' => clamavRealtimeStart($params),
'clamav.realtime_stop' => clamavRealtimeStop($params),
'clamav.realtime_enable' => clamavRealtimeEnable($params),
'clamav.realtime_disable' => clamavRealtimeDisable($params),
'clamav.delete_quarantined' => clamavDeleteQuarantined($params),
'clamav.clear_threats' => clamavClearThreats($params),
'clamav.set_light_mode' => clamavSetLightMode($params),
'clamav.set_full_mode' => clamavSetFullMode($params),
'clamav.force_update_signatures' => clamavForceUpdateSignatures($params),
'ssh.get_settings' => sshGetSettings($params),
'ssh.save_settings' => sshSaveSettings($params),
// Cron job operations
'cron.list' => cronList($params),
'cron.create' => cronCreate($params),
'cron.delete' => cronDelete($params),
'cron.toggle' => cronToggle($params),
'cron.run' => cronRun($params),
'cron.wp_setup' => cronWordPressSetup($params),
// Server metrics operations
'metrics.overview' => metricsOverview($params),
'metrics.cpu' => metricsCpu($params),
'metrics.memory' => metricsMemory($params),
'metrics.disk' => metricsDisk($params),
'metrics.network' => metricsNetwork($params),
'metrics.processes' => metricsProcesses($params),
'metrics.history' => metricsHistory($params),
'system.kill_process' => systemKillProcess($params),
// Disk quota operations
'quota.status' => quotaStatus($params),
'quota.enable' => quotaEnable($params),
'quota.set' => quotaSet($params),
'quota.get' => quotaGet($params),
'quota.report' => quotaReport($params),
// IP address management
'ip.list' => ipList($params),
'ip.add' => ipAdd($params),
'ip.remove' => ipRemove($params),
'ip.info' => ipInfo($params),
// Security scanner tools
'scanner.install' => scannerInstall($params),
'scanner.uninstall' => scannerUninstall($params),
'scanner.status' => scannerStatus($params),
'scanner.run_lynis' => scannerRunLynis($params),
'scanner.run_nikto' => scannerRunNikto($params),
'scanner.start_lynis' => scannerStartLynis($params),
'scanner.get_scan_status' => scannerGetScanStatus($params),
// Log analysis
'logs.tail' => logsTail($params),
'logs.goaccess' => logsGoaccess($params),
// Redis ACL management
'redis.create_user' => redisCreateUser($params),
'redis.delete_user' => redisDeleteUser($params),
'redis.user_exists' => redisUserExists($params),
'redis.change_password' => redisChangePassword($params),
'redis.migrate_users' => redisMigrateUsers($params),
default => ['success' => false, 'error' => "Unknown action: $action"],
};
}
// ============ USER MANAGEMENT ============
function createUser(array $params): array
{
$username = $params['username'] ?? '';
$password = $params['password'] ?? null;
logger("Creating user: $username");
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username format'];
}
if (isProtectedUser($username)) {
return ['success' => false, 'error' => 'Cannot create protected system user'];
}
exec("id " . escapeshellarg($username) . " 2>/dev/null", $output, $exitCode);
if ($exitCode === 0) {
return ['success' => false, 'error' => 'User already exists'];
}
$homeDir = "/home/$username";
// Create user with nologin shell (SFTP-only by default)
$cmd = sprintf('useradd -m -d %s -s /usr/sbin/nologin %s 2>&1',
escapeshellarg($homeDir),
escapeshellarg($username)
);
exec($cmd, $output, $exitCode);
if ($exitCode !== 0) {
return ['success' => false, 'error' => 'Failed to create user: ' . implode("\n", $output)];
}
if ($password) {
$cmd = sprintf('echo %s:%s | chpasswd 2>&1',
escapeshellarg($username),
escapeshellarg($password)
);
exec($cmd);
}
// Remove symlinks that cause issues
@unlink("$homeDir/.face.icon");
@unlink("$homeDir/.face");
// Create standard directories (NO ACLs for www-data!)
$dirs = ['domains', 'logs', 'tmp', 'ssl', 'backups'];
foreach ($dirs as $dir) {
$path = "$homeDir/$dir";
if (!is_dir($path)) {
mkdir($path, 0755, true);
}
chown($path, $username);
chgrp($path, $username);
}
// Set up for secure SFTP chroot
exec("usermod -aG sftpusers " . escapeshellarg($username));
// Chroot requires root ownership of home directory
// Use user's group with 750 for complete isolation between users
chown($homeDir, "root");
chgrp($homeDir, $username); // User's own group - only this user can access
chmod($homeDir, 0750); // root=rwx, user's group=r-x, others=none
// Create PHP-FPM pool for the user (so it's ready when they create domains)
// Don't reload FPM here - caller is responsible for reloading after all operations complete
$fpmResult = createFpmPool($username, false);
$fpmPoolCreated = (bool) ($fpmResult['pool_created'] ?? false);
$fpmReloadRequired = $fpmPoolCreated && (bool) ($fpmResult['needs_reload'] ?? false);
// Create Redis ACL user for isolated caching
$redisPassword = bin2hex(random_bytes(16)); // 32 char password
$redisResult = redisCreateUser(['username' => $username, 'password' => $redisPassword]);
if ($redisResult['success']) {
// Store Redis credentials in user's home directory
$redisCredFile = "{$homeDir}/.redis_credentials";
$credContent = "REDIS_USER=jabali_{$username}\n" .
"REDIS_PASS={$redisPassword}\n" .
"REDIS_PREFIX={$username}:\n";
file_put_contents($redisCredFile, $credContent);
chmod($redisCredFile, 0600);
chown($redisCredFile, $username);
chgrp($redisCredFile, $username);
logger("Created Redis ACL user for $username");
} else {
logger("Warning: Failed to create Redis user for $username: " . ($redisResult['error'] ?? 'Unknown error'));
}
logger("Created user $username with home directory $homeDir");
return [
'success' => true,
'message' => "User $username created successfully",
'home_directory' => $homeDir,
'redis_user' => $redisResult['success'] ? "jabali_{$username}" : null,
'fpm_pool_created' => $fpmPoolCreated,
'fpm_reload_required' => $fpmReloadRequired,
];
}
function deleteUser(array $params): array
{
$username = $params['username'] ?? '';
$removeHome = $params['remove_home'] ?? false;
$domains = $params['domains'] ?? []; // List of user's domains to clean up
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username format'];
}
if (isProtectedUser($username)) {
return ['success' => false, 'error' => 'Cannot delete protected system user'];
}
// Check if user exists
exec("id " . escapeshellarg($username) . " 2>/dev/null", $idOutput, $idExit);
if ($idExit !== 0) {
return ['success' => false, 'error' => 'User does not exist'];
}
$homeDir = "/home/$username";
// Get domains from .domains file if not provided
if (empty($domains)) {
$domainsFile = "$homeDir/.domains";
if (file_exists($domainsFile)) {
$domainsData = json_decode(file_get_contents($domainsFile), true) ?: [];
$domains = array_keys($domainsData);
}
}
// Clean up domain-related files for each domain
foreach ($domains as $domain) {
if (!validateDomain($domain)) {
continue;
}
// Remove nginx vhost configs (with .conf extension)
$nginxAvailable = "/etc/nginx/sites-available/{$domain}.conf";
$nginxEnabled = "/etc/nginx/sites-enabled/{$domain}.conf";
// Also try without .conf for backwards compatibility
$nginxAvailableOld = "/etc/nginx/sites-available/$domain";
$nginxEnabledOld = "/etc/nginx/sites-enabled/$domain";
foreach ([$nginxEnabled, $nginxEnabledOld] as $file) {
if (file_exists($file) || is_link($file)) {
@unlink($file);
logger("Removed nginx symlink: $file");
}
}
foreach ([$nginxAvailable, $nginxAvailableOld] as $file) {
if (file_exists($file)) {
@unlink($file);
logger("Removed nginx config: $file");
}
}
// Remove DNS zone file
$zoneFile = "/etc/bind/zones/db.$domain";
if (file_exists($zoneFile)) {
@unlink($zoneFile);
logger("Removed DNS zone: $zoneFile");
// Remove from named.conf.local
$namedConf = '/etc/bind/named.conf.local';
if (file_exists($namedConf)) {
$content = file_get_contents($namedConf);
// Remove zone block for this domain (use [\s\S]*?\n\} to match nested braces)
$pattern = '/\n?zone\s+"' . preg_quote($domain, '/') . '"\s*\{[\s\S]*?\n\};\n?/';
$newContent = preg_replace($pattern, "\n", $content);
if ($newContent !== $content) {
file_put_contents($namedConf, $newContent);
logger("Removed zone from named.conf.local: $domain");
}
}
}
// Remove mail directories (both in home and /var/vmail)
$mailDir = "$homeDir/mail/$domain";
if (is_dir($mailDir)) {
exec("rm -rf " . escapeshellarg($mailDir));
logger("Removed mail directory: $mailDir");
}
$vmailDir = "/var/vmail/$domain";
if (is_dir($vmailDir)) {
exec("rm -rf " . escapeshellarg($vmailDir));
logger("Removed vmail directory: $vmailDir");
}
// Remove from Postfix virtual_mailbox_domains
$vdomainsFile = POSTFIX_VIRTUAL_DOMAINS;
if (file_exists($vdomainsFile)) {
$content = file_get_contents($vdomainsFile);
$lines = explode("\n", $content);
$lines = array_filter($lines, fn($line) => trim($line) !== $domain);
file_put_contents($vdomainsFile, implode("\n", $lines));
}
// Remove mailboxes from Postfix virtual_mailbox_maps
$vmailboxFile = POSTFIX_VIRTUAL_MAILBOXES;
if (file_exists($vmailboxFile)) {
$content = file_get_contents($vmailboxFile);
$lines = explode("\n", $content);
$lines = array_filter($lines, fn($line) => !str_contains($line, "@$domain"));
file_put_contents($vmailboxFile, implode("\n", $lines));
}
// Remove from Postfix virtual_alias_maps
$valiasFile = POSTFIX_VIRTUAL_ALIASES;
if (file_exists($valiasFile)) {
$content = file_get_contents($valiasFile);
$lines = explode("\n", $content);
$lines = array_filter($lines, fn($line) => !str_contains($line, "@$domain"));
file_put_contents($valiasFile, implode("\n", $lines));
}
// Remove SSL certificates (live, archive, and renewal)
$certPath = "/etc/letsencrypt/live/$domain";
$certArchive = "/etc/letsencrypt/archive/$domain";
$certRenewal = "/etc/letsencrypt/renewal/$domain.conf";
if (is_dir($certPath)) {
exec("rm -rf " . escapeshellarg($certPath));
logger("Removed SSL certificate: $certPath");
}
if (is_dir($certArchive)) {
exec("rm -rf " . escapeshellarg($certArchive));
logger("Removed SSL archive: $certArchive");
}
if (file_exists($certRenewal)) {
@unlink($certRenewal);
logger("Removed SSL renewal config: $certRenewal");
}
}
// Delete MySQL databases and users belonging to this user
$dbPrefix = $username . '_';
$mysqli = getMysqlConnection();
if ($mysqli) {
// Get all databases belonging to this user
$result = $mysqli->query("SHOW DATABASES LIKE '{$mysqli->real_escape_string($dbPrefix)}%'");
if ($result) {
while ($row = $result->fetch_row()) {
$dbName = $row[0];
// Double-check it starts with username_
if (strpos($dbName, $dbPrefix) === 0) {
$mysqli->query("DROP DATABASE IF EXISTS `{$mysqli->real_escape_string($dbName)}`");
logger("Deleted MySQL database: $dbName");
}
}
$result->free();
}
// Get all MySQL users belonging to this user
$result = $mysqli->query("SELECT User, Host FROM mysql.user WHERE User LIKE '{$mysqli->real_escape_string($dbPrefix)}%'");
if ($result) {
while ($row = $result->fetch_assoc()) {
$dbUser = $row['User'];
$dbHost = $row['Host'];
// Double-check it starts with username_
if (strpos($dbUser, $dbPrefix) === 0) {
$mysqli->query("DROP USER IF EXISTS '{$mysqli->real_escape_string($dbUser)}'@'{$mysqli->real_escape_string($dbHost)}'");
logger("Deleted MySQL user: $dbUser@$dbHost");
}
}
$result->free();
}
$mysqli->query("FLUSH PRIVILEGES");
$mysqli->close();
}
// Remove PHP-FPM pool config
foreach (glob("/etc/php/*/fpm/pool.d/$username.conf") as $poolConf) {
@unlink($poolConf);
logger("Removed PHP-FPM pool: $poolConf");
}
// Delete user with --force to ignore warnings about mail spool
// Don't use -r since home directory is owned by root for chroot
$cmd = sprintf('userdel --force %s 2>&1', escapeshellarg($username));
$userdelOutput = [];
exec($cmd, $userdelOutput, $userdelExit);
// Verify user was actually deleted (userdel may return non-zero for warnings)
exec("id " . escapeshellarg($username) . " 2>/dev/null", $checkOutput, $checkExit);
if ($checkExit === 0) {
// User still exists - deletion actually failed
return ['success' => false, 'error' => 'Failed to delete user: ' . implode("\n", $userdelOutput)];
}
// Delete Redis ACL user (and all their cached keys)
$redisResult = redisDeleteUser(['username' => $username]);
if (!$redisResult['success']) {
logger("Warning: Failed to delete Redis user for $username: " . ($redisResult['error'] ?? 'Unknown error'));
} else {
logger("Deleted Redis ACL user for $username");
}
// Manually remove home directory if requested (since it's owned by root)
if ($removeHome && is_dir($homeDir)) {
exec(sprintf('rm -rf %s 2>&1', escapeshellarg($homeDir)), $rmOutput, $rmExit);
if ($rmExit !== 0) {
logger("Warning: Failed to remove home directory for $username");
}
}
// Reload services
exec('postmap ' . escapeshellarg(POSTFIX_VIRTUAL_DOMAINS) . ' 2>/dev/null');
exec('postmap ' . escapeshellarg(POSTFIX_VIRTUAL_MAILBOXES) . ' 2>/dev/null');
exec('postmap ' . escapeshellarg(POSTFIX_VIRTUAL_ALIASES) . ' 2>/dev/null');
exec('systemctl reload nginx 2>/dev/null');
exec('rndc reload 2>/dev/null');
exec('systemctl reload php*-fpm 2>/dev/null');
logger("Deleted user $username" . ($removeHome ? " with home directory" : "") . " and cleaned up " . count($domains) . " domain(s)");
return ['success' => true, 'message' => "User $username deleted successfully"];
}
function setUserPassword(array $params): array
{
$username = $params['username'] ?? '';
$password = $params['password'] ?? '';
if (!validateUsername($username) || empty($password)) {
return ['success' => false, 'error' => 'Invalid username or password'];
}
exec("id " . escapeshellarg($username) . " 2>/dev/null", $output, $exitCode);
if ($exitCode !== 0) {
return ['success' => false, 'error' => 'User does not exist'];
}
$cmd = sprintf('echo %s:%s | chpasswd 2>&1', escapeshellarg($username), escapeshellarg($password));
exec($cmd, $output, $exitCode);
return $exitCode === 0
? ['success' => true, 'message' => 'Password updated']
: ['success' => false, 'error' => 'Failed to set password'];
}
function userExists(array $params): array
{
$username = $params['username'] ?? '';
if (!validateUsername($username)) {
return ['success' => true, 'exists' => false];
}
exec("id " . escapeshellarg($username) . " 2>/dev/null", $output, $exitCode);
return ['success' => true, 'exists' => $exitCode === 0];
}
// ============ PHP-FPM POOL MANAGEMENT ============
function getFpmSocketPath(string $username): string
{
$phpVersion = '8.4';
return "/run/php/php{$phpVersion}-fpm-{$username}.sock";
}
function generateNginxVhost(string $domain, string $publicHtml, string $logs, string $fpmSocket): string
{
$config = <<<'NGINXCONF'
server {
listen 80;
listen [::]:80;
server_name DOMAIN_PLACEHOLDER www.DOMAIN_PLACEHOLDER;
root DOCROOT_PLACEHOLDER;
# Allow ACME challenge for SSL certificate issuance/renewal
location /.well-known/acme-challenge/ {
try_files $uri =404;
}
# Redirect all other HTTP traffic to HTTPS
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name DOMAIN_PLACEHOLDER www.DOMAIN_PLACEHOLDER;
root DOCROOT_PLACEHOLDER;
# Symlink protection - prevent following symlinks outside document root
disable_symlinks if_not_owner from=$document_root;
ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem;
ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key;
index index.php index.html;
client_max_body_size 50M;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:SOCKET_PLACEHOLDER;
fastcgi_next_upstream error timeout invalid_header http_500 http_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\n
Welcome to $domain\nWelcome to $domain
\n";
file_put_contents("$publicDir/index.html", $indexContent);
chown("$publicDir/index.html", $username);
chgrp("$publicDir/index.html", $username);
logger("Created domain $domain for user $username");
return [
'success' => true,
'message' => "Domain $domain created",
'domain_path' => $domainDir,
'public_path' => $publicDir,
];
}
function deleteDomain(array $params): array
{
$username = $params['username'] ?? '';
$domain = $params['domain'] ?? '';
if (!validateUsername($username) || !validateDomain($domain)) {
return ['success' => false, 'error' => 'Invalid username or domain format'];
}
$domainDir = "/home/$username/domains/$domain";
if (!is_dir($domainDir)) {
return ['success' => false, 'error' => 'Domain directory does not exist'];
}
exec(sprintf('rm -rf %s 2>&1', escapeshellarg($domainDir)), $output, $exitCode);
if ($exitCode !== 0) {
return ['success' => false, 'error' => 'Failed to delete domain'];
}
logger("Deleted domain $domain for user $username");
return ['success' => true, 'message' => "Domain $domain deleted"];
}
// ============ FILE OPERATIONS ============
function fileList(array $params): array
{
$username = $params['username'] ?? '';
$path = $params['path'] ?? '';
$showHidden = (bool) ($params['show_hidden'] ?? false);
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$fullPath = validateUserPath($username, $path);
if ($fullPath === null) {
return ['success' => false, 'error' => 'Invalid path'];
}
if (!is_dir($fullPath)) {
return ['success' => false, 'error' => 'Directory not found'];
}
$items = [];
$entries = @scandir($fullPath);
if ($entries === false) {
return ['success' => false, 'error' => 'Cannot read directory'];
}
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') continue;
// Hide hidden files unless show_hidden is true (always hide .trash)
if (str_starts_with($entry, '.') && (!$showHidden || $entry === '.trash')) continue;
$itemPath = "$fullPath/$entry";
$stat = @stat($itemPath);
if ($stat === false) continue;
// Skip symlinks
if (is_link($itemPath)) continue;
$items[] = [
'name' => $entry,
'path' => ltrim(str_replace("/home/$username", '', $itemPath), '/'),
'is_dir' => is_dir($itemPath),
'size' => is_file($itemPath) ? $stat['size'] : null,
'modified' => $stat['mtime'],
'permissions' => substr(sprintf('%o', $stat['mode']), -4),
];
}
// Sort: directories first, then alphabetically
usort($items, function ($a, $b) {
if ($a['is_dir'] !== $b['is_dir']) {
return $b['is_dir'] <=> $a['is_dir'];
}
return strcasecmp($a['name'], $b['name']);
});
return ['success' => true, 'items' => $items, 'path' => $path];
}
function fileRead(array $params): array
{
$username = $params['username'] ?? '';
$path = $params['path'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$fullPath = validateUserPath($username, $path);
if ($fullPath === null || !is_file($fullPath)) {
return ['success' => false, 'error' => 'File not found'];
}
// Limit file size for reading
$maxSize = 50 * 1024 * 1024; // 5MB
if (filesize($fullPath) > $maxSize) {
return ['success' => false, 'error' => 'File too large to read'];
}
$content = @file_get_contents($fullPath);
if ($content === false) {
return ['success' => false, 'error' => 'Cannot read file'];
}
return [
'success' => true,
'content' => base64_encode($content),
'encoding' => 'base64',
'size' => strlen($content),
];
}
function fileWrite(array $params): array
{
$username = $params['username'] ?? '';
$path = $params['path'] ?? '';
$content = $params['content'] ?? '';
$encoding = $params['encoding'] ?? 'plain';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$fullPath = validateUserPath($username, $path);
if ($fullPath === null) {
return ['success' => false, 'error' => 'Invalid path'];
}
// Decode content if base64
if ($encoding === 'base64') {
$content = base64_decode($content, true);
if ($content === false) {
return ['success' => false, 'error' => 'Invalid base64 content'];
}
}
// Ensure parent directory exists
$parentDir = dirname($fullPath);
if (!is_dir($parentDir)) {
return ['success' => false, 'error' => 'Parent directory does not exist'];
}
if (@file_put_contents($fullPath, $content) === false) {
return ['success' => false, 'error' => 'Cannot write file'];
}
chown($fullPath, $username);
chgrp($fullPath, $username);
chmod($fullPath, 0644);
logger("File written: $fullPath for user $username");
return ['success' => true, 'message' => 'File saved', 'size' => strlen($content)];
}
function fileDelete(array $params): array
{
$username = $params['username'] ?? '';
$path = $params['path'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$fullPath = validateUserPath($username, $path);
if ($fullPath === null) {
return ['success' => false, 'error' => 'Invalid path'];
}
// Don't allow deleting the home directory itself or critical folders
$homeDir = "/home/$username";
$protected = [$homeDir, "$homeDir/domains", "$homeDir/logs", "$homeDir/ssl", "$homeDir/backups", "$homeDir/tmp"];
if (in_array($fullPath, $protected)) {
return ['success' => false, 'error' => 'Cannot delete protected directory'];
}
if (is_dir($fullPath)) {
exec(sprintf('rm -rf %s 2>&1', escapeshellarg($fullPath)), $output, $exitCode);
} else {
$exitCode = @unlink($fullPath) ? 0 : 1;
}
if ($exitCode !== 0) {
return ['success' => false, 'error' => 'Cannot delete'];
}
logger("Deleted: $fullPath for user $username");
return ['success' => true, 'message' => 'Deleted successfully'];
}
function fileMkdir(array $params): array
{
$username = $params['username'] ?? '';
$path = $params['path'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$fullPath = validateUserPath($username, $path);
if ($fullPath === null) {
return ['success' => false, 'error' => 'Invalid path'];
}
if (file_exists($fullPath)) {
return ['success' => false, 'error' => 'Path already exists'];
}
if (!@mkdir($fullPath, 0755, true)) {
return ['success' => false, 'error' => 'Cannot create directory'];
}
chown($fullPath, $username);
chgrp($fullPath, $username);
logger("Directory created: $fullPath for user $username");
return ['success' => true, 'message' => 'Directory created'];
}
function fileRename(array $params): array
{
$username = $params['username'] ?? '';
$path = $params['path'] ?? '';
$newName = $params['new_name'] ?? '';
if (!validateUsername($username) || empty($newName)) {
return ['success' => false, 'error' => 'Invalid parameters'];
}
// Validate new name doesn't contain path separators
if (str_contains($newName, '/') || str_contains($newName, '..')) {
return ['success' => false, 'error' => 'Invalid new name'];
}
$fullPath = validateUserPath($username, $path);
if ($fullPath === null || !file_exists($fullPath)) {
return ['success' => false, 'error' => 'File not found'];
}
$newPath = dirname($fullPath) . '/' . $newName;
if (file_exists($newPath)) {
return ['success' => false, 'error' => 'Target already exists'];
}
if (!@rename($fullPath, $newPath)) {
return ['success' => false, 'error' => 'Cannot rename'];
}
logger("Renamed: $fullPath to $newPath for user $username");
return ['success' => true, 'message' => 'Renamed successfully'];
}
function fileMove(array $params): array
{
$username = $params['username'] ?? '';
$path = $params['path'] ?? '';
$destination = $params['destination'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$fullPath = validateUserPath($username, $path);
$destPath = validateUserPath($username, $destination);
if ($fullPath === null || $destPath === null) {
return ['success' => false, 'error' => 'Invalid path'];
}
if (!file_exists($fullPath)) {
return ['success' => false, 'error' => 'Source not found'];
}
// If destination is a directory, move into it
if (is_dir($destPath)) {
$destPath = $destPath . '/' . basename($fullPath);
}
if (file_exists($destPath)) {
return ['success' => false, 'error' => 'Destination already exists'];
}
if (!@rename($fullPath, $destPath)) {
return ['success' => false, 'error' => 'Cannot move'];
}
logger("Moved: $fullPath to $destPath for user $username");
return ['success' => true, 'message' => 'Moved successfully'];
}
function fileCopy(array $params): array
{
$username = $params['username'] ?? '';
$path = $params['path'] ?? '';
$destination = $params['destination'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$fullPath = validateUserPath($username, $path);
$destPath = validateUserPath($username, $destination);
if ($fullPath === null || $destPath === null) {
return ['success' => false, 'error' => 'Invalid path'];
}
if (!file_exists($fullPath)) {
return ['success' => false, 'error' => 'Source not found'];
}
if (is_dir($destPath)) {
$destPath = $destPath . '/' . basename($fullPath);
}
if (file_exists($destPath)) {
return ['success' => false, 'error' => 'Destination already exists'];
}
if (is_dir($fullPath)) {
exec(sprintf('cp -r %s %s 2>&1', escapeshellarg($fullPath), escapeshellarg($destPath)), $output, $exitCode);
if ($exitCode !== 0) {
return ['success' => false, 'error' => 'Cannot copy directory'];
}
exec(sprintf('chown -R %s:%s %s', escapeshellarg($username), escapeshellarg($username), escapeshellarg($destPath)));
} else {
if (!@copy($fullPath, $destPath)) {
return ['success' => false, 'error' => 'Cannot copy file'];
}
chown($destPath, $username);
chgrp($destPath, $username);
}
logger("Copied: $fullPath to $destPath for user $username");
return ['success' => true, 'message' => 'Copied successfully'];
}
function fileUpload(array $params): array
{
$username = $params['username'] ?? '';
$path = $params['path'] ?? '';
$filename = $params['filename'] ?? '';
$content = $params['content'] ?? '';
if (!validateUsername($username) || empty($filename)) {
return ['success' => false, 'error' => 'Invalid parameters'];
}
// Sanitize filename
$filename = basename($filename);
$filename = preg_replace('/[^a-zA-Z0-9._-]/', '_', $filename);
if (empty($filename)) {
return ['success' => false, 'error' => 'Invalid filename'];
}
$dirPath = validateUserPath($username, $path);
if ($dirPath === null || !is_dir($dirPath)) {
return ['success' => false, 'error' => 'Invalid directory'];
}
$fullPath = "$dirPath/$filename";
// Decode base64 content
$decoded = base64_decode($content, true);
if ($decoded === false) {
return ['success' => false, 'error' => 'Invalid file content'];
}
// Limit upload size (50MB)
if (strlen($decoded) > 50 * 1024 * 1024) {
return ['success' => false, 'error' => 'File too large'];
}
if (@file_put_contents($fullPath, $decoded) === false) {
return ['success' => false, 'error' => 'Cannot save file'];
}
chown($fullPath, $username);
chgrp($fullPath, $username);
chmod($fullPath, 0644);
logger("Uploaded: $fullPath for user $username (" . strlen($decoded) . " bytes)");
return ['success' => true, 'message' => 'File uploaded', 'path' => ltrim(str_replace("/home/$username", '', $fullPath), '/')];
}
/**
* Upload large files by moving from temp location.
* This avoids JSON encoding issues with large binary content.
*/
function fileUploadTemp(array $params): array
{
$username = $params['username'] ?? '';
$path = $params['path'] ?? '';
$filename = $params['filename'] ?? '';
$tempPath = $params['temp_path'] ?? '';
if (!validateUsername($username) || empty($filename) || empty($tempPath)) {
return ['success' => false, 'error' => 'Invalid parameters'];
}
// Validate temp path is in allowed temp directory
$allowedTempDir = '/tmp/jabali-uploads/';
$realTempPath = realpath($tempPath);
$allowedPrefix = rtrim($allowedTempDir, '/') . '/';
if ($realTempPath === false || !str_starts_with($realTempPath, $allowedPrefix)) {
logger("Invalid temp path: $tempPath (real: $realTempPath)", 'ERROR');
return ['success' => false, 'error' => 'Invalid temp file path'];
}
if (!file_exists($realTempPath)) {
return ['success' => false, 'error' => 'Temp file not found'];
}
// Sanitize filename
$filename = basename($filename);
$filename = preg_replace('/[^a-zA-Z0-9._-]/', '_', $filename);
if (empty($filename)) {
@unlink($realTempPath);
return ['success' => false, 'error' => 'Invalid filename'];
}
$dirPath = validateUserPath($username, $path);
if ($dirPath === null || !is_dir($dirPath)) {
@unlink($realTempPath);
return ['success' => false, 'error' => 'Invalid directory'];
}
$fullPath = "$dirPath/$filename";
// Get file size for logging
$fileSize = filesize($realTempPath);
// Limit upload size (500MB for temp uploads)
if ($fileSize > 500 * 1024 * 1024) {
@unlink($realTempPath);
return ['success' => false, 'error' => 'File too large (max 500MB)'];
}
// Move temp file to destination
if (!@rename($realTempPath, $fullPath)) {
// If rename fails (cross-device), try copy+delete
if (!@copy($realTempPath, $fullPath)) {
@unlink($realTempPath);
return ['success' => false, 'error' => 'Cannot move file to destination'];
}
@unlink($realTempPath);
}
chown($fullPath, $username);
chgrp($fullPath, $username);
chmod($fullPath, 0644);
logger("Uploaded (temp): $fullPath for user $username ($fileSize bytes)");
return ['success' => true, 'message' => 'File uploaded', 'path' => ltrim(str_replace("/home/$username", '', $fullPath), '/')];
}
function fileDownload(array $params): array
{
return fileRead($params); // Same as read, just different context
}
function fileExists(array $params): array
{
$username = $params['username'] ?? '';
$path = $params['path'] ?? '';
if (!validateUsername($username)) {
return ['success' => true, 'exists' => false];
}
$fullPath = validateUserPath($username, $path);
return ['success' => true, 'exists' => $fullPath !== null && file_exists($fullPath)];
}
function fileInfo(array $params): array
{
$username = $params['username'] ?? '';
$path = $params['path'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$fullPath = validateUserPath($username, $path);
if ($fullPath === null || !file_exists($fullPath)) {
return ['success' => false, 'error' => 'File not found'];
}
$stat = stat($fullPath);
return [
'success' => true,
'info' => [
'name' => basename($fullPath),
'path' => $path,
'is_dir' => is_dir($fullPath),
'is_file' => is_file($fullPath),
'size' => $stat['size'],
'modified' => $stat['mtime'],
'permissions' => substr(sprintf('%o', $stat['mode']), -4),
'owner' => posix_getpwuid($stat['uid'])['name'] ?? $stat['uid'],
'group' => posix_getgrgid($stat['gid'])['name'] ?? $stat['gid'],
],
];
}
function fileExtract(array $params): array
{
$username = $params['username'] ?? '';
$path = $params['path'] ?? '';
if (!validateUsername($username) || isProtectedUser($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$fullPath = validateUserPath($username, $path);
if ($fullPath === null) {
return ['success' => false, 'error' => 'Invalid path'];
}
if (!file_exists($fullPath)) {
return ['success' => false, 'error' => 'File not found'];
}
$ext = strtolower(pathinfo($fullPath, PATHINFO_EXTENSION));
$filename = basename($fullPath);
$destDir = dirname($fullPath);
$output = [];
$returnCode = 0;
// Handle .tar.gz, .tar.bz2, .tar.xz
if (preg_match('/\.tar\.(gz|bz2|xz)$/i', $filename)) {
$flag = match(strtolower(pathinfo($filename, PATHINFO_EXTENSION))) {
'gz' => 'z',
'bz2' => 'j',
'xz' => 'J',
default => ''
};
exec("cd " . escapeshellarg($destDir) . " && tar -x{$flag}f " . escapeshellarg($fullPath) . " 2>&1", $output, $returnCode);
} else {
switch ($ext) {
case 'zip':
exec("cd " . escapeshellarg($destDir) . " && unzip -o " . escapeshellarg($fullPath) . " 2>&1", $output, $returnCode);
break;
case 'gz':
exec("cd " . escapeshellarg($destDir) . " && pigz -dk " . escapeshellarg($fullPath) . " 2>&1", $output, $returnCode);
break;
case 'tgz':
exec("cd " . escapeshellarg($destDir) . " && tar -I pigz -xf " . escapeshellarg($fullPath) . " 2>&1", $output, $returnCode);
break;
case 'tar':
exec("cd " . escapeshellarg($destDir) . " && tar -xf " . escapeshellarg($fullPath) . " 2>&1", $output, $returnCode);
break;
case 'bz2':
exec("cd " . escapeshellarg($destDir) . " && bunzip2 -k " . escapeshellarg($fullPath) . " 2>&1", $output, $returnCode);
break;
case 'xz':
exec("cd " . escapeshellarg($destDir) . " && unxz -k " . escapeshellarg($fullPath) . " 2>&1", $output, $returnCode);
break;
case 'rar':
exec("cd " . escapeshellarg($destDir) . " && unrar x -o+ " . escapeshellarg($fullPath) . " 2>&1", $output, $returnCode);
break;
case '7z':
exec("cd " . escapeshellarg($destDir) . " && 7z x -y " . escapeshellarg($fullPath) . " 2>&1", $output, $returnCode);
break;
default:
return ['success' => false, 'error' => 'Unsupported archive format'];
}
}
if ($returnCode !== 0) {
return ['success' => false, 'error' => 'Extract failed: ' . implode("\n", $output)];
}
// Fix ownership of extracted files
exec("chown -R " . escapeshellarg($username) . ":" . escapeshellarg($username) . " " . escapeshellarg($destDir));
logger("Extracted: $fullPath for user $username");
return ['success' => true, 'message' => 'Archive extracted successfully'];
}
function fileChmod(array $params): array
{
$username = $params['username'] ?? '';
$path = $params['path'] ?? '';
$mode = $params['mode'] ?? '';
if (!validateUsername($username) || isProtectedUser($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$fullPath = validateUserPath($username, $path);
if ($fullPath === null) {
return ['success' => false, 'error' => 'Invalid path'];
}
if (!file_exists($fullPath)) {
return ['success' => false, 'error' => 'File not found'];
}
// Validate mode - must be octal string like "755" or "0755"
$mode = ltrim($mode, '0');
if (!preg_match('/^[0-7]{3,4}$/', $mode)) {
return ['success' => false, 'error' => 'Invalid permission mode'];
}
$octalMode = octdec($mode);
// Safety: Don't allow setuid/setgid bits for regular users
$octalMode = $octalMode & 0777;
if (!@chmod($fullPath, $octalMode)) {
return ['success' => false, 'error' => 'Failed to change permissions'];
}
logger("Chmod: $fullPath to $mode for user $username");
return ['success' => true, 'message' => 'Permissions changed successfully', 'mode' => sprintf('%o', $octalMode)];
}
function fileChown(array $params): array
{
$path = $params['path'] ?? '';
$owner = $params['owner'] ?? '';
$group = $params['group'] ?? '';
if (empty($path)) {
return ['success' => false, 'error' => 'Path is required'];
}
if (empty($owner) && empty($group)) {
return ['success' => false, 'error' => 'Owner or group is required'];
}
// Security: Only allow chown in specific directories
$allowedPrefixes = [
'/var/backups/jabali/',
'/home/',
];
$realPath = realpath($path);
if ($realPath === false) {
return ['success' => false, 'error' => 'Path does not exist'];
}
$allowed = false;
foreach ($allowedPrefixes as $prefix) {
if (strpos($realPath, $prefix) === 0) {
$allowed = true;
break;
}
}
if (!$allowed) {
logger("Security: Blocked chown attempt on $realPath", 'WARNING');
return ['success' => false, 'error' => 'Path not in allowed directories'];
}
// Change owner
if (!empty($owner)) {
if (!@chown($realPath, $owner)) {
return ['success' => false, 'error' => 'Failed to change owner'];
}
}
// Change group
if (!empty($group)) {
if (!@chgrp($realPath, $group)) {
return ['success' => false, 'error' => 'Failed to change group'];
}
}
logger("Chown: $realPath to $owner:$group");
return ['success' => true, 'message' => 'Ownership changed successfully'];
}
function fileTrash(array $params): array
{
$username = $params['username'] ?? '';
$path = $params['path'] ?? '';
if (!validateUsername($username) || isProtectedUser($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$fullPath = validateUserPath($username, $path);
if ($fullPath === null) {
return ['success' => false, 'error' => 'Invalid path'];
}
if (!file_exists($fullPath)) {
return ['success' => false, 'error' => 'File not found'];
}
// Create trash directory if it doesn't exist
$trashDir = "/home/$username/.trash";
if (!is_dir($trashDir)) {
mkdir($trashDir, 0755, true);
chown($trashDir, $username);
chgrp($trashDir, $username);
}
// Generate unique name to avoid conflicts
$basename = basename($fullPath);
$timestamp = date('Y-m-d_His');
$trashName = "{$basename}.{$timestamp}";
$trashPath = "$trashDir/$trashName";
// Store original path in metadata file for potential restore
$metaFile = "$trashPath.meta";
if (!@rename($fullPath, $trashPath)) {
return ['success' => false, 'error' => 'Failed to move to trash'];
}
// Save metadata
file_put_contents($metaFile, json_encode([
'original_path' => str_replace("/home/$username/", '', $path),
'trashed_at' => time(),
'name' => $basename,
]));
chown($metaFile, $username);
chgrp($metaFile, $username);
logger("Trashed: $fullPath to $trashPath for user $username");
return ['success' => true, 'message' => 'Moved to trash', 'trash_path' => ".trash/$trashName"];
}
function fileRestore(array $params): array
{
$username = $params['username'] ?? '';
$trashName = $params['trash_name'] ?? '';
if (!validateUsername($username) || isProtectedUser($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$trashDir = "/home/$username/.trash";
$trashPath = "$trashDir/$trashName";
$metaFile = "$trashPath.meta";
if (!file_exists($trashPath)) {
return ['success' => false, 'error' => 'Item not found in trash'];
}
// Try to get original path from metadata
$originalPath = null;
if (file_exists($metaFile)) {
$meta = json_decode(file_get_contents($metaFile), true);
$originalPath = $meta['original_path'] ?? null;
}
if (!$originalPath) {
// Fall back to home directory with original name (strip timestamp)
$basename = preg_replace('/\.\d{4}-\d{2}-\d{2}_\d{6}$/', '', $trashName);
$originalPath = $basename;
}
$fullDestPath = "/home/$username/$originalPath";
// If destination exists, add suffix
if (file_exists($fullDestPath)) {
$pathInfo = pathinfo($fullDestPath);
$counter = 1;
do {
$newName = $pathInfo['filename'] . "_restored_$counter";
if (isset($pathInfo['extension'])) {
$newName .= '.' . $pathInfo['extension'];
}
$fullDestPath = $pathInfo['dirname'] . '/' . $newName;
$counter++;
} while (file_exists($fullDestPath));
}
// Ensure destination directory exists
$destDir = dirname($fullDestPath);
if (!is_dir($destDir)) {
mkdir($destDir, 0755, true);
chown($destDir, $username);
chgrp($destDir, $username);
}
if (!@rename($trashPath, $fullDestPath)) {
return ['success' => false, 'error' => 'Failed to restore from trash'];
}
// Remove metadata file
@unlink($metaFile);
logger("Restored: $trashPath to $fullDestPath for user $username");
return ['success' => true, 'message' => 'Restored from trash', 'restored_path' => str_replace("/home/$username/", '', $fullDestPath)];
}
function fileEmptyTrash(array $params): array
{
$username = $params['username'] ?? '';
if (!validateUsername($username) || isProtectedUser($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$trashDir = "/home/$username/.trash";
if (!is_dir($trashDir)) {
return ['success' => true, 'message' => 'Trash is already empty', 'deleted' => 0];
}
$deleted = 0;
$items = scandir($trashDir);
foreach ($items as $item) {
if ($item === '.' || $item === '..') continue;
$itemPath = "$trashDir/$item";
if (is_dir($itemPath)) {
exec("rm -rf " . escapeshellarg($itemPath));
} else {
@unlink($itemPath);
}
$deleted++;
}
logger("Emptied trash for user $username ($deleted items)");
return ['success' => true, 'message' => 'Trash emptied', 'deleted' => $deleted];
}
function fileListTrash(array $params): array
{
$username = $params['username'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$trashDir = "/home/$username/.trash";
if (!is_dir($trashDir)) {
return ['success' => true, 'items' => []];
}
$items = [];
$files = scandir($trashDir);
foreach ($files as $file) {
if ($file === '.' || $file === '..' || str_ends_with($file, '.meta')) continue;
$fullPath = "$trashDir/$file";
$stat = @stat($fullPath);
if (!$stat) continue;
$meta = null;
$metaFile = "$fullPath.meta";
if (file_exists($metaFile)) {
$meta = json_decode(file_get_contents($metaFile), true);
}
$items[] = [
'trash_name' => $file,
'name' => $meta['name'] ?? preg_replace('/\.\d{4}-\d{2}-\d{2}_\d{6}$/', '', $file),
'original_path' => $meta['original_path'] ?? null,
'trashed_at' => $meta['trashed_at'] ?? $stat['mtime'],
'is_dir' => is_dir($fullPath),
'size' => is_dir($fullPath) ? 0 : $stat['size'],
];
}
// Sort by trashed_at descending (most recent first)
usort($items, fn($a, $b) => $b['trashed_at'] <=> $a['trashed_at']);
return ['success' => true, 'items' => $items];
}
function mysqlCreateMasterUser(array $params): array
{
$username = $params["username"] ?? "";
if (!validateUsername($username)) {
return ["success" => false, "error" => "Invalid username"];
}
$conn = getMysqlConnection();
if (!$conn) {
return ["success" => false, "error" => "Cannot connect to MySQL"];
}
// Create master user with pattern like: user1_admin
$masterUser = $username . "_admin";
$password = bin2hex(random_bytes(16));
// Drop if exists
$conn->query("DROP USER IF EXISTS '{$masterUser}'@'localhost'");
// Create user
$escapedPassword = $conn->real_escape_string($password);
if (!$conn->query("CREATE USER '{$masterUser}'@'localhost' IDENTIFIED BY '{$escapedPassword}'")) {
$conn->close();
return ["success" => false, "error" => "Failed to create master user: " . $conn->error];
}
// Grant privileges to all user's databases using wildcard
$pattern = $username . "\\_%";
if (!$conn->query("GRANT ALL PRIVILEGES ON `{$pattern}`.* TO '{$masterUser}'@'localhost'")) {
$conn->close();
return ["success" => false, "error" => "Failed to grant privileges: " . $conn->error];
}
$conn->query("FLUSH PRIVILEGES");
$conn->close();
return [
"success" => true,
"master_user" => $masterUser,
"password" => $password,
"message" => "Master MySQL user created with access to all {$username}_* databases"
];
}
function mysqlImportDatabase(array $params): array
{
$username = $params["username"] ?? "";
$database = $params["database"] ?? "";
$sqlFile = $params["sql_file"] ?? "";
if (!validateUsername($username)) {
return ["success" => false, "error" => "Invalid username"];
}
if (!file_exists($sqlFile) || !is_readable($sqlFile)) {
return ["success" => false, "error" => "SQL file not found or not readable"];
}
// Ensure database belongs to user
$prefix = $username . "_";
if (strpos($database, $prefix) !== 0) {
return ["success" => false, "error" => "Database does not belong to user"];
}
// Get MySQL root credentials
$mysqlRoot = getMysqlRootCredentials();
if (!$mysqlRoot) {
return ["success" => false, "error" => "Cannot get MySQL credentials"];
}
// Detect file type by extension
$extension = strtolower(pathinfo($sqlFile, PATHINFO_EXTENSION));
$filename = strtolower(basename($sqlFile));
// Import using mysql command with socket authentication
// Redirect stderr to a temp file to avoid mixing with stdin
$errFile = tempnam('/tmp', 'mysql_err_');
if ($extension === 'gz' || str_ends_with($filename, '.sql.gz')) {
// Gzipped SQL file - decompress and pipe to mysql
$cmd = sprintf(
'pigz -dc %s | mysql --defaults-file=/etc/mysql/debian.cnf %s 2>%s',
escapeshellarg($sqlFile),
escapeshellarg($database),
escapeshellarg($errFile)
);
} elseif ($extension === 'zip') {
// ZIP file - extract first file and pipe to mysql
$cmd = sprintf(
'unzip -p %s | mysql --defaults-file=/etc/mysql/debian.cnf %s 2>%s',
escapeshellarg($sqlFile),
escapeshellarg($database),
escapeshellarg($errFile)
);
} else {
// Plain SQL file
$cmd = sprintf(
'mysql --defaults-file=/etc/mysql/debian.cnf %s < %s 2>%s',
escapeshellarg($database),
escapeshellarg($sqlFile),
escapeshellarg($errFile)
);
}
exec($cmd, $output, $returnCode);
// Read stderr if command failed
if ($returnCode !== 0) {
$stderr = file_get_contents($errFile);
@unlink($errFile);
return ["success" => false, "error" => "Import failed: " . trim($stderr)];
}
@unlink($errFile);
logger("Database imported: $database from $sqlFile for user $username");
return ["success" => true, "message" => "Database imported successfully"];
}
function mysqlExportDatabase(array $params): array
{
$username = $params["username"] ?? "";
$database = $params["database"] ?? "";
$outputFile = $params["output_file"] ?? "";
$compress = $params["compress"] ?? "gz"; // "gz", "zip", or "none"
if (!validateUsername($username)) {
return ["success" => false, "error" => "Invalid username"];
}
// Ensure database belongs to user
$prefix = $username . "_";
if (strpos($database, $prefix) !== 0) {
return ["success" => false, "error" => "Database does not belong to user"];
}
// Get MySQL root credentials
$mysqlRoot = getMysqlRootCredentials();
if (!$mysqlRoot) {
return ["success" => false, "error" => "Cannot get MySQL credentials"];
}
// Ensure output directory exists
$outputDir = dirname($outputFile);
if (!is_dir($outputDir)) {
mkdir($outputDir, 0755, true);
}
// Export using mysqldump with socket authentication
$errFile = tempnam('/tmp', 'mysqldump_err_');
if ($compress === "gz") {
// Pipe through pigz (parallel gzip) for compression
$cmd = sprintf(
'mysqldump --defaults-file=/etc/mysql/debian.cnf --single-transaction --routines --triggers %s 2>%s | pigz > %s',
escapeshellarg($database),
escapeshellarg($errFile),
escapeshellarg($outputFile)
);
} elseif ($compress === "zip") {
// Export to temp SQL file, then zip
$tempSql = tempnam('/tmp', 'mysqldump_') . '.sql';
$cmd = sprintf(
'mysqldump --defaults-file=/etc/mysql/debian.cnf --single-transaction --routines --triggers %s > %s 2>%s && zip -j %s %s && rm -f %s',
escapeshellarg($database),
escapeshellarg($tempSql),
escapeshellarg($errFile),
escapeshellarg($outputFile),
escapeshellarg($tempSql),
escapeshellarg($tempSql)
);
} else {
// No compression
$cmd = sprintf(
'mysqldump --defaults-file=/etc/mysql/debian.cnf --single-transaction --routines --triggers %s > %s 2>%s',
escapeshellarg($database),
escapeshellarg($outputFile),
escapeshellarg($errFile)
);
}
exec($cmd, $output, $returnCode);
if ($returnCode !== 0) {
$stderr = file_get_contents($errFile);
@unlink($errFile);
return ["success" => false, "error" => "Export failed: " . trim($stderr)];
}
@unlink($errFile);
// Set ownership to user
chown($outputFile, $username);
chgrp($outputFile, $username);
logger("Database exported: $database to $outputFile (compress: $compress) for user $username");
return ["success" => true, "output_file" => $outputFile, "message" => "Database exported successfully"];
}
// ============ SERVICE MANAGEMENT ============
function shouldReloadService(string $service): bool
{
$normalized = $service;
if (str_ends_with($normalized, '.service')) {
$normalized = substr($normalized, 0, -strlen('.service'));
}
if ($normalized === 'nginx') {
return true;
}
return preg_match('/^php(\d+\.\d+)?-fpm$/', $normalized) === 1;
}
function restartService(array $params): array
{
global $allowedServices;
$service = $params['service'] ?? '';
if (!in_array($service, $allowedServices, true)) {
return ['success' => false, 'error' => "Service not allowed: $service"];
}
$action = shouldReloadService($service) ? 'reload' : 'restart';
exec("systemctl {$action} " . escapeshellarg($service) . " 2>&1", $output, $exitCode);
return $exitCode === 0
? ['success' => true, 'message' => "Service $service " . ($action === 'reload' ? 'reloaded' : 'restarted')]
: ['success' => false, 'error' => 'Failed to ' . $action . ' service'];
}
function reloadService(array $params): array
{
global $allowedServices;
$service = $params['service'] ?? '';
if (!in_array($service, $allowedServices, true)) {
return ['success' => false, 'error' => "Service not allowed: $service"];
}
exec("systemctl reload " . escapeshellarg($service) . " 2>&1", $output, $exitCode);
return $exitCode === 0
? ['success' => true, 'message' => "Service $service reloaded"]
: ['success' => false, 'error' => 'Failed to reload service'];
}
function getServiceStatus(array $params): array
{
global $allowedServices;
$service = $params['service'] ?? '';
if (!in_array($service, $allowedServices, true)) {
return ['success' => false, 'error' => "Service not allowed: $service"];
}
exec("systemctl is-active " . escapeshellarg($service) . " 2>&1", $output, $exitCode);
$status = trim($output[0] ?? 'unknown');
return ['success' => true, 'service' => $service, 'status' => $status, 'running' => $status === 'active'];
}
/**
* Enable gzip compression in nginx for all text-based content
* This provides ~400-500KB savings on typical WordPress sites
*/
function nginxEnableCompression(array $params): array
{
$nginxConf = '/etc/nginx/nginx.conf';
if (!file_exists($nginxConf)) {
return ['success' => false, 'error' => 'nginx.conf not found'];
}
$content = file_get_contents($nginxConf);
$modified = false;
// Check if gzip_types is already configured (not commented)
if (preg_match('/^\s*gzip_types\s/m', $content)) {
return ['success' => true, 'message' => 'Compression already enabled', 'already_enabled' => true];
}
// Uncomment and configure gzip settings (patterns account for tab indentation)
$replacements = [
'/^[ \t]*# gzip_vary on;/m' => "\tgzip_vary on;",
'/^[ \t]*# gzip_proxied any;/m' => "\tgzip_proxied any;",
'/^[ \t]*# gzip_comp_level 6;/m' => "\tgzip_comp_level 6;",
'/^[ \t]*# gzip_buffers 16 8k;/m' => "\tgzip_buffers 16 8k;",
'/^[ \t]*# gzip_http_version 1.1;/m' => "\tgzip_http_version 1.1;",
'/^[ \t]*# gzip_types .*/m' => "\tgzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml application/xml+rss application/x-javascript application/vnd.ms-fontobject application/x-font-ttf font/opentype font/woff font/woff2 image/svg+xml image/x-icon;",
];
foreach ($replacements as $pattern => $replacement) {
$newContent = preg_replace($pattern, $replacement, $content);
if ($newContent !== $content) {
$content = $newContent;
$modified = true;
}
}
// Add gzip_min_length if gzip_types was added and gzip_min_length doesn't exist
if ($modified && strpos($content, 'gzip_min_length') === false) {
$content = preg_replace(
'/(gzip_types[^;]+;)/',
"$1\n\tgzip_min_length 256;",
$content
);
}
// Write back if modified
if ($modified) {
// Test configuration before applying
file_put_contents($nginxConf, $content);
exec('nginx -t 2>&1', $testOutput, $testCode);
if ($testCode !== 0) {
// Restore original
exec("git -C /etc/nginx checkout nginx.conf 2>/dev/null");
return ['success' => false, 'error' => 'nginx configuration test failed: ' . implode("\n", $testOutput)];
}
// Reload nginx
exec('systemctl reload nginx 2>&1', $output, $exitCode);
if ($exitCode !== 0) {
return ['success' => false, 'error' => 'Failed to reload nginx'];
}
return ['success' => true, 'message' => 'Compression enabled successfully'];
}
return ['success' => true, 'message' => 'Compression settings already optimal', 'already_enabled' => true];
}
/**
* Get current nginx compression status
*/
function nginxGetCompressionStatus(array $params): array
{
$nginxConf = '/etc/nginx/nginx.conf';
if (!file_exists($nginxConf)) {
return ['success' => false, 'error' => 'nginx.conf not found'];
}
$content = file_get_contents($nginxConf);
$settings = [
'gzip' => (bool)preg_match('/^\s*gzip\s+on\s*;/m', $content),
'gzip_vary' => (bool)preg_match('/^\s*gzip_vary\s+on\s*;/m', $content),
'gzip_proxied' => (bool)preg_match('/^\s*gzip_proxied\s/m', $content),
'gzip_comp_level' => null,
'gzip_types' => (bool)preg_match('/^\s*gzip_types\s/m', $content),
'gzip_min_length' => null,
];
// Extract compression level
if (preg_match('/^\s*gzip_comp_level\s+(\d+)\s*;/m', $content, $matches)) {
$settings['gzip_comp_level'] = (int)$matches[1];
}
// Extract min length
if (preg_match('/^\s*gzip_min_length\s+(\d+)\s*;/m', $content, $matches)) {
$settings['gzip_min_length'] = (int)$matches[1];
}
// Determine if fully optimized
$optimized = $settings['gzip'] && $settings['gzip_vary'] && $settings['gzip_types'] && $settings['gzip_comp_level'];
return [
'success' => true,
'enabled' => $settings['gzip'],
'optimized' => $optimized,
'settings' => $settings,
];
}
// ============ MAIN ============
function main(): void
{
@mkdir(dirname(SOCKET_PATH), 0755, true);
@mkdir(dirname(LOG_FILE), 0755, true);
if (file_exists(SOCKET_PATH)) {
unlink(SOCKET_PATH);
}
// SSH Key Management Functions
function sshListKeys(array $params): array
{
$username = $params['username'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$userInfo = posix_getpwnam($username);
if (!$userInfo) {
return ['success' => false, 'error' => 'User not found'];
}
$sshDir = $userInfo['dir'] . '/.ssh';
$authKeysFile = $sshDir . '/authorized_keys';
if (!file_exists($authKeysFile)) {
return ['success' => true, 'keys' => []];
}
$keys = [];
$lines = file($authKeysFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $index => $line) {
$line = trim($line);
if (empty($line) || strpos($line, '#') === 0) {
continue;
}
// Parse SSH key: type base64 comment
$parts = preg_split('/\s+/', $line, 3);
if (count($parts) < 2) {
continue;
}
$type = $parts[0];
$keyData = $parts[1];
$comment = $parts[2] ?? 'Key ' . ($index + 1);
// Generate fingerprint
$tempFile = tempnam(sys_get_temp_dir(), 'sshkey');
file_put_contents($tempFile, $line);
exec("ssh-keygen -lf " . escapeshellarg($tempFile) . " 2>&1", $fpOutput);
@unlink($tempFile);
$fingerprint = isset($fpOutput[0]) ? preg_replace('/^\d+\s+/', '', $fpOutput[0]) : substr($keyData, 0, 20) . '...';
$keys[] = [
'id' => md5($line),
'name' => $comment,
'type' => $type,
'fingerprint' => $fingerprint,
'key' => substr($keyData, 0, 30) . '...',
];
}
return ['success' => true, 'keys' => $keys];
}
function sshAddKey(array $params): array
{
$username = $params['username'] ?? '';
$name = $params['name'] ?? '';
$publicKey = $params['public_key'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
if (empty($publicKey)) {
return ['success' => false, 'error' => 'Public key is required'];
}
// Validate key format
$publicKey = trim($publicKey);
if (!preg_match('/^(ssh-rsa|ssh-ed25519|ssh-dss|ecdsa-sha2-\S+)\s+[A-Za-z0-9+\/=]+/', $publicKey)) {
return ['success' => false, 'error' => 'Invalid SSH public key format'];
}
$userInfo = posix_getpwnam($username);
if (!$userInfo) {
return ['success' => false, 'error' => 'User not found'];
}
$uid = $userInfo['uid'];
$gid = $userInfo['gid'];
$sshDir = $userInfo['dir'] . '/.ssh';
$authKeysFile = $sshDir . '/authorized_keys';
// Create .ssh directory if it doesn't exist
if (!is_dir($sshDir)) {
mkdir($sshDir, 0700, true);
chown($sshDir, $uid);
chgrp($sshDir, $gid);
}
// Add comment if not present
if (!preg_match('/\s+\S+$/', $publicKey) || preg_match('/==$/', $publicKey)) {
$publicKey .= ' ' . $name;
}
// Check if key already exists
if (file_exists($authKeysFile)) {
$existingKeys = file_get_contents($authKeysFile);
$keyParts = preg_split('/\s+/', $publicKey);
if (count($keyParts) >= 2 && strpos($existingKeys, $keyParts[1]) !== false) {
return ['success' => false, 'error' => 'This key already exists'];
}
}
// Append key to authorized_keys
$result = file_put_contents($authKeysFile, $publicKey . "\n", FILE_APPEND | LOCK_EX);
if ($result === false) {
return ['success' => false, 'error' => 'Failed to write authorized_keys file'];
}
// Set proper permissions
chmod($authKeysFile, 0600);
chown($authKeysFile, $uid);
chgrp($authKeysFile, $gid);
logger("SSH key added for user $username: $name");
return ['success' => true, 'message' => 'SSH key added successfully'];
}
function sshDeleteKey(array $params): array
{
$username = $params['username'] ?? '';
$keyId = $params['key_id'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
if (empty($keyId)) {
return ['success' => false, 'error' => 'Key ID is required'];
}
$userInfo = posix_getpwnam($username);
if (!$userInfo) {
return ['success' => false, 'error' => 'User not found'];
}
$uid = $userInfo['uid'];
$gid = $userInfo['gid'];
$authKeysFile = $userInfo['dir'] . '/.ssh/authorized_keys';
if (!file_exists($authKeysFile)) {
return ['success' => false, 'error' => 'No SSH keys found'];
}
$lines = file($authKeysFile, FILE_IGNORE_NEW_LINES);
$newLines = [];
$found = false;
foreach ($lines as $line) {
if (md5(trim($line)) === $keyId) {
$found = true;
continue; // Skip this key
}
$newLines[] = $line;
}
if (!$found) {
return ['success' => false, 'error' => 'Key not found'];
}
// Write back
file_put_contents($authKeysFile, implode("\n", $newLines) . (count($newLines) > 0 ? "\n" : ""));
chmod($authKeysFile, 0600);
chown($authKeysFile, $uid);
chgrp($authKeysFile, $gid);
logger("SSH key deleted for user $username: $keyId");
return ['success' => true, 'message' => 'SSH key deleted successfully'];
}
$socket = socket_create(AF_UNIX, SOCK_STREAM, 0);
if ($socket === false) {
logger("Failed to create socket", 'ERROR');
exit(1);
}
if (socket_bind($socket, SOCKET_PATH) === false) {
logger("Failed to bind socket", 'ERROR');
exit(1);
}
if (socket_listen($socket, 5) === false) {
logger("Failed to listen", 'ERROR');
exit(1);
}
chmod(SOCKET_PATH, 0660);
chown(SOCKET_PATH, 'root');
chgrp(SOCKET_PATH, 'www-data');
file_put_contents(PID_FILE, getmypid());
logger("Jabali Agent started on " . SOCKET_PATH);
pcntl_signal(SIGTERM, function () use ($socket) {
logger("Shutting down...");
socket_close($socket);
@unlink(SOCKET_PATH);
@unlink(PID_FILE);
exit(0);
});
pcntl_signal(SIGINT, function () use ($socket) {
logger("Shutting down...");
socket_close($socket);
@unlink(SOCKET_PATH);
@unlink(PID_FILE);
exit(0);
});
// Make socket non-blocking for signal handling
socket_set_nonblock($socket);
while (true) {
pcntl_signal_dispatch();
$client = @socket_accept($socket);
if ($client === false) {
usleep(50000); // 50ms delay
continue;
}
// Set short timeout for reading
socket_set_option($client, SOL_SOCKET, SO_RCVTIMEO, ['sec' => 30, 'usec' => 0]);
$data = '';
while (($chunk = @socket_read($client, 65536)) !== false && $chunk !== '') {
$data .= $chunk;
// Check if we have complete JSON
if (strlen($chunk) < 65536) {
break;
}
}
if (!empty($data)) {
$request = json_decode($data, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$response = ['success' => false, 'error' => 'Invalid JSON: ' . json_last_error_msg()];
} else {
try {
$response = handleAction($request);
} catch (Throwable $e) {
logger("[ERROR] Exception in handleAction: " . $e->getMessage());
$response = ['success' => false, 'error' => 'Internal error: ' . $e->getMessage()];
}
}
socket_write($client, json_encode($response));
}
socket_close($client);
}
}
// ============ MYSQL MANAGEMENT ============
function getMysqlConnection(): ?mysqli
{
$socket = "/var/run/mysqld/mysqld.sock";
// Try socket connection as root
$conn = @new mysqli("localhost", "root", "", "", 0, $socket);
if ($conn->connect_error) {
// Try TCP
$conn = @new mysqli("127.0.0.1", "root", "");
if ($conn->connect_error) {
return null;
}
}
return $conn;
}
function getMysqlRootCredentials(): ?array
{
// Try to read from debian.cnf first (Debian/Ubuntu)
$debianCnf = '/etc/mysql/debian.cnf';
if (file_exists($debianCnf)) {
$content = file_get_contents($debianCnf);
if (preg_match('/user\s*=\s*(\S+)/', $content, $userMatch) &&
preg_match('/password\s*=\s*(\S+)/', $content, $passMatch)) {
return ['user' => $userMatch[1], 'password' => $passMatch[1]];
}
}
// Try root with no password (socket auth)
return ['user' => 'root', 'password' => ''];
}
function mysqlListDatabases(array $params): array
{
$username = $params["username"] ?? "";
$conn = getMysqlConnection();
if (!$conn) {
return ["success" => false, "error" => "Cannot connect to MySQL"];
}
$databases = [];
$prefix = $username . "_";
// Get database sizes
$sizeQuery = "SELECT table_schema AS db_name,
SUM(data_length + index_length) AS size_bytes
FROM information_schema.tables
GROUP BY table_schema";
$sizeResult = $conn->query($sizeQuery);
$dbSizes = [];
if ($sizeResult) {
while ($row = $sizeResult->fetch_assoc()) {
$dbSizes[$row['db_name']] = (int)($row['size_bytes'] ?? 0);
}
}
$result = $conn->query("SHOW DATABASES");
while ($row = $result->fetch_array()) {
$dbName = $row[0];
if (strpos($dbName, $prefix) === 0 || $username === "admin") {
$sizeBytes = $dbSizes[$dbName] ?? 0;
$databases[] = [
"name" => $dbName,
"size_bytes" => $sizeBytes,
"size_human" => formatBytes($sizeBytes),
];
}
}
$conn->close();
return ["success" => true, "databases" => $databases];
}
function mysqlCreateDatabase(array $params): array
{
$username = $params["username"] ?? "";
$database = $params["database"] ?? "";
if (!validateUsername($username)) {
return ["success" => false, "error" => "Invalid username"];
}
// Check if already prefixed
$prefix = $username . "_";
$cleanDb = preg_replace("/[^a-zA-Z0-9_]/", "", $database);
if (strpos($cleanDb, $prefix) === 0) {
$dbName = $cleanDb;
} else {
$dbName = $prefix . $cleanDb;
}
if (strlen($dbName) > 64) {
return ["success" => false, "error" => "Database name too long"];
}
$conn = getMysqlConnection();
if (!$conn) {
return ["success" => false, "error" => "Cannot connect to MySQL"];
}
$dbName = $conn->real_escape_string($dbName);
if (!$conn->query("CREATE DATABASE IF NOT EXISTS `$dbName` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")) {
$error = $conn->error;
$conn->close();
return ["success" => false, "error" => "Failed to create database: $error"];
}
$conn->close();
logger("Created MySQL database: $dbName for user $username");
return ["success" => true, "message" => "Database created", "database" => $dbName];
}
function mysqlDeleteDatabase(array $params): array
{
$username = $params["username"] ?? "";
$database = $params["database"] ?? "";
if (!validateUsername($username)) {
return ["success" => false, "error" => "Invalid username"];
}
$prefix = $username . "_";
if (strpos($database, $prefix) !== 0 && $username !== "admin") {
return ["success" => false, "error" => "Access denied"];
}
$conn = getMysqlConnection();
if (!$conn) {
return ["success" => false, "error" => "Cannot connect to MySQL"];
}
$dbName = $conn->real_escape_string($database);
if (!$conn->query("DROP DATABASE IF EXISTS `$dbName`")) {
$error = $conn->error;
$conn->close();
return ["success" => false, "error" => "Failed to delete database: $error"];
}
$conn->close();
logger("Deleted MySQL database: $dbName for user $username");
return ["success" => true, "message" => "Database deleted"];
}
function mysqlListUsers(array $params): array
{
$username = $params["username"] ?? "";
$conn = getMysqlConnection();
if (!$conn) {
return ["success" => false, "error" => "Cannot connect to MySQL"];
}
$users = [];
$prefix = $username . "_";
$result = $conn->query("SELECT User, Host FROM mysql.user WHERE User != 'root' AND User != '' AND User NOT LIKE 'mysql.%'");
while ($row = $result->fetch_assoc()) {
if (strpos($row["User"], $prefix) === 0 || $username === "admin") {
$users[] = ["user" => $row["User"], "host" => $row["Host"]];
}
}
$conn->close();
return ["success" => true, "users" => $users];
}
function mysqlCreateUser(array $params): array
{
$username = $params["username"] ?? "";
$dbUser = $params["db_user"] ?? "";
$password = $params["password"] ?? "";
$host = $params["host"] ?? "localhost";
if (!validateUsername($username)) {
return ["success" => false, "error" => "Invalid username"];
}
if (empty($password) || strlen($password) < 8) {
return ["success" => false, "error" => "Password must be at least 8 characters"];
}
// Check if already prefixed
$prefix = $username . "_";
$cleanDbUser = preg_replace("/[^a-zA-Z0-9_]/", "", $dbUser);
if (strpos($cleanDbUser, $prefix) === 0) {
$dbUserName = $cleanDbUser;
} else {
$dbUserName = $prefix . $cleanDbUser;
}
if (strlen($dbUserName) > 32) {
return ["success" => false, "error" => "Username too long"];
}
$conn = getMysqlConnection();
if (!$conn) {
return ["success" => false, "error" => "Cannot connect to MySQL"];
}
$dbUserName = $conn->real_escape_string($dbUserName);
$host = $conn->real_escape_string($host);
$password = $conn->real_escape_string($password);
if (!$conn->query("CREATE USER '$dbUserName'@'$host' IDENTIFIED BY '$password'")) {
$error = $conn->error;
$conn->close();
return ["success" => false, "error" => "Failed to create user: $error"];
}
$conn->close();
logger("Created MySQL user: $dbUserName@$host for user $username");
return ["success" => true, "message" => "User created", "db_user" => $dbUserName];
}
function mysqlDeleteUser(array $params): array
{
$username = $params["username"] ?? "";
$dbUser = $params["db_user"] ?? "";
$host = $params["host"] ?? "localhost";
if (!validateUsername($username)) {
return ["success" => false, "error" => "Invalid username"];
}
$prefix = $username . "_";
if (strpos($dbUser, $prefix) !== 0 && $username !== "admin") {
return ["success" => false, "error" => "Access denied"];
}
$conn = getMysqlConnection();
if (!$conn) {
return ["success" => false, "error" => "Cannot connect to MySQL"];
}
$dbUser = $conn->real_escape_string($dbUser);
$host = $conn->real_escape_string($host);
if (!$conn->query("DROP USER IF EXISTS '$dbUser'@'$host'")) {
$error = $conn->error;
$conn->close();
return ["success" => false, "error" => "Failed to delete user: $error"];
}
$conn->close();
logger("Deleted MySQL user: $dbUser@$host for user $username");
return ["success" => true, "message" => "User deleted"];
}
function mysqlChangePassword(array $params): array
{
$username = $params["username"] ?? "";
$dbUser = $params["db_user"] ?? "";
$password = $params["password"] ?? "";
$host = $params["host"] ?? "localhost";
if (!validateUsername($username)) {
return ["success" => false, "error" => "Invalid username"];
}
$prefix = $username . "_";
if (strpos($dbUser, $prefix) !== 0 && $username !== "admin") {
return ["success" => false, "error" => "Access denied"];
}
if (empty($password) || strlen($password) < 8) {
return ["success" => false, "error" => "Password must be at least 8 characters"];
}
$conn = getMysqlConnection();
if (!$conn) {
return ["success" => false, "error" => "Cannot connect to MySQL"];
}
$dbUser = $conn->real_escape_string($dbUser);
$host = $conn->real_escape_string($host);
$password = $conn->real_escape_string($password);
if (!$conn->query("ALTER USER '$dbUser'@'$host' IDENTIFIED BY '$password'")) {
$error = $conn->error;
$conn->close();
return ["success" => false, "error" => "Failed to change password: $error"];
}
$conn->query("FLUSH PRIVILEGES");
$conn->close();
logger("Changed password for MySQL user: $dbUser@$host");
return ["success" => true, "message" => "Password changed"];
}
function mysqlGrantPrivileges(array $params): array
{
$username = $params["username"] ?? "";
$dbUser = $params["db_user"] ?? "";
$database = $params["database"] ?? "";
$privileges = $params["privileges"] ?? ["ALL"];
$host = $params["host"] ?? "localhost";
if (!validateUsername($username)) {
return ["success" => false, "error" => "Invalid username"];
}
$prefix = $username . "_";
if ($username !== "admin") {
if (strpos($dbUser, $prefix) !== 0) {
return ["success" => false, "error" => "Access denied to user"];
}
if (strpos($database, $prefix) !== 0 && $database !== "*") {
return ["success" => false, "error" => "Access denied to database"];
}
}
$conn = getMysqlConnection();
if (!$conn) {
return ["success" => false, "error" => "Cannot connect to MySQL"];
}
$dbUser = $conn->real_escape_string($dbUser);
$host = $conn->real_escape_string($host);
$database = $conn->real_escape_string($database);
$allowedPrivs = ["ALL", "SELECT", "INSERT", "UPDATE", "DELETE", "CREATE", "DROP", "INDEX", "ALTER", "EXECUTE", "CREATE VIEW", "SHOW VIEW"];
$privList = [];
foreach ($privileges as $priv) {
$priv = strtoupper(trim($priv));
if (in_array($priv, $allowedPrivs)) {
$privList[] = $priv;
}
}
if (empty($privList)) {
$privList = ["ALL"];
}
$privString = implode(", ", $privList);
$dbTarget = $database === "*" ? "*.*" : "`$database`.*";
if (!$conn->query("GRANT $privString ON $dbTarget TO '$dbUser'@'$host'")) {
$error = $conn->error;
$conn->close();
return ["success" => false, "error" => "Failed to grant privileges: $error"];
}
$conn->query("FLUSH PRIVILEGES");
$conn->close();
logger("Granted $privString on $database to $dbUser@$host");
return ["success" => true, "message" => "Privileges granted"];
}
function mysqlRevokePrivileges(array $params): array
{
$username = $params["username"] ?? "";
$dbUser = $params["db_user"] ?? "";
$database = $params["database"] ?? "";
$host = $params["host"] ?? "localhost";
if (!validateUsername($username)) {
return ["success" => false, "error" => "Invalid username"];
}
$prefix = $username . "_";
if ($username !== "admin") {
if (strpos($dbUser, $prefix) !== 0) {
return ["success" => false, "error" => "Access denied to user"];
}
}
$conn = getMysqlConnection();
if (!$conn) {
return ["success" => false, "error" => "Cannot connect to MySQL"];
}
$dbUser = $conn->real_escape_string($dbUser);
$host = $conn->real_escape_string($host);
$database = $conn->real_escape_string($database);
$dbTarget = $database === "*" ? "*.*" : "`$database`.*";
if (!$conn->query("REVOKE ALL PRIVILEGES ON $dbTarget FROM '$dbUser'@'$host'")) {
$error = $conn->error;
$conn->close();
return ["success" => false, "error" => "Failed to revoke privileges: $error"];
}
$conn->query("FLUSH PRIVILEGES");
$conn->close();
logger("Revoked privileges on $database from $dbUser@$host");
return ["success" => true, "message" => "Privileges revoked"];
}
function mysqlGetPrivileges(array $params): array
{
$username = $params["username"] ?? "";
$dbUser = $params["db_user"] ?? "";
$host = $params["host"] ?? "localhost";
if (!validateUsername($username)) {
return ["success" => false, "error" => "Invalid username"];
// Signal handling for graceful shutdown
$shutdown = false;
pcntl_async_signals(true);
pcntl_signal(SIGTERM, function() use (&$shutdown) {
global $socket;
$shutdown = true;
if ($socket) {
socket_close($socket);
}
exit(0);
});
pcntl_signal(SIGINT, function() use (&$shutdown) {
global $socket;
$shutdown = true;
if ($socket) {
socket_close($socket);
}
exit(0);
});
while (!$shutdown) {
// Set socket timeout so it doesn't block forever
socket_set_option($socket, SOL_SOCKET, SO_RCVTIMEO, ['sec' => 1, 'usec' => 0]);
$client = @socket_accept($socket);
if ($client === false) {
// Timeout or error - check if we should shutdown
if ($shutdown) break;
continue;
}
handleConnection($client);
}
socket_close($socket);
unlink($socketPath);
}
$conn = getMysqlConnection();
if (!$conn) {
return ["success" => false, "error" => "Cannot connect to MySQL"];
}
$dbUser = $conn->real_escape_string($dbUser);
$host = $conn->real_escape_string($host);
$rawPrivileges = [];
$parsedPrivileges = [];
$result = $conn->query("SHOW GRANTS FOR '$dbUser'@'$host'");
if ($result) {
while ($row = $result->fetch_array()) {
$grant = $row[0];
$rawPrivileges[] = $grant;
// Parse: GRANT SELECT, INSERT ON `db`.* TO user
// Or: GRANT ALL PRIVILEGES ON `db`.* TO user
if (preg_match('/GRANT\s+(.+?)\s+ON\s+[`"\']*([^`"\'\.\s]+)[`"\']*\.\*\s+TO/i', $grant, $matches)) {
$privsStr = trim($matches[1]);
$db = trim($matches[2], "`\'\"");
if ($db !== "*") {
$privList = [];
if (stripos($privsStr, "ALL PRIVILEGES") !== false) {
$privList = ["ALL PRIVILEGES"];
} elseif (stripos($privsStr, "ALL") === 0) {
$privList = ["ALL PRIVILEGES"];
} else {
$parts = explode(",", $privsStr);
foreach ($parts as $p) {
$p = trim($p);
if (!empty($p) && stripos($p, "GRANT OPTION") === false) {
$privList[] = strtoupper($p);
}
}
}
if (!empty($privList)) {
$parsedPrivileges[] = [
"database" => $db,
"privileges" => $privList
];
}
}
}
}
}
$conn->close();
return ["success" => true, "privileges" => $rawPrivileges, "parsed" => $parsedPrivileges];
}
main();
// Domain Management Functions
function domainCreate(array $params): array
{
$username = $params['username'] ?? '';
$domain = $params['domain'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
// Validate domain format
$domain = strtolower(trim($domain));
if (!preg_match('/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*\.[a-z]{2,}$/', $domain)) {
return ['success' => false, 'error' => 'Invalid domain format'];
}
// Check if domain already exists
$vhostFile = "/etc/nginx/sites-available/{$domain}.conf";
if (file_exists($vhostFile)) {
return ['success' => false, 'error' => 'Domain already exists'];
}
// Get user info
$userInfo = posix_getpwnam($username);
if (!$userInfo) {
return ['success' => false, 'error' => 'User not found'];
}
$userHome = $userInfo['dir'];
$uid = $userInfo['uid'];
$gid = $userInfo['gid'];
// Create domain directories
$domainRoot = "{$userHome}/domains/{$domain}";
$publicHtml = "{$domainRoot}/public_html";
$logs = "{$domainRoot}/logs";
$dirs = [$domainRoot, $publicHtml, $logs];
foreach ($dirs as $dir) {
if (!is_dir($dir)) {
if (!mkdir($dir, 0755, true)) {
return ['success' => false, 'error' => "Failed to create directory: {$dir}"];
}
chown($dir, $uid);
chgrp($dir, $gid);
}
}
// Create default index.html
$indexContent = '
Welcome to ' . $domain . '
Welcome!
Your website is ready. Upload your files to get started.
' . $domain . '
';
$indexFile = "{$publicHtml}/index.html";
if (!file_exists($indexFile)) {
file_put_contents($indexFile, $indexContent);
chown($indexFile, $uid);
chgrp($indexFile, $gid);
}
// Ensure FPM pool exists for this user (don't reload - caller handles that)
createFpmPool($username, false);
$fpmSocket = getFpmSocketPath($username);
// Create Nginx virtual host configuration
$vhostContent = generateNginxVhost($domain, $publicHtml, $logs, $fpmSocket);
if (file_put_contents($vhostFile, $vhostContent) === false) {
return ['success' => false, 'error' => 'Failed to create virtual host configuration'];
}
// Enable the site (create symlink)
$enableCmd = "ln -sf " . escapeshellarg($vhostFile) . " /etc/nginx/sites-enabled/" . escapeshellarg("{$domain}.conf") . " 2>&1";
exec($enableCmd, $output, $returnCode);
if ($returnCode !== 0) {
unlink($vhostFile);
return ['success' => false, 'error' => 'Failed to enable site: ' . implode("\n", $output)];
}
// Set ACLs for Nginx to access files
exec("setfacl -m u:www-data:x " . escapeshellarg($userHome));
exec("setfacl -m u:www-data:x " . escapeshellarg("{$userHome}/domains"));
exec("setfacl -m u:www-data:rx " . escapeshellarg($domainRoot));
exec("setfacl -R -m u:www-data:rx " . escapeshellarg($publicHtml));
exec("setfacl -R -m u:www-data:rwx " . escapeshellarg($logs));
exec("setfacl -R -d -m u:www-data:rx " . escapeshellarg($publicHtml));
exec("setfacl -R -d -m u:www-data:rwx " . escapeshellarg($logs));
// Reload Nginx
exec("nginx -t 2>&1", $testOutput, $testCode);
if ($testCode !== 0) {
unlink($vhostFile);
unlink("/etc/nginx/sites-enabled/{$domain}.conf");
return ['success' => false, 'error' => 'Nginx config test failed: ' . implode("\n", $testOutput)];
}
exec("systemctl reload nginx 2>&1", $reloadOutput, $reloadCode);
if ($reloadCode !== 0) {
return ['success' => false, 'error' => 'Site created but failed to reload Nginx: ' . implode("\n", $reloadOutput)];
}
// Store domain in user's domain list
$domainListFile = "{$userHome}/.domains";
$domains = [];
if (file_exists($domainListFile)) {
$domains = json_decode(file_get_contents($domainListFile), true) ?: [];
}
$domains[$domain] = [
'created' => date('Y-m-d H:i:s'),
'document_root' => $publicHtml,
'ssl' => false
];
file_put_contents($domainListFile, json_encode($domains, JSON_PRETTY_PRINT));
chown($domainListFile, $uid);
chgrp($domainListFile, $gid);
return [
'success' => true,
'domain' => $domain,
'document_root' => $publicHtml,
'message' => "Domain {$domain} created successfully"
];
}
function domainDelete(array $params): array
{
$username = $params['username'] ?? '';
$domain = $params['domain'] ?? '';
$deleteFiles = $params['delete_files'] ?? false;
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$domain = strtolower(trim($domain));
// Verify ownership
$userInfo = posix_getpwnam($username);
if (!$userInfo) {
return ['success' => false, 'error' => 'User not found'];
}
$userHome = $userInfo['dir'];
$domainListFile = "{$userHome}/.domains";
// Check if user owns this domain
$domains = [];
if (file_exists($domainListFile)) {
$domains = json_decode(file_get_contents($domainListFile), true) ?: [];
}
// Admin can delete any domain, regular users only their own
if ($username !== 'admin' && !isset($domains[$domain])) {
return ['success' => false, 'error' => 'Domain not found or access denied'];
}
// Disable and remove the site
$vhostFile = "/etc/nginx/sites-available/{$domain}.conf";
if (file_exists("/etc/nginx/sites-enabled/{$domain}.conf")) {
exec("rm -f /etc/nginx/sites-enabled/" . escapeshellarg("{$domain}.conf") . " 2>&1", $output, $returnCode);
}
if (file_exists($vhostFile)) {
unlink($vhostFile);
}
// Reload Nginx
exec("nginx -t && systemctl reload nginx 2>&1");
// Delete DNS zone file and remove from named.conf.local
$zoneFile = "/etc/bind/zones/db.{$domain}";
if (file_exists($zoneFile)) {
unlink($zoneFile);
logger("Deleted DNS zone file for {$domain}");
}
$namedConf = '/etc/bind/named.conf.local';
if (file_exists($namedConf)) {
$content = file_get_contents($namedConf);
// Use [\s\S]*? to match any chars including newlines (handles nested braces like allow-transfer { none; })
$pattern = '/\n?zone\s+"' . preg_quote($domain, '/') . '"\s*\{[\s\S]*?\n\};\n?/';
$newContent = preg_replace($pattern, "\n", $content);
if ($newContent !== $content) {
file_put_contents($namedConf, trim($newContent) . "\n");
exec('systemctl reload bind9 2>&1 || systemctl reload named 2>&1');
logger("Removed DNS zone entry and reloaded BIND for {$domain}");
}
}
// Delete domain files if requested
if ($deleteFiles) {
$domainRoot = "{$userHome}/domains/{$domain}";
if (is_dir($domainRoot)) {
exec("rm -rf " . escapeshellarg($domainRoot));
}
}
// Remove from domain list
unset($domains[$domain]);
file_put_contents($domainListFile, json_encode($domains, JSON_PRETTY_PRINT));
return [
'success' => true,
'message' => "Domain {$domain} deleted successfully"
];
}
function domainList(array $params): array
{
$username = $params['username'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$userInfo = posix_getpwnam($username);
if (!$userInfo) {
return ['success' => false, 'error' => 'User not found'];
}
$userHome = $userInfo['dir'];
$domainListFile = "{$userHome}/.domains";
$domains = [];
if (file_exists($domainListFile)) {
$domains = json_decode(file_get_contents($domainListFile), true) ?: [];
}
// Enrich with additional info
$result = [];
foreach ($domains as $domain => $info) {
$docRoot = $info['document_root'] ?? "{$userHome}/domains/{$domain}/public_html";
$result[] = [
'domain' => $domain,
'document_root' => $docRoot,
'created' => $info['created'] ?? 'Unknown',
'ssl' => $info['ssl'] ?? false,
'enabled' => file_exists("/etc/nginx/sites-enabled/{$domain}.conf")
];
}
return ['success' => true, 'domains' => $result];
}
function domainToggle(array $params): array
{
$username = $params['username'] ?? '';
$domain = $params['domain'] ?? '';
$enable = $params['enable'] ?? true;
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$domain = strtolower(trim($domain));
$vhostFile = "/etc/nginx/sites-available/{$domain}.conf";
if (!file_exists($vhostFile)) {
return ['success' => false, 'error' => 'Domain configuration not found'];
}
if ($enable) {
exec("ln -sf /etc/nginx/sites-available/" . escapeshellarg("{$domain}.conf") . " /etc/nginx/sites-enabled/" . escapeshellarg("{$domain}.conf") . " 2>&1", $output, $returnCode);
$action = 'enabled';
} else {
exec("rm -f /etc/nginx/sites-enabled/" . escapeshellarg("{$domain}.conf") . " 2>&1", $output, $returnCode);
$action = 'disabled';
}
exec("nginx -t && systemctl reload nginx 2>&1");
return ['success' => true, 'message' => "Domain {$domain} {$action}"];
}
function domainSetRedirects(array $params): array
{
$username = $params['username'] ?? '';
$domain = $params['domain'] ?? '';
$redirects = $params['redirects'] ?? [];
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$domain = strtolower(trim($domain));
$vhostFile = "/etc/nginx/sites-available/{$domain}.conf";
if (!file_exists($vhostFile)) {
return ['success' => false, 'error' => 'Domain configuration not found'];
}
$vhostContent = file_get_contents($vhostFile);
// Remove any existing Jabali redirect markers and content (from ALL server blocks)
$vhostContent = preg_replace('/\n\s*# JABALI_REDIRECTS_START.*?# JABALI_REDIRECTS_END\n/s', "\n", $vhostContent);
// Check if there's a domain-wide redirect
$domainWideRedirect = null;
$pageRedirects = [];
foreach ($redirects as $redirect) {
$source = $redirect['source'] ?? '';
$destination = $redirect['destination'] ?? '';
$type = $redirect['type'] ?? '301';
$wildcard = $redirect['wildcard'] ?? false;
if (empty($source) || empty($destination)) {
continue;
}
// Sanitize destination URL
$destination = filter_var($destination, FILTER_SANITIZE_URL);
$type = in_array($type, ['301', '302']) ? $type : '301';
if ($source === '/*' || $source === '*' || $source === '/') {
// Domain-wide redirect
$domainWideRedirect = [
'destination' => $destination,
'type' => $type,
];
} else {
// Sanitize source path
$source = preg_replace('/[^a-zA-Z0-9\/_\-\.\*]/', '', $source);
$pageRedirects[] = [
'source' => $source,
'destination' => $destination,
'type' => $type,
'wildcard' => $wildcard,
];
}
}
// Build redirect configuration
$redirectConfig = "\n # JABALI_REDIRECTS_START\n";
if ($domainWideRedirect) {
// For domain-wide redirect, use return at server level (before location blocks)
$redirectConfig .= " # Domain-wide redirect - all requests go to: {$domainWideRedirect['destination']}\n";
$redirectConfig .= " return {$domainWideRedirect['type']} {$domainWideRedirect['destination']}\$request_uri;\n";
} else {
// Add page-specific redirects using rewrite rules (works before location matching)
foreach ($pageRedirects as $redirect) {
$source = $redirect['source'];
$destination = $redirect['destination'];
$type = $redirect['type'];
$wildcard = $redirect['wildcard'];
$redirectConfig .= " # Redirect: {$source}\n";
if ($wildcard) {
// Wildcard: match path and everything after
$escapedSource = preg_quote($source, '/');
$redirectConfig .= " rewrite ^{$escapedSource}(.*)\$ {$destination}\$1 permanent;\n";
} else {
// Exact match
$escapedSource = preg_quote($source, '/');
$flag = $type === '301' ? 'permanent' : 'redirect';
$redirectConfig .= " rewrite ^{$escapedSource}\$ {$destination} {$flag};\n";
}
}
}
$redirectConfig .= " # JABALI_REDIRECTS_END\n";
// Insert redirect config after EVERY server_name line (both HTTP and HTTPS blocks)
if (!empty($redirects)) {
$pattern = '/(server_name\s+' . preg_quote($domain, '/') . '[^;]*;)/';
$vhostContent = preg_replace(
$pattern,
"$1\n{$redirectConfig}",
$vhostContent
);
}
// Write updated vhost
file_put_contents($vhostFile, $vhostContent);
// Test and reload nginx
exec("nginx -t 2>&1", $testOutput, $testCode);
if ($testCode !== 0) {
// Restore original file on failure
return ['success' => false, 'error' => 'Nginx configuration test failed: ' . implode("\n", $testOutput)];
}
exec("systemctl reload nginx 2>&1", $reloadOutput, $reloadCode);
return [
'success' => $reloadCode === 0,
'message' => 'Redirects updated successfully',
'redirects_count' => count($redirects),
];
}
function domainSetHotlinkProtection(array $params): array
{
$username = $params['username'] ?? '';
$domain = $params['domain'] ?? '';
$enabled = $params['enabled'] ?? false;
$allowedDomains = $params['allowed_domains'] ?? [];
$blockBlankReferrer = $params['block_blank_referrer'] ?? true;
$protectedExtensions = $params['protected_extensions'] ?? ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'mp4', 'mp3', 'pdf'];
$redirectUrl = $params['redirect_url'] ?? null;
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$domain = strtolower(trim($domain));
$vhostFile = "/etc/nginx/sites-available/{$domain}.conf";
if (!file_exists($vhostFile)) {
return ['success' => false, 'error' => 'Domain configuration not found'];
}
$vhostContent = file_get_contents($vhostFile);
// Remove any existing hotlink protection markers
$vhostContent = preg_replace('/\n\s*# JABALI_HOTLINK_START.*?# JABALI_HOTLINK_END\n/s', "\n", $vhostContent);
if ($enabled && !empty($protectedExtensions)) {
// Build the hotlink protection config
$extensionsPattern = implode('|', array_map('preg_quote', $protectedExtensions));
// Build valid referers list using nginx valid_referers syntax
// server_names matches the server's own names from server_name directive
// Use regex patterns (~pattern) to match domains in the referer URL
$validReferers = ['server_names'];
// Add the domain itself (exact and with subdomains)
// Referer format: https://domain.com/path or https://sub.domain.com/path
$escapedDomain = str_replace('.', '\.', $domain);
$validReferers[] = "~{$escapedDomain}";
// Add user-specified allowed domains
foreach ($allowedDomains as $allowedDomain) {
$allowedDomain = trim($allowedDomain);
if (!empty($allowedDomain)) {
$escapedAllowed = str_replace('.', '\.', $allowedDomain);
$validReferers[] = "~{$escapedAllowed}";
}
}
// Handle blank referrer
if (!$blockBlankReferrer) {
array_unshift($validReferers, 'none');
}
$validReferersStr = implode(' ', $validReferers);
// Determine the action for invalid referrers
if (!empty($redirectUrl)) {
$action = "return 301 {$redirectUrl}";
} else {
$action = "return 403";
}
$hotlinkConfig = <<&1", $testOutput, $testCode);
if ($testCode !== 0) {
return ['success' => false, 'error' => 'Nginx configuration test failed: ' . implode("\n", $testOutput)];
}
exec("systemctl reload nginx 2>&1", $reloadOutput, $reloadCode);
return [
'success' => $reloadCode === 0,
'message' => $enabled ? 'Hotlink protection enabled' : 'Hotlink protection disabled',
];
}
function domainSetDirectoryIndex(array $params): array
{
$username = $params['username'] ?? '';
$domain = $params['domain'] ?? '';
$directoryIndex = $params['directory_index'] ?? 'index.php index.html';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$domain = strtolower(trim($domain));
$vhostFile = "/etc/nginx/sites-available/{$domain}.conf";
if (!file_exists($vhostFile)) {
return ['success' => false, 'error' => 'Domain configuration not found'];
}
// Validate and sanitize directory index value
$validIndexFiles = ['index.php', 'index.html', 'index.htm'];
$indexParts = preg_split('/\s+/', trim($directoryIndex));
$sanitizedParts = [];
foreach ($indexParts as $part) {
if (in_array($part, $validIndexFiles)) {
$sanitizedParts[] = $part;
}
}
if (empty($sanitizedParts)) {
$sanitizedParts = ['index.php', 'index.html'];
}
$newIndex = implode(' ', $sanitizedParts);
$vhostContent = file_get_contents($vhostFile);
// Replace the existing index directive
$vhostContent = preg_replace(
'/(\s+index\s+)[^;]+;/',
"$1{$newIndex};",
$vhostContent
);
// Write updated vhost
file_put_contents($vhostFile, $vhostContent);
// Test and reload nginx
exec("nginx -t 2>&1", $testOutput, $testCode);
if ($testCode !== 0) {
return ['success' => false, 'error' => 'Nginx configuration test failed: ' . implode("\n", $testOutput)];
}
exec("systemctl reload nginx 2>&1", $reloadOutput, $reloadCode);
return [
'success' => $reloadCode === 0,
'message' => 'Directory index updated',
'directory_index' => $newIndex,
];
}
/**
* List protected directories for a domain
*/
function domainListProtectedDirs(array $params): array
{
$username = $params['username'] ?? '';
$domain = $params['domain'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$domain = strtolower(trim($domain));
$userInfo = posix_getpwnam($username);
if (!$userInfo) {
return ['success' => false, 'error' => 'User not found'];
}
$userHome = $userInfo['dir'];
$protectedDir = "{$userHome}/domains/{$domain}/.protected";
$directories = [];
if (is_dir($protectedDir)) {
$files = glob("{$protectedDir}/*.conf");
foreach ($files as $confFile) {
$config = parse_ini_file($confFile);
if ($config === false) {
continue;
}
$path = $config['path'] ?? '';
$name = $config['name'] ?? 'Protected Area';
$htpasswdFile = "{$protectedDir}/" . md5($path) . ".htpasswd";
$users = [];
if (file_exists($htpasswdFile)) {
$lines = file($htpasswdFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (strpos($line, ':') !== false) {
[$authUser] = explode(':', $line, 2);
$users[] = $authUser;
}
}
}
$directories[] = [
'path' => $path,
'name' => $name,
'users' => $users,
'users_count' => count($users),
];
}
}
return [
'success' => true,
'directories' => $directories,
];
}
/**
* Add protection to a directory
*/
function domainAddProtectedDir(array $params): array
{
$username = $params['username'] ?? '';
$domain = $params['domain'] ?? '';
$path = $params['path'] ?? '';
$name = $params['name'] ?? 'Protected Area';
$authUsername = $params['auth_username'] ?? '';
$authPassword = $params['auth_password'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
if (empty($path) || empty($authUsername) || empty($authPassword)) {
return ['success' => false, 'error' => 'Missing required parameters'];
}
// Validate path - must start with / and not contain ..
$path = '/' . ltrim($path, '/');
if (strpos($path, '..') !== false || !preg_match('#^/[a-zA-Z0-9/_-]*$#', $path)) {
return ['success' => false, 'error' => 'Invalid path'];
}
// Validate auth username
if (!preg_match('/^[a-zA-Z0-9_]+$/', $authUsername)) {
return ['success' => false, 'error' => 'Invalid username format'];
}
$domain = strtolower(trim($domain));
$userInfo = posix_getpwnam($username);
if (!$userInfo) {
return ['success' => false, 'error' => 'User not found'];
}
$userHome = $userInfo['dir'];
$uid = $userInfo['uid'];
$gid = $userInfo['gid'];
$domainRoot = "{$userHome}/domains/{$domain}";
$protectedDir = "{$domainRoot}/.protected";
if (!is_dir($domainRoot)) {
return ['success' => false, 'error' => 'Domain not found'];
}
// Create .protected directory
if (!is_dir($protectedDir)) {
mkdir($protectedDir, 0750, true);
chown($protectedDir, $uid);
chgrp($protectedDir, $gid);
// Set ACL for nginx to read
exec("setfacl -m u:www-data:rx " . escapeshellarg($protectedDir));
}
$pathHash = md5($path);
$confFile = "{$protectedDir}/{$pathHash}.conf";
$htpasswdFile = "{$protectedDir}/{$pathHash}.htpasswd";
// Check if already protected
if (file_exists($confFile)) {
return ['success' => false, 'error' => 'This directory is already protected'];
}
// Create config file
$configContent = "path=\"{$path}\"\nname=\"{$name}\"\n";
file_put_contents($confFile, $configContent);
chown($confFile, $uid);
chgrp($confFile, $gid);
chmod($confFile, 0640);
// Create htpasswd file with first user
$hashedPassword = password_hash($authPassword, PASSWORD_BCRYPT);
file_put_contents($htpasswdFile, "{$authUsername}:{$hashedPassword}\n");
chown($htpasswdFile, $uid);
chgrp($htpasswdFile, $gid);
chmod($htpasswdFile, 0640);
// Set ACL for nginx to read htpasswd
exec("setfacl -m u:www-data:r " . escapeshellarg($htpasswdFile));
// Update nginx configuration
$result = updateNginxProtectedDirs($domain, $domainRoot);
if (!$result['success']) {
// Rollback
unlink($confFile);
unlink($htpasswdFile);
return $result;
}
return [
'success' => true,
'message' => "Directory {$path} is now protected",
];
}
/**
* Remove protection from a directory
*/
function domainRemoveProtectedDir(array $params): array
{
$username = $params['username'] ?? '';
$domain = $params['domain'] ?? '';
$path = $params['path'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$domain = strtolower(trim($domain));
$userInfo = posix_getpwnam($username);
if (!$userInfo) {
return ['success' => false, 'error' => 'User not found'];
}
$userHome = $userInfo['dir'];
$domainRoot = "{$userHome}/domains/{$domain}";
$protectedDir = "{$domainRoot}/.protected";
$pathHash = md5($path);
$confFile = "{$protectedDir}/{$pathHash}.conf";
$htpasswdFile = "{$protectedDir}/{$pathHash}.htpasswd";
if (!file_exists($confFile)) {
return ['success' => false, 'error' => 'Protected directory not found'];
}
// Remove files
if (file_exists($confFile)) {
unlink($confFile);
}
if (file_exists($htpasswdFile)) {
unlink($htpasswdFile);
}
// Update nginx configuration
$result = updateNginxProtectedDirs($domain, $domainRoot);
return [
'success' => true,
'message' => "Protection removed from {$path}",
];
}
/**
* Add a user to a protected directory
*/
function domainAddProtectedDirUser(array $params): array
{
$username = $params['username'] ?? '';
$domain = $params['domain'] ?? '';
$path = $params['path'] ?? '';
$authUsername = $params['auth_username'] ?? '';
$authPassword = $params['auth_password'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
if (empty($authUsername) || empty($authPassword)) {
return ['success' => false, 'error' => 'Username and password are required'];
}
if (!preg_match('/^[a-zA-Z0-9_]+$/', $authUsername)) {
return ['success' => false, 'error' => 'Invalid username format'];
}
$domain = strtolower(trim($domain));
$userInfo = posix_getpwnam($username);
if (!$userInfo) {
return ['success' => false, 'error' => 'User not found'];
}
$userHome = $userInfo['dir'];
$uid = $userInfo['uid'];
$gid = $userInfo['gid'];
$domainRoot = "{$userHome}/domains/{$domain}";
$protectedDir = "{$domainRoot}/.protected";
$pathHash = md5($path);
$htpasswdFile = "{$protectedDir}/{$pathHash}.htpasswd";
if (!file_exists($htpasswdFile)) {
return ['success' => false, 'error' => 'Protected directory not found'];
}
// Check if user already exists
$lines = file($htpasswdFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (strpos($line, ':') !== false) {
[$existingUser] = explode(':', $line, 2);
if ($existingUser === $authUsername) {
return ['success' => false, 'error' => 'User already exists'];
}
}
}
// Add new user
$hashedPassword = password_hash($authPassword, PASSWORD_BCRYPT);
file_put_contents($htpasswdFile, "{$authUsername}:{$hashedPassword}\n", FILE_APPEND);
chown($htpasswdFile, $uid);
chgrp($htpasswdFile, $gid);
return [
'success' => true,
'message' => "User {$authUsername} added",
];
}
/**
* Remove a user from a protected directory
*/
function domainRemoveProtectedDirUser(array $params): array
{
$username = $params['username'] ?? '';
$domain = $params['domain'] ?? '';
$path = $params['path'] ?? '';
$authUsername = $params['auth_username'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$domain = strtolower(trim($domain));
$userInfo = posix_getpwnam($username);
if (!$userInfo) {
return ['success' => false, 'error' => 'User not found'];
}
$userHome = $userInfo['dir'];
$uid = $userInfo['uid'];
$gid = $userInfo['gid'];
$domainRoot = "{$userHome}/domains/{$domain}";
$protectedDir = "{$domainRoot}/.protected";
$pathHash = md5($path);
$htpasswdFile = "{$protectedDir}/{$pathHash}.htpasswd";
if (!file_exists($htpasswdFile)) {
return ['success' => false, 'error' => 'Protected directory not found'];
}
// Remove user from htpasswd file
$lines = file($htpasswdFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$newLines = [];
$found = false;
foreach ($lines as $line) {
if (strpos($line, ':') !== false) {
[$existingUser] = explode(':', $line, 2);
if ($existingUser === $authUsername) {
$found = true;
continue;
}
}
$newLines[] = $line;
}
if (!$found) {
return ['success' => false, 'error' => 'User not found'];
}
file_put_contents($htpasswdFile, implode("\n", $newLines) . (count($newLines) > 0 ? "\n" : ""));
chown($htpasswdFile, $uid);
chgrp($htpasswdFile, $gid);
return [
'success' => true,
'message' => "User {$authUsername} removed",
];
}
/**
* Update nginx configuration for protected directories
*/
function updateNginxProtectedDirs(string $domain, string $domainRoot): array
{
$vhostFile = "/etc/nginx/sites-available/{$domain}.conf";
if (!file_exists($vhostFile)) {
return ['success' => false, 'error' => 'Domain nginx configuration not found'];
}
$protectedDir = "{$domainRoot}/.protected";
$vhostContent = file_get_contents($vhostFile);
// Remove existing protected directory blocks
$vhostContent = preg_replace('/\n\s*# Protected directory:[^\n]*\n\s*location[^}]+auth_basic[^}]+\}\n?/s', '', $vhostContent);
// Build new protected directory blocks
$protectedBlocks = '';
if (is_dir($protectedDir)) {
$files = glob("{$protectedDir}/*.conf");
foreach ($files as $confFile) {
$config = parse_ini_file($confFile);
if ($config === false) {
continue;
}
$path = $config['path'] ?? '';
$name = addslashes($config['name'] ?? 'Protected Area');
$pathHash = md5($path);
$htpasswdFile = "{$protectedDir}/{$pathHash}.htpasswd";
if (!file_exists($htpasswdFile)) {
continue;
}
// Escape path for nginx location
$escapedPath = preg_quote($path, '/');
$protectedBlocks .= <<&1", $testOutput, $testCode);
if ($testCode !== 0) {
return ['success' => false, 'error' => 'Nginx configuration test failed: ' . implode("\n", $testOutput)];
}
// Reload nginx
exec("systemctl reload nginx 2>&1", $reloadOutput, $reloadCode);
return [
'success' => $reloadCode === 0,
'error' => $reloadCode !== 0 ? 'Failed to reload nginx' : null,
];
}
main();
// ============ REDIS ACL MANAGEMENT ============
/**
* Get Redis admin credentials from config file
*/
function getRedisAdminCredentials(): array
{
$credFile = '/root/.jabali_redis_credentials';
if (!file_exists($credFile)) {
return ['error' => 'Redis credentials file not found'];
}
$content = file_get_contents($credFile);
$password = '';
if (preg_match('/REDIS_ADMIN_PASSWORD=(.+)/', $content, $matches)) {
$password = trim($matches[1]);
}
if (empty($password)) {
return ['error' => 'Redis admin password not found'];
}
return ['username' => 'jabali_admin', 'password' => $password];
}
/**
* Execute Redis ACL command
*/
function redisAclCommand(string $command): array
{
$creds = getRedisAdminCredentials();
if (isset($creds['error'])) {
return ['success' => false, 'error' => $creds['error']];
}
$escapedPassword = escapeshellarg($creds['password']);
// Use echo to pipe the command to redis-cli to avoid shell argument parsing issues
$escapedCommand = escapeshellarg($command);
$cmd = "echo {$escapedCommand} | redis-cli --user {$creds['username']} -a {$escapedPassword} --no-auth-warning 2>&1";
$output = [];
$returnCode = 0;
exec($cmd, $output, $returnCode);
return [
'success' => $returnCode === 0,
'output' => implode("\n", $output),
'return_code' => $returnCode
];
}
/**
* Create a Redis ACL user for a Jabali user
*/
function redisCreateUser(array $params): array
{
$username = $params['username'] ?? '';
$password = $params['password'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
if (empty($password) || strlen($password) < 16) {
return ['success' => false, 'error' => 'Password must be at least 16 characters'];
}
// Redis ACL user will be prefixed with 'jabali_'
$redisUser = 'jabali_' . $username;
// Check if user already exists
$checkResult = redisAclCommand("ACL GETUSER {$redisUser}");
if ($checkResult['success'] && strpos($checkResult['output'], 'ERR') === false) {
return ['success' => false, 'error' => 'Redis user already exists'];
}
// Create user with key pattern restriction (only keys prefixed with username:)
// Permissions: all commands except admin commands, restricted to keys matching pattern
$keyPattern = $username . ':*';
$aclRule = "ACL SETUSER {$redisUser} on >{$password} ~{$keyPattern} +@all -@admin -@dangerous";
$result = redisAclCommand($aclRule);
if (!$result['success'] || strpos($result['output'], 'ERR') !== false) {
return ['success' => false, 'error' => 'Failed to create Redis user: ' . $result['output']];
}
// Save ACL to disk
$saveResult = redisAclCommand("ACL SAVE");
if (!$saveResult['success']) {
// User was created but not saved - log warning but don't fail
error_log("Warning: Redis ACL created but not saved to disk");
}
return [
'success' => true,
'message' => 'Redis user created',
'redis_user' => $redisUser,
'key_prefix' => $username . ':'
];
}
/**
* Delete a Redis ACL user
*/
function redisDeleteUser(array $params): array
{
$username = $params['username'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$redisUser = 'jabali_' . $username;
// Check if user exists first
$checkResult = redisAclCommand("ACL GETUSER {$redisUser}");
if (!$checkResult['success'] || strpos($checkResult['output'], 'ERR') !== false) {
return ['success' => false, 'error' => 'Redis user does not exist'];
}
// Delete all keys with user's prefix first
$keyPattern = $username . ':*';
$creds = getRedisAdminCredentials();
if (!isset($creds['error'])) {
$escapedPassword = escapeshellarg($creds['password']);
exec("redis-cli --user {$creds['username']} -a {$escapedPassword} --no-auth-warning --scan --pattern " . escapeshellarg($keyPattern) . " | xargs -r redis-cli --user {$creds['username']} -a {$escapedPassword} --no-auth-warning DEL 2>/dev/null");
}
// Delete user
$result = redisAclCommand("ACL DELUSER {$redisUser}");
if (!$result['success'] || strpos($result['output'], 'ERR') !== false) {
return ['success' => false, 'error' => 'Failed to delete Redis user: ' . $result['output']];
}
// Save ACL to disk
redisAclCommand("ACL SAVE");
return ['success' => true, 'message' => 'Redis user deleted'];
}
/**
* Check if a Redis ACL user exists
*/
function redisUserExists(array $params): array
{
$username = $params['username'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$redisUser = 'jabali_' . $username;
$result = redisAclCommand("ACL GETUSER {$redisUser}");
$exists = $result['success'] && strpos($result['output'], 'ERR') === false;
return [
'success' => true,
'exists' => $exists,
'redis_user' => $redisUser
];
}
/**
* Change Redis user password
*/
function redisChangePassword(array $params): array
{
$username = $params['username'] ?? '';
$password = $params['password'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
if (empty($password) || strlen($password) < 16) {
return ['success' => false, 'error' => 'Password must be at least 16 characters'];
}
$redisUser = 'jabali_' . $username;
// Check if user exists
$checkResult = redisAclCommand("ACL GETUSER {$redisUser}");
if (!$checkResult['success'] || strpos($checkResult['output'], 'ERR') !== false) {
return ['success' => false, 'error' => 'Redis user does not exist'];
}
// Reset passwords and set new one
$result = redisAclCommand("ACL SETUSER {$redisUser} resetpass >{$password}");
if (!$result['success'] || strpos($result['output'], 'ERR') !== false) {
return ['success' => false, 'error' => 'Failed to change password: ' . $result['output']];
}
// Save ACL to disk
redisAclCommand("ACL SAVE");
return ['success' => true, 'message' => 'Redis password changed'];
}
/**
* Migrate existing Jabali users to have Redis ACL users
* Can migrate all users or a specific user
*/
function redisMigrateUsers(array $params): array
{
$specificUser = $params['username'] ?? '';
$migrated = [];
$skipped = [];
$failed = [];
// Get list of users to migrate
$users = [];
if (!empty($specificUser)) {
// Migrate specific user
if (!validateUsername($specificUser)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$users = [$specificUser];
} else {
// Migrate all users in /home
$homeDirs = glob('/home/*', GLOB_ONLYDIR);
foreach ($homeDirs as $homeDir) {
$username = basename($homeDir);
// Skip system/protected users
if (validateUsername($username) && !isProtectedUser($username)) {
// Check if it's actually a Jabali user (has domains directory)
if (is_dir("{$homeDir}/domains")) {
$users[] = $username;
}
}
}
}
foreach ($users as $username) {
$homeDir = "/home/{$username}";
$redisCredFile = "{$homeDir}/.redis_credentials";
// Check if user already has Redis credentials
if (file_exists($redisCredFile)) {
$skipped[] = ['username' => $username, 'reason' => 'Already has Redis credentials'];
continue;
}
// Check if Redis user already exists
$existsResult = redisUserExists(['username' => $username]);
if ($existsResult['success'] && $existsResult['exists']) {
$skipped[] = ['username' => $username, 'reason' => 'Redis user already exists'];
continue;
}
// Create Redis ACL user
$redisPassword = bin2hex(random_bytes(16)); // 32 char password
$createResult = redisCreateUser(['username' => $username, 'password' => $redisPassword]);
if (!$createResult['success']) {
$failed[] = ['username' => $username, 'error' => $createResult['error'] ?? 'Unknown error'];
continue;
}
// Store Redis credentials in user's home directory
$credContent = "REDIS_USER=jabali_{$username}\n" .
"REDIS_PASS={$redisPassword}\n" .
"REDIS_PREFIX={$username}:\n";
file_put_contents($redisCredFile, $credContent);
chmod($redisCredFile, 0600);
// Get user's UID/GID
$userInfo = posix_getpwnam($username);
if ($userInfo) {
chown($redisCredFile, $userInfo['uid']);
chgrp($redisCredFile, $userInfo['gid']);
}
$migrated[] = ['username' => $username, 'redis_user' => "jabali_{$username}"];
logger("Migrated Redis ACL for user: {$username}");
}
return [
'success' => true,
'message' => sprintf('Migration complete: %d migrated, %d skipped, %d failed',
count($migrated), count($skipped), count($failed)),
'migrated' => $migrated,
'skipped' => $skipped,
'failed' => $failed,
];
}
// ============ WORDPRESS MANAGEMENT ============
function wpInstall(array $params): array
{
$username = $params['username'] ?? '';
$domain = $params['domain'] ?? '';
$siteTitle = $params['site_title'] ?? 'My WordPress Site';
$adminUser = $params['admin_user'] ?? 'admin';
$adminPassword = $params['admin_password'] ?? '';
$adminEmail = $params['admin_email'] ?? '';
$path = $params['path'] ?? ''; // Subdirectory, empty for root
$useWww = $params['use_www'] ?? false;
$language = $params['language'] ?? 'en_US';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$userInfo = posix_getpwnam($username);
if (!$userInfo) {
return ['success' => false, 'error' => 'User not found'];
}
$uid = $userInfo['uid'];
$gid = $userInfo['gid'];
$userHome = $userInfo['dir'];
// Determine install path
$docRoot = "{$userHome}/domains/{$domain}/public_html";
$installPath = $path ? "{$docRoot}/{$path}" : $docRoot;
if (!is_dir($docRoot)) {
return ['success' => false, 'error' => 'Domain not found. Please create the domain first.'];
}
// Create subdirectory if needed
if ($path && !is_dir($installPath)) {
mkdir($installPath, 0755, true);
chown($installPath, $uid);
chgrp($installPath, $gid);
}
// Check if WordPress already installed
if (file_exists("{$installPath}/wp-config.php")) {
return ['success' => false, 'error' => 'WordPress is already installed at this location'];
}
// Generate database credentials
$dbName = $username . '_wp' . substr(md5($domain . $path . time()), 0, 6);
$dbUser = $dbName;
$dbPass = bin2hex(random_bytes(12));
// Truncate to MySQL limits
$dbName = substr($dbName, 0, 64);
$dbUser = substr($dbUser, 0, 32);
// Create database and user
$conn = getMysqlConnection();
if (!$conn) {
return ['success' => false, 'error' => 'Cannot connect to MySQL'];
}
$dbName = $conn->real_escape_string($dbName);
$dbUser = $conn->real_escape_string($dbUser);
// Create database
if (!$conn->query("CREATE DATABASE IF NOT EXISTS `{$dbName}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")) {
$conn->close();
return ['success' => false, 'error' => 'Failed to create database: ' . $conn->error];
}
// Create user and grant privileges
$conn->query("DROP USER IF EXISTS '{$dbUser}'@'localhost'");
if (!$conn->query("CREATE USER '{$dbUser}'@'localhost' IDENTIFIED BY '{$dbPass}'")) {
$conn->close();
return ['success' => false, 'error' => 'Failed to create database user: ' . $conn->error];
}
if (!$conn->query("GRANT ALL PRIVILEGES ON `{$dbName}`.* TO '{$dbUser}'@'localhost'")) {
$conn->close();
return ['success' => false, 'error' => 'Failed to grant privileges: ' . $conn->error];
}
$conn->query("FLUSH PRIVILEGES");
$conn->close();
// Download WordPress (always in English first for reliability)
$cmd = "cd " . escapeshellarg($installPath) . " && sudo -u " . escapeshellarg($username) . " wp core download 2>&1";
exec($cmd, $output, $returnCode);
if ($returnCode !== 0) {
return ['success' => false, 'error' => 'Failed to download WordPress: ' . implode("\n", $output)];
}
// Create wp-config.php
$urlDomain = $useWww ? "www.{$domain}" : $domain;
$siteUrl = "https://{$urlDomain}" . ($path ? "/{$path}" : "");
$cmd = "cd " . escapeshellarg($installPath) . " && sudo -u " . escapeshellarg($username) . " wp config create " .
"--dbname=" . escapeshellarg($dbName) . " " .
"--dbuser=" . escapeshellarg($dbUser) . " " .
"--dbpass=" . escapeshellarg($dbPass) . " " .
"--dbhost=localhost " .
"--dbcharset=utf8mb4 2>&1";
exec($cmd, $output2, $returnCode2);
if ($returnCode2 !== 0) {
return ['success' => false, 'error' => 'Failed to create wp-config.php: ' . implode("\n", $output2)];
}
// Add Redis cache configuration
$redisDb = abs(crc32($domain)) % 16;
$redisUser = 'wp_' . substr(md5($domain), 0, 8);
$redisPass = bin2hex(random_bytes(16));
$prefixBase = $domain . ($path ? '_' . $path : '');
$redisPrefix = 'jc_' . preg_replace('/[^a-z0-9]/', '_', strtolower($prefixBase)) . '_';
// Create Redis user with ACL (if Redis ACL is available)
$redisCredsFile = '/root/.jabali_redis_credentials';
if (file_exists($redisCredsFile)) {
// Get admin credentials
$redisCreds = parse_ini_file($redisCredsFile);
$adminPass = $redisCreds['REDIS_ADMIN_PASSWORD'] ?? null;
if ($adminPass) {
// Connect to Redis and create user
$redis = new Redis();
try {
$redis->connect('127.0.0.1', 6379);
$redis->auth(['user' => 'jabali_admin', 'pass' => $adminPass]);
// Create user with access only to their database and key prefix
// ACL: on (enabled), password, ~{prefix}* (key pattern), +@all (all commands)
$aclRule = "on >{$redisPass} ~{$redisPrefix}* +@all";
$redis->rawCommand('ACL', 'SETUSER', $redisUser, ...explode(' ', $aclRule));
$redis->rawCommand('ACL', 'SAVE');
$redis->close();
} catch (Exception $e) {
// Redis ACL not available or failed, will use without auth
$redisUser = '';
$redisPass = '';
}
}
} else {
// No Redis credentials file, skip authentication
$redisUser = '';
$redisPass = '';
}
$wpConfigFile = "{$installPath}/wp-config.php";
$wpConfig = file_get_contents($wpConfigFile);
// Add cache constants before "That's all, stop editing!"
$cacheConstants = "\n/** Jabali Cache - Redis configuration */\n";
$cacheConstants .= "define('JABALI_CACHE_DATABASE', {$redisDb});\n";
$cacheConstants .= "define('JABALI_CACHE_PREFIX', '{$redisPrefix}');\n";
if ($redisUser && $redisPass) {
$cacheConstants .= "define('JABALI_CACHE_REDIS_USER', '{$redisUser}');\n";
$cacheConstants .= "define('JABALI_CACHE_REDIS_PASS', '{$redisPass}');\n";
}
$wpConfig = str_replace(
"/* That's all, stop editing!",
$cacheConstants . "\n/* That's all, stop editing!",
$wpConfig
);
file_put_contents($wpConfigFile, $wpConfig);
chown($wpConfigFile, $uid);
chgrp($wpConfigFile, $gid);
// Install WordPress
$cmd = "cd " . escapeshellarg($installPath) . " && sudo -u " . escapeshellarg($username) . " wp core install " .
"--url=" . escapeshellarg($siteUrl) . " " .
"--title=" . escapeshellarg($siteTitle) . " " .
"--admin_user=" . escapeshellarg($adminUser) . " " .
"--admin_password=" . escapeshellarg($adminPassword) . " " .
"--admin_email=" . escapeshellarg($adminEmail) . " " .
"--skip-email 2>&1";
exec($cmd, $output3, $returnCode3);
if ($returnCode3 !== 0) {
return ['success' => false, 'error' => 'Failed to install WordPress: ' . implode("\n", $output3)];
}
// Install language pack if not English
if ($language && $language !== 'en_US') {
$cmd = "cd " . escapeshellarg($installPath) . " && sudo -u " . escapeshellarg($username) . " wp language core install " . escapeshellarg($language) . " 2>&1";
exec($cmd, $langOutput, $langReturnCode);
if ($langReturnCode === 0) {
// Switch site language
$cmd = "cd " . escapeshellarg($installPath) . " && sudo -u " . escapeshellarg($username) . " wp option update WPLANG " . escapeshellarg($language) . " 2>&1";
exec($cmd);
}
// If language install fails, continue anyway - site will be in English
}
// Set proper permissions
exec("chown -R " . escapeshellarg($username) . ":" . escapeshellarg($username) . " " . escapeshellarg($installPath));
exec("find " . escapeshellarg($installPath) . " -type d -exec chmod 755 {} \\;");
exec("find " . escapeshellarg($installPath) . " -type f -exec chmod 644 {} \\;");
// Store installation info
$wpListFile = "{$userHome}/.wordpress_sites";
$wpSites = [];
if (file_exists($wpListFile)) {
$wpSites = json_decode(file_get_contents($wpListFile), true) ?: [];
}
$siteId = md5($domain . $path);
// Get version and counts
$wpVersion = trim((string)shell_exec("cd " . escapeshellarg($installPath) . " && sudo -u " . escapeshellarg($username) . " wp core version 2>/dev/null") ?: 'Unknown');
$pluginCount = (int) trim((string)shell_exec("cd " . escapeshellarg($installPath) . " && sudo -u " . escapeshellarg($username) . " wp plugin list --format=count 2>/dev/null") ?: '0');
$themeCount = (int) trim((string)shell_exec("cd " . escapeshellarg($installPath) . " && sudo -u " . escapeshellarg($username) . " wp theme list --format=count 2>/dev/null") ?: '0');
$wpSites[$siteId] = [
'domain' => $domain,
'path' => $path,
'install_path' => $installPath,
'url' => $siteUrl,
'admin_user' => $adminUser,
'admin_email' => $adminEmail,
'db_name' => $dbName,
'db_user' => $dbUser,
'installed_at' => date('Y-m-d H:i:s'),
'version' => $wpVersion,
'plugin_count' => $pluginCount,
'theme_count' => $themeCount
];
file_put_contents($wpListFile, json_encode($wpSites, JSON_PRETTY_PRINT));
chown($wpListFile, $uid);
chgrp($wpListFile, $gid);
// Enable nginx page cache by default for all WordPress installations
// This is server-level caching that works independently of WordPress caching plugins
try {
wpPageCacheEnable(['username' => $username, 'domain' => $domain, 'site_id' => $siteId]);
logger("Enabled nginx page cache for WordPress site {$domain}");
} catch (Exception $e) {
// Page cache enable failed, but installation succeeded - continue
logger("Warning: Failed to enable page cache for {$domain}: " . $e->getMessage());
}
return [
'success' => true,
'site_id' => $siteId,
'url' => $siteUrl,
'admin_url' => "{$siteUrl}/wp-admin/",
'admin_user' => $adminUser,
'admin_password' => $adminPassword,
'db_name' => $dbName,
'db_user' => $dbUser,
'db_password' => $dbPass,
'message' => 'WordPress installed successfully'
];
}
function wpList(array $params): array
{
$username = $params['username'] ?? '';
$refresh = $params['refresh'] ?? false;
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'];
$wpListFile = "{$userHome}/.wordpress_sites";
$wpSites = [];
if (file_exists($wpListFile)) {
$wpSites = json_decode(file_get_contents($wpListFile), true) ?: [];
}
$sites = [];
foreach ($wpSites as $siteId => $site) {
$installPath = $site['install_path'] ?? '';
// Check if still exists
if (!$installPath || !file_exists("{$installPath}/wp-config.php")) {
continue;
}
// Check if Jabali Cache plugin is active
$cacheEnabled = false;
$activePluginsFile = "{$installPath}/wp-content/options-active_plugins.php";
if (file_exists($activePluginsFile)) {
// Check serialized active plugins
$content = @file_get_contents($activePluginsFile);
if ($content && strpos($content, 'jabali-cache') !== false) {
$cacheEnabled = true;
}
}
// Alternative: check if object-cache.php drop-in exists (indicates cache is active)
if (!$cacheEnabled && file_exists("{$installPath}/wp-content/object-cache.php")) {
$content = @file_get_contents("{$installPath}/wp-content/object-cache.php");
if ($content && strpos($content, 'Jabali Cache') !== false) {
$cacheEnabled = true;
}
}
// Also check if plugin directory exists and is linked
if (!$cacheEnabled && file_exists("{$installPath}/wp-content/plugins/jabali-cache/jabali-cache.php")) {
// Plugin exists, check if active via wp-cli or database
// For now, assume if drop-in doesn't exist but plugin does, cache is disabled
$cacheEnabled = false;
}
// Check for warnings (conflicting plugins, security issues, etc.)
$warnings = [];
// Check for known conflicting caching plugins
$conflictingPlugins = [
'w3-total-cache', 'wp-super-cache', 'wp-fastest-cache', 'litespeed-cache',
'wp-rocket', 'redis-cache', 'autoptimize', 'wp-optimize', 'hummingbird-performance',
'sg-cachepress', 'breeze', 'cache-enabler', 'comet-cache', 'powered-cache', 'hyper-cache'
];
$pluginsDir = "{$installPath}/wp-content/plugins";
if (is_dir($pluginsDir)) {
foreach ($conflictingPlugins as $conflictPlugin) {
if (is_dir("{$pluginsDir}/{$conflictPlugin}")) {
$warnings[] = [
'type' => 'conflict',
'message' => "Conflicting cache plugin detected: {$conflictPlugin}",
'severity' => 'warning',
];
}
}
}
// Check debug and auto-update status from wp-config.php
$debugEnabled = false;
$autoUpdate = $site['auto_update'] ?? false;
$wpConfigContent = @file_get_contents("{$installPath}/wp-config.php");
if ($wpConfigContent) {
if (preg_match("/define\s*\(\s*['\"]WP_DEBUG['\"]\s*,\s*true\s*\)/i", $wpConfigContent)) {
$debugEnabled = true;
}
if (preg_match("/define\s*\(\s*['\"]AUTOMATIC_UPDATER_DISABLED['\"]\s*,\s*false\s*\)/i", $wpConfigContent)) {
$autoUpdate = true;
} elseif (preg_match("/define\s*\(\s*['\"]WP_AUTO_UPDATE_CORE['\"]\s*,\s*true\s*\)/i", $wpConfigContent)) {
$autoUpdate = true;
}
}
$sites[] = [
'id' => $siteId,
'domain' => $site['domain'] ?? '',
'path' => $site['path'] ?? '',
'url' => $site['url'] ?? '',
'admin_url' => ($site['url'] ?? '') . '/wp-admin/',
'admin_user' => $site['admin_user'] ?? 'admin',
'admin_email' => $site['admin_email'] ?? '',
'version' => $site['version'] ?? 'Unknown',
'update_available' => false,
'plugin_count' => $site['plugin_count'] ?? 0,
'theme_count' => $site['theme_count'] ?? 0,
'installed_at' => $site['installed_at'] ?? '',
'db_name' => $site['db_name'] ?? '',
'cache_enabled' => $cacheEnabled,
'debug_enabled' => $debugEnabled,
'auto_update' => $autoUpdate,
'is_staging' => $site['is_staging'] ?? false,
'warnings' => $warnings,
];
}
return ['success' => true, 'sites' => $sites];
}
/**
* Scan user's home folder for WordPress installations that aren't tracked yet
*/
function wpScan(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'];
$wpListFile = "{$userHome}/.wordpress_sites";
// Get tracked sites
$trackedSites = [];
if (file_exists($wpListFile)) {
$trackedSites = json_decode(file_get_contents($wpListFile), true) ?: [];
}
// Build list of already tracked paths
$trackedPaths = [];
foreach ($trackedSites as $site) {
if (isset($site['install_path'])) {
$trackedPaths[] = realpath($site['install_path']);
}
}
// Scan for wp-config.php files in common locations
$scanDirs = [
$userHome,
"{$userHome}/public_html",
"{$userHome}/www",
"{$userHome}/sites",
];
// Also scan domain directories
$domainDirs = glob("{$userHome}/domains/*/public_html");
$scanDirs = array_merge($scanDirs, $domainDirs);
$found = [];
foreach ($scanDirs as $scanDir) {
if (!is_dir($scanDir)) {
continue;
}
// Find wp-config.php files (max depth 3)
$cmd = sprintf(
'find %s -maxdepth 3 -name "wp-config.php" -type f 2>/dev/null',
escapeshellarg($scanDir)
);
$output = [];
exec($cmd, $output);
foreach ($output as $configFile) {
$wpDir = dirname($configFile);
$realPath = realpath($wpDir);
// Skip if already tracked
if (in_array($realPath, $trackedPaths)) {
continue;
}
// Verify it's a valid WordPress installation
if (!file_exists("{$wpDir}/wp-includes/version.php")) {
continue;
}
// Get WordPress version
$version = 'Unknown';
$versionFile = "{$wpDir}/wp-includes/version.php";
if (file_exists($versionFile)) {
$content = file_get_contents($versionFile);
if (preg_match('/\$wp_version\s*=\s*[\'"]([^\'"]+)[\'"]/i', $content, $m)) {
$version = $m[1];
}
}
// Try to get site URL and DB info from wp-config.php
$configContent = file_get_contents($configFile);
$dbName = '';
$dbUser = '';
$tablePrefix = 'wp_';
$siteUrl = '';
if (preg_match('/define\s*\(\s*[\'"]DB_NAME[\'"]\s*,\s*[\'"]([^\'"]+)[\'"]\s*\)/i', $configContent, $m)) {
$dbName = $m[1];
}
if (preg_match('/define\s*\(\s*[\'"]DB_USER[\'"]\s*,\s*[\'"]([^\'"]+)[\'"]\s*\)/i', $configContent, $m)) {
$dbUser = $m[1];
}
if (preg_match('/\$table_prefix\s*=\s*[\'"]([^\'"]+)[\'"]/i', $configContent, $m)) {
$tablePrefix = $m[1];
}
if (preg_match('/define\s*\(\s*[\'"]WP_SITEURL[\'"]\s*,\s*[\'"]([^\'"]+)[\'"]\s*\)/i', $configContent, $m)) {
$siteUrl = $m[1];
} elseif (preg_match('/define\s*\(\s*[\'"]WP_HOME[\'"]\s*,\s*[\'"]([^\'"]+)[\'"]\s*\)/i', $configContent, $m)) {
$siteUrl = $m[1];
}
// Try to get site URL from database if not in config
if (empty($siteUrl) && !empty($dbName)) {
$siteUrl = wpGetSiteUrlFromDb($dbName, $tablePrefix);
}
// Determine relative path from home
$relativePath = str_replace($userHome, '', $wpDir);
$relativePath = ltrim($relativePath, '/');
$found[] = [
'path' => $wpDir,
'relative_path' => $relativePath,
'version' => $version,
'site_url' => $siteUrl,
'db_name' => $dbName,
'db_user' => $dbUser,
'table_prefix' => $tablePrefix,
];
}
}
// Remove duplicates by path
$uniqueFound = [];
$seenPaths = [];
foreach ($found as $site) {
$realPath = realpath($site['path']);
if (!in_array($realPath, $seenPaths)) {
$seenPaths[] = $realPath;
$uniqueFound[] = $site;
}
}
return ['success' => true, 'found' => $uniqueFound, 'count' => count($uniqueFound)];
}
/**
* Helper to get site URL from WordPress database
*/
function wpGetSiteUrlFromDb(string $dbName, string $tablePrefix): string
{
try {
$conn = getMysqlConnection();
if (!$conn) {
return '';
}
// Select the WordPress database
if (!$conn->select_db($dbName)) {
$conn->close();
return '';
}
$table = $conn->real_escape_string($tablePrefix . 'options');
$result = $conn->query("SELECT option_value FROM {$table} WHERE option_name = 'siteurl' LIMIT 1");
if ($result && $row = $result->fetch_assoc()) {
$siteUrl = $row['option_value'];
$conn->close();
return $siteUrl;
}
$conn->close();
} catch (Throwable $e) {
// Silently fail
}
return '';
}
/**
* Import a found WordPress installation into tracking
*/
function wpImport(array $params): array
{
$username = $params['username'] ?? '';
$path = $params['path'] ?? '';
$domain = $params['domain'] ?? '';
$sitePath = $params['site_path'] ?? '';
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'];
// Validate the path is within user's home
$realPath = realpath($path);
if (!$realPath || strpos($realPath, $userHome) !== 0) {
return ['success' => false, 'error' => 'Invalid path'];
}
// Check it's a valid WordPress installation
if (!file_exists("{$realPath}/wp-config.php") || !file_exists("{$realPath}/wp-includes/version.php")) {
return ['success' => false, 'error' => 'Not a valid WordPress installation'];
}
// Get WordPress version
$version = 'Unknown';
$versionFile = "{$realPath}/wp-includes/version.php";
if (file_exists($versionFile)) {
$content = file_get_contents($versionFile);
if (preg_match('/\$wp_version\s*=\s*[\'"]([^\'"]+)[\'"]/i', $content, $m)) {
$version = $m[1];
}
}
// Get DB info from wp-config.php
$configContent = file_get_contents("{$realPath}/wp-config.php");
$dbName = '';
$dbUser = '';
$tablePrefix = 'wp_';
if (preg_match('/define\s*\(\s*[\'"]DB_NAME[\'"]\s*,\s*[\'"]([^\'"]+)[\'"]\s*\)/i', $configContent, $m)) {
$dbName = $m[1];
}
if (preg_match('/define\s*\(\s*[\'"]DB_USER[\'"]\s*,\s*[\'"]([^\'"]+)[\'"]\s*\)/i', $configContent, $m)) {
$dbUser = $m[1];
}
if (preg_match('/\$table_prefix\s*=\s*[\'"]([^\'"]+)[\'"]/i', $configContent, $m)) {
$tablePrefix = $m[1];
}
// Get site URL - check wp-config.php first, then fall back to database
$siteUrl = '';
if (!empty($domain)) {
$siteUrl = 'https://' . $domain . ($sitePath ? '/' . ltrim($sitePath, '/') : '');
} else {
// First try WP_SITEURL from wp-config.php
if (preg_match('/define\s*\(\s*[\'"]WP_SITEURL[\'"]\s*,\s*[\'"]([^\'"]+)[\'"]\s*\)/i', $configContent, $m)) {
$siteUrl = $m[1];
}
// Then try WP_HOME from wp-config.php
elseif (preg_match('/define\s*\(\s*[\'"]WP_HOME[\'"]\s*,\s*[\'"]([^\'"]+)[\'"]\s*\)/i', $configContent, $m)) {
$siteUrl = $m[1];
}
// Fall back to database only if not found in wp-config.php
if (empty($siteUrl) && !empty($dbName)) {
$siteUrl = wpGetSiteUrlFromDb($dbName, $tablePrefix);
}
}
// Load existing sites
$wpListFile = "{$userHome}/.wordpress_sites";
$wpSites = [];
if (file_exists($wpListFile)) {
$wpSites = json_decode(file_get_contents($wpListFile), true) ?: [];
}
// Generate unique site ID
$siteId = 'wp_' . substr(md5($realPath . time()), 0, 12);
// Add new site
$wpSites[$siteId] = [
'domain' => $domain ?: parse_url($siteUrl, PHP_URL_HOST) ?: 'unknown',
'path' => $sitePath,
'url' => $siteUrl,
'admin_user' => 'admin',
'admin_email' => '',
'version' => $version,
'install_path' => $realPath,
'installed_at' => date('Y-m-d H:i:s'),
'db_name' => $dbName,
'db_user' => $dbUser,
'db_prefix' => $tablePrefix,
'imported' => true,
];
// Save
file_put_contents($wpListFile, json_encode($wpSites, JSON_PRETTY_PRINT));
chown($wpListFile, $userInfo['uid']);
chgrp($wpListFile, $userInfo['gid']);
chmod($wpListFile, 0600);
logger("Imported WordPress site at {$realPath} for user {$username}");
// Enable nginx page cache by default for imported WordPress sites
$importedDomain = $wpSites[$siteId]['domain'] ?? '';
if ($importedDomain && $importedDomain !== 'unknown') {
try {
wpPageCacheEnable(['username' => $username, 'domain' => $importedDomain, 'site_id' => $siteId]);
logger("Enabled nginx page cache for imported WordPress site {$importedDomain}");
} catch (Exception $e) {
// Page cache enable failed, but import succeeded - continue
logger("Warning: Failed to enable page cache for {$importedDomain}: " . $e->getMessage());
}
}
return [
'success' => true,
'site_id' => $siteId,
'message' => 'WordPress site imported successfully',
];
}
/**
* Enable Jabali Cache for a WordPress site
*/
function wpCacheEnable(array $params): array
{
$username = $params['username'] ?? '';
$siteId = $params['site_id'] ?? '';
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'];
$wpListFile = "{$userHome}/.wordpress_sites";
$wpSites = [];
if (file_exists($wpListFile)) {
$wpSites = json_decode(file_get_contents($wpListFile), true) ?: [];
}
if (!isset($wpSites[$siteId])) {
return ['success' => false, 'error' => 'WordPress site not found'];
}
$site = $wpSites[$siteId];
$installPath = $site['install_path'] ?? '';
$forceEnable = $params['force'] ?? false;
if (!$installPath || !is_dir($installPath)) {
return ['success' => false, 'error' => 'WordPress installation path not found'];
}
$wpContentDir = "{$installPath}/wp-content";
if (!is_dir($wpContentDir)) {
return ['success' => false, 'error' => 'wp-content directory not found'];
}
// List of known conflicting caching plugins
$conflictingPlugins = [
'w3-total-cache' => 'W3 Total Cache',
'wp-super-cache' => 'WP Super Cache',
'wp-fastest-cache' => 'WP Fastest Cache',
'litespeed-cache' => 'LiteSpeed Cache',
'wp-rocket' => 'WP Rocket',
'redis-cache' => 'Redis Object Cache',
'autoptimize' => 'Autoptimize',
'wp-optimize' => 'WP-Optimize',
'hummingbird-performance' => 'Hummingbird',
'sg-cachepress' => 'SG Optimizer',
'breeze' => 'Breeze',
'cache-enabler' => 'Cache Enabler',
'comet-cache' => 'Comet Cache',
'powered-cache' => 'Powered Cache',
'hyper-cache' => 'Hyper Cache',
];
// Check for active conflicting plugins
$foundConflicts = [];
$cmd = "cd " . escapeshellarg($installPath) . " && sudo -u " . escapeshellarg($username) . " wp plugin list --status=active --format=json 2>/dev/null";
exec($cmd, $output, $returnCode);
if ($returnCode === 0 && !empty($output)) {
$activePlugins = json_decode(implode('', $output), true);
if (is_array($activePlugins)) {
foreach ($activePlugins as $plugin) {
$pluginName = $plugin['name'] ?? '';
if (isset($conflictingPlugins[$pluginName])) {
$foundConflicts[] = [
'slug' => $pluginName,
'name' => $conflictingPlugins[$pluginName],
];
}
}
}
}
// If conflicts found and not forcing, return warning
if (!empty($foundConflicts) && !$forceEnable) {
return [
'success' => false,
'error' => 'Conflicting caching plugins detected',
'conflicts' => $foundConflicts,
'message' => 'Please disable the following plugins before enabling Jabali Cache: ' . implode(', ', array_column($foundConflicts, 'name')),
];
}
// Copy Jabali Cache plugin
$pluginSrc = '/var/www/jabali/resources/wordpress/jabali-cache';
$pluginDst = "{$wpContentDir}/plugins/jabali-cache";
if (!is_dir($pluginSrc)) {
return ['success' => false, 'error' => 'Jabali Cache plugin source not found'];
}
// Create plugin directory
if (!is_dir($pluginDst)) {
mkdir($pluginDst, 0755, true);
}
// Copy plugin files
foreach (['jabali-cache.php', 'object-cache.php', 'readme.txt'] as $file) {
if (file_exists("{$pluginSrc}/{$file}")) {
copy("{$pluginSrc}/{$file}", "{$pluginDst}/{$file}");
}
}
// Copy object-cache.php to wp-content
copy("{$pluginSrc}/object-cache.php", "{$wpContentDir}/object-cache.php");
// Read Redis credentials from user's home directory
$redisCredFile = "{$userHome}/.redis_credentials";
$redisUser = '';
$redisPass = '';
$redisPrefix = '';
if (file_exists($redisCredFile)) {
$credContent = file_get_contents($redisCredFile);
if (preg_match('/REDIS_USER=(.+)/', $credContent, $m)) $redisUser = trim($m[1]);
if (preg_match('/REDIS_PASS=(.+)/', $credContent, $m)) $redisPass = trim($m[1]);
if (preg_match('/REDIS_PREFIX=(.+)/', $credContent, $m)) $redisPrefix = trim($m[1]);
}
// Fallback prefix if Redis credentials not found
if (empty($redisPrefix)) {
$redisPrefix = $username . ':';
}
// Add site-specific suffix to prefix for multiple WordPress sites
$siteSuffix = substr(md5($installPath), 0, 8);
$cachePrefix = $redisPrefix . $siteSuffix . '_';
// Add cache constants to wp-config.php if not present
$wpConfigPath = "{$installPath}/wp-config.php";
if (file_exists($wpConfigPath)) {
$wpConfig = file_get_contents($wpConfigPath);
// Check if constants already exist
if (strpos($wpConfig, 'JABALI_CACHE_PREFIX') === false) {
$cacheConfig = "\n// Jabali Cache Configuration\n" .
"define('JABALI_CACHE_HOST', '127.0.0.1');\n" .
"define('JABALI_CACHE_PORT', 6379);\n" .
"define('JABALI_CACHE_PREFIX', '{$cachePrefix}');\n";
// Add Redis ACL credentials if available
if (!empty($redisUser) && !empty($redisPass)) {
$cacheConfig .= "define('JABALI_CACHE_REDIS_USER', '{$redisUser}');\n" .
"define('JABALI_CACHE_REDIS_PASS', '{$redisPass}');\n";
}
$cacheConfig .= "\n";
// Insert after opening PHP tag
$wpConfig = preg_replace('/^<\?php\s*/i', "enable_page_cache_on_activation();
// Ensure settings exist with page_cache enabled
$settings = get_option(Jabali_Cache_Plugin::OPTION_KEY);
if ($settings === false) {
update_option(Jabali_Cache_Plugin::OPTION_KEY, ['page_cache' => true]);
}
echo 'OK';
PHPSCRIPT;
$tempScript = tempnam('/tmp', 'jabali_activate_');
file_put_contents($tempScript, $phpScript);
chmod($tempScript, 0644);
$cmd = "cd " . escapeshellarg($installPath) . " && sudo -u " . escapeshellarg($username) . " php " . escapeshellarg($tempScript) . " 2>&1";
exec($cmd, $output, $returnCode);
unlink($tempScript);
$activationOk = ($returnCode === 0 && in_array('OK', $output));
// Also enable nginx page cache directly (in case the plugin's API call fails)
$domain = $site['domain'] ?? '';
if (!empty($domain)) {
wpPageCacheEnable(['username' => $username, 'domain' => $domain, 'site_id' => $siteId]);
}
// Update site record
$wpSites[$siteId]['cache_enabled'] = true;
$wpSites[$siteId]['cache_prefix'] = $cachePrefix;
file_put_contents($wpListFile, json_encode($wpSites, JSON_PRETTY_PRINT));
logger("Enabled Jabali Cache for WordPress site {$siteId} (user: {$username})");
return [
'success' => true,
'message' => 'Jabali Cache enabled and activated',
'cache_prefix' => $cachePrefix,
'plugin_activated' => $activationOk,
];
}
/**
* Disable Jabali Cache for a WordPress site
*/
function wpCacheDisable(array $params): array
{
$username = $params['username'] ?? '';
$siteId = $params['site_id'] ?? '';
$removePlugin = $params['remove_plugin'] ?? false;
$resetData = $params['reset_data'] ?? false;
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'];
$wpListFile = "{$userHome}/.wordpress_sites";
$wpSites = [];
if (file_exists($wpListFile)) {
$wpSites = json_decode(file_get_contents($wpListFile), true) ?: [];
}
if (!isset($wpSites[$siteId])) {
return ['success' => false, 'error' => 'WordPress site not found'];
}
$site = $wpSites[$siteId];
$installPath = $site['install_path'] ?? '';
if (!$installPath || !is_dir($installPath)) {
return ['success' => false, 'error' => 'WordPress installation path not found'];
}
$wpContentDir = "{$installPath}/wp-content";
// Deactivate the plugin using PHP (WP-CLI has issues with this plugin)
$phpScript = <<<'PHPSCRIPT'
disable_page_cache_on_deactivation();
$p->disable_drop_in();
}
// Remove from active_plugins
$active = get_option('active_plugins', []);
$plugin = 'jabali-cache/jabali-cache.php';
$active = array_diff($active, [$plugin]);
update_option('active_plugins', array_values($active));
echo 'OK';
PHPSCRIPT;
$tempScript = tempnam('/tmp', 'jabali_deactivate_');
file_put_contents($tempScript, $phpScript);
chmod($tempScript, 0644);
$cmd = "cd " . escapeshellarg($installPath) . " && sudo -u " . escapeshellarg($username) . " php " . escapeshellarg($tempScript) . " 2>&1";
exec($cmd, $output, $returnCode);
unlink($tempScript);
// Remove object-cache.php drop-in
$objectCachePath = "{$wpContentDir}/object-cache.php";
if (file_exists($objectCachePath)) {
unlink($objectCachePath);
}
// Also disable nginx page cache directly (in case the plugin's API call fails)
$domain = $site['domain'] ?? '';
if (!empty($domain)) {
wpPageCacheDisable(['username' => $username, 'domain' => $domain, 'site_id' => $siteId]);
}
// Remove plugin if requested
if ($removePlugin) {
$pluginDir = "{$wpContentDir}/plugins/jabali-cache";
if (is_dir($pluginDir)) {
foreach (glob("{$pluginDir}/*") as $file) {
unlink($file);
}
rmdir($pluginDir);
}
// Delete plugin settings from database if requested
if ($resetData) {
$resetScript = <<<'PHPSCRIPT'
query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_jabali_cache%'");
$wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_timeout_jabali_cache%'");
echo 'OK';
PHPSCRIPT;
$tempResetScript = tempnam('/tmp', 'jabali_reset_');
file_put_contents($tempResetScript, $resetScript);
chmod($tempResetScript, 0644);
$cmd = "cd " . escapeshellarg($installPath) . " && sudo -u " . escapeshellarg($username) . " php " . escapeshellarg($tempResetScript) . " 2>&1";
exec($cmd, $resetOutput, $resetCode);
unlink($tempResetScript);
}
}
// Update site record
$wpSites[$siteId]['cache_enabled'] = false;
file_put_contents($wpListFile, json_encode($wpSites, JSON_PRETTY_PRINT));
logger("Disabled Jabali Cache for WordPress site {$siteId} (user: {$username})");
return [
'success' => true,
'message' => 'Jabali Cache disabled',
];
}
/**
* Flush WordPress object cache
*/
function wpCacheFlush(array $params): array
{
$username = $params['username'] ?? '';
$siteId = $params['site_id'] ?? '';
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'];
$wpListFile = "{$userHome}/.wordpress_sites";
$wpSites = [];
if (file_exists($wpListFile)) {
$wpSites = json_decode(file_get_contents($wpListFile), true) ?: [];
}
if (!isset($wpSites[$siteId])) {
return ['success' => false, 'error' => 'WordPress site not found'];
}
$site = $wpSites[$siteId];
$installPath = $site['install_path'] ?? '';
$cachePrefix = $site['cache_prefix'] ?? '';
if (!$installPath || !is_dir($installPath)) {
return ['success' => false, 'error' => 'WordPress installation path not found'];
}
// Use WP-CLI to flush cache
$cmd = "cd " . escapeshellarg($installPath) . " && sudo -u " . escapeshellarg($username) . " wp cache flush 2>&1";
exec($cmd, $output, $returnCode);
if ($returnCode !== 0) {
// Fallback: flush directly via Redis if WP-CLI fails
if (!empty($cachePrefix)) {
try {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379, 1);
$keys = $redis->keys($cachePrefix . '*');
if (!empty($keys)) {
$redis->del($keys);
}
$redis->close();
logger("Flushed Redis cache for WordPress site {$siteId} (prefix: {$cachePrefix})");
return [
'success' => true,
'message' => 'Cache flushed via Redis',
'keys_deleted' => count($keys),
];
} catch (Exception $e) {
return ['success' => false, 'error' => 'Failed to flush cache: ' . $e->getMessage()];
}
}
return ['success' => false, 'error' => 'Failed to flush cache: ' . implode("\n", $output)];
}
logger("Flushed WordPress cache for site {$siteId} (user: {$username})");
return [
'success' => true,
'message' => 'Cache flushed successfully',
];
}
/**
* Get WordPress cache status
*/
function wpCacheStatus(array $params): array
{
$username = $params['username'] ?? '';
$siteId = $params['site_id'] ?? '';
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'];
$wpListFile = "{$userHome}/.wordpress_sites";
$wpSites = [];
if (file_exists($wpListFile)) {
$wpSites = json_decode(file_get_contents($wpListFile), true) ?: [];
}
if (!isset($wpSites[$siteId])) {
return ['success' => false, 'error' => 'WordPress site not found'];
}
$site = $wpSites[$siteId];
$installPath = $site['install_path'] ?? '';
$cachePrefix = $site['cache_prefix'] ?? '';
if (!$installPath || !is_dir($installPath)) {
return ['success' => false, 'error' => 'WordPress installation path not found'];
}
$wpContentDir = "{$installPath}/wp-content";
$status = [
'enabled' => $site['cache_enabled'] ?? false,
'drop_in_installed' => file_exists("{$wpContentDir}/object-cache.php"),
'plugin_installed' => is_dir("{$wpContentDir}/plugins/jabali-cache"),
'cache_prefix' => $cachePrefix,
'redis_connected' => false,
'cached_keys' => 0,
];
// Check Redis connection and key count
if (!empty($cachePrefix)) {
try {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379, 1);
$status['redis_connected'] = true;
$keys = $redis->keys($cachePrefix . '*');
$status['cached_keys'] = is_array($keys) ? count($keys) : 0;
$redis->close();
} catch (Exception $e) {
// Redis not available
}
}
return [
'success' => true,
'status' => $status,
];
}
/**
* Enable nginx page cache for a domain
*/
function wpPageCacheEnable(array $params): array
{
$username = $params['username'] ?? '';
$domain = $params['domain'] ?? '';
$siteId = $params['site_id'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
if (empty($domain)) {
return ['success' => false, 'error' => 'Domain is required'];
}
// Validate domain format
if (!preg_match('/^[a-zA-Z0-9][a-zA-Z0-9-]*(\.[a-zA-Z0-9-]+)+$/', $domain)) {
return ['success' => false, 'error' => 'Invalid domain format'];
}
$configFile = "/etc/nginx/sites-available/{$domain}.conf";
if (!file_exists($configFile)) {
return ['success' => false, 'error' => 'Nginx config not found for domain'];
}
$config = file_get_contents($configFile);
// Check if page cache is already enabled
if (strpos($config, 'fastcgi_cache JABALI') !== false) {
return ['success' => true, 'message' => 'Page cache already enabled'];
}
// Create cache include block
$cacheBlock = <<<'CACHE'
# Jabali Page Cache v2.3
set $skip_cache 0;
set $cache_reason "";
# Skip cache for POST requests
if ($request_method = POST) {
set $skip_cache 1;
set $cache_reason "method";
}
# Normalize query strings - ignore marketing params for better cache hit rate
# UTM params, click IDs, affiliate params are stripped from cache key
set $normalized_args "";
if ($args ~* "^(.*)(?:utm_source|utm_medium|utm_campaign|utm_term|utm_content|gclid|fbclid|msclkid|mc_cid|mc_eid|_ga|ref|affiliate)=") {
set $normalized_args "";
}
if ($args !~* "(?:utm_source|utm_medium|utm_campaign|utm_term|utm_content|gclid|fbclid|msclkid|mc_cid|mc_eid|_ga|ref|affiliate)") {
set $normalized_args $args;
}
# Skip cache for meaningful query strings (search, pagination, filters)
if ($normalized_args ~* "s=|page=|filter|sort|order|product-page|add-to-cart") {
set $skip_cache 1;
set $cache_reason "query_string";
}
# Skip cache for logged-in users and recent commenters
if ($http_cookie ~* "wordpress_logged_in|wp-postpass|woocommerce_cart_hash|woocommerce_items_in_cart") {
set $skip_cache 1;
set $cache_reason "logged_in";
}
# Skip cache for specific URLs (admin, login, API, cart, checkout, account)
if ($request_uri ~* "/wp-admin/|/wp-login.php|/xmlrpc.php|/wp-cron.php|/wp-json/|/feed/|/cart/|/checkout/|/my-account/") {
set $skip_cache 1;
set $cache_reason "admin_url";
}
# Browser caching for static assets (1 year, immutable for versioned files)
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|webp|avif|mp4|webm|ogg|mp3|wav|pdf|zip)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}
# Dynamic content directories - don't cache 404s
# Covers ALL plugins that generate CSS/JS/assets on-demand
# (Spectra, Elementor, Autoptimize, WP Rocket, etc.)
location ~* ^/wp-content/(uploads|cache)/ {
try_files $uri @dynamic_asset_fallback;
expires 1y;
add_header Cache-Control "public, max-age=31536000";
access_log off;
}
# Fallback for dynamically generated assets - pass to PHP for generation
location @dynamic_asset_fallback {
try_files /index.php =404;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
fastcgi_pass unix:/run/php/php-fpm.sock;
# Don't cache - may be 404 or trigger generation
fastcgi_cache_bypass 1;
fastcgi_no_cache 1;
}
CACHE;
// Add cache directives to PHP location block
$phpCacheDirectives = <<<'PHPCACHE'
# Page Cache
fastcgi_cache JABALI;
fastcgi_cache_valid 200 301 302 60m;
fastcgi_cache_valid 404 1m;
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
add_header X-Cache $upstream_cache_status;
add_header X-Cache-Reason $cache_reason;
# bfcache-compatible: no-store breaks back/forward cache
add_header Cache-Control "public, no-cache";
PHPCACHE;
// Insert cache block after root directive in HTTPS server block
// Use preg_replace_callback to avoid $ and {} being interpreted as backreferences
$config = preg_replace_callback(
'/(listen 443 ssl;[^}]*?root [^;]+;)/s',
function($matches) use ($cacheBlock) {
return $matches[1] . $cacheBlock;
},
$config,
1
);
// Insert cache directives after include fastcgi_params in PHP location
$config = preg_replace_callback(
'/(location\s+~\s+\\\\.php\$\s*\{[^}]*?include\s+fastcgi_params;)/s',
function($matches) use ($phpCacheDirectives) {
return $matches[1] . $phpCacheDirectives;
},
$config
);
// Backup original config
copy($configFile, $configFile . '.bak');
// Write new config
if (file_put_contents($configFile, $config) === false) {
return ['success' => false, 'error' => 'Failed to write nginx config'];
}
// Test nginx config
exec('nginx -t 2>&1', $output, $exitCode);
if ($exitCode !== 0) {
// Restore backup
copy($configFile . '.bak', $configFile);
return ['success' => false, 'error' => 'Nginx config test failed: ' . implode(' ', $output)];
}
// Reload nginx
exec('systemctl reload nginx 2>&1', $output, $exitCode);
if ($exitCode !== 0) {
return ['success' => false, 'error' => 'Failed to reload nginx'];
}
// Also enable gzip compression in nginx.conf if not already enabled
nginxEnableCompression([]);
// Update WordPress site record if siteId provided
if (!empty($siteId)) {
$userInfo = posix_getpwnam($username);
if ($userInfo) {
$wpListFile = $userInfo['dir'] . '/.wordpress_sites';
if (file_exists($wpListFile)) {
$wpSites = json_decode(file_get_contents($wpListFile), true) ?: [];
if (isset($wpSites[$siteId])) {
$wpSites[$siteId]['page_cache_enabled'] = true;
file_put_contents($wpListFile, json_encode($wpSites, JSON_PRETTY_PRINT));
}
}
}
}
logger("Enabled page cache for domain: {$domain}");
return ['success' => true, 'message' => 'Page cache enabled'];
}
/**
* Disable nginx page cache for a domain
*/
function wpPageCacheDisable(array $params): array
{
$username = $params['username'] ?? '';
$domain = $params['domain'] ?? '';
$siteId = $params['site_id'] ?? '';
if (empty($domain)) {
return ['success' => false, 'error' => 'Domain is required'];
}
$configFile = "/etc/nginx/sites-available/{$domain}.conf";
if (!file_exists($configFile)) {
return ['success' => false, 'error' => 'Nginx config not found for domain'];
}
$config = file_get_contents($configFile);
// Check if page cache is enabled
if (strpos($config, 'fastcgi_cache JABALI') === false) {
return ['success' => true, 'message' => 'Page cache not enabled'];
}
// Remove server-level cache block (v2.3 format)
// From "# Jabali Page Cache" to just before "# Symlink protection" or "disable_symlinks" or "ssl_certificate"
$config = preg_replace(
'/\n\s*# Jabali Page Cache[^\n]*\n.*?(?=\n\s*(?:# Symlink protection|disable_symlinks|ssl_certificate))/s',
"\n",
$config
);
// Remove static asset location block if it was added by cache config
// (Browser caching for static assets)
$config = preg_replace(
'/\n\s*# Browser caching for static assets[^\n]*\n\s*location ~\*.*?access_log off;\s*\}\s*\n/s',
"\n",
$config
);
// Remove dynamic content directories location block
$config = preg_replace(
'/\n\s*# Dynamic content directories[^\n]*\n.*?access_log off;\s*\}\s*\n/s',
"\n",
$config
);
// Remove fallback location block
$config = preg_replace(
'/\n\s*# Fallback for dynamically generated assets[^\n]*\n\s*location @dynamic_asset_fallback\s*\{.*?\}\s*\n/s',
"\n",
$config
);
// Remove cache directives from PHP block line by line
// This handles all formats (v2.1, v2.2, v2.3)
$cacheLines = [
'/^\s*# Page Cache\s*$/m',
'/^\s*# bfcache-compatible:.*$/m',
'/^\s*fastcgi_cache\s+JABALI\s*;/m',
'/^\s*fastcgi_cache_valid\s+[^;]+;/m',
'/^\s*fastcgi_cache_bypass\s+\$skip_cache\s*;/m',
'/^\s*fastcgi_no_cache\s+\$skip_cache\s*;/m',
'/^\s*add_header\s+X-Cache\s+\$upstream_cache_status\s*;/m',
'/^\s*add_header\s+X-Cache-Reason\s+\$cache_reason\s*;/m',
'/^\s*add_header\s+X-Cache-Key\s+[^;]+;/m',
'/^\s*add_header\s+Cache-Control\s+"public,\s*no-cache"\s*;/m',
];
foreach ($cacheLines as $pattern) {
$config = preg_replace($pattern, '', $config);
}
// Clean up multiple blank lines
$config = preg_replace('/\n{3,}/', "\n\n", $config);
// Backup original config
copy($configFile, $configFile . '.bak');
// Write new config
if (file_put_contents($configFile, $config) === false) {
return ['success' => false, 'error' => 'Failed to write nginx config'];
}
// Test nginx config
exec('nginx -t 2>&1', $output, $exitCode);
if ($exitCode !== 0) {
// Restore backup
copy($configFile . '.bak', $configFile);
return ['success' => false, 'error' => 'Nginx config test failed: ' . implode(' ', $output)];
}
// Reload nginx
exec('systemctl reload nginx 2>&1', $output, $exitCode);
if ($exitCode !== 0) {
return ['success' => false, 'error' => 'Failed to reload nginx'];
}
// Update WordPress site record if siteId provided
if (!empty($siteId) && !empty($username) && validateUsername($username)) {
$userInfo = posix_getpwnam($username);
if ($userInfo) {
$wpListFile = $userInfo['dir'] . '/.wordpress_sites';
if (file_exists($wpListFile)) {
$wpSites = json_decode(file_get_contents($wpListFile), true) ?: [];
if (isset($wpSites[$siteId])) {
$wpSites[$siteId]['page_cache_enabled'] = false;
file_put_contents($wpListFile, json_encode($wpSites, JSON_PRETTY_PRINT));
}
}
}
}
logger("Disabled page cache for domain: {$domain}");
return ['success' => true, 'message' => 'Page cache disabled'];
}
/**
* Purge nginx page cache for a domain
* Supports single path, array of paths, or full domain purge
*/
function wpPageCachePurge(array $params): array
{
$domain = $params['domain'] ?? '';
$path = $params['path'] ?? ''; // Optional: single path to purge
$paths = $params['paths'] ?? []; // Optional: array of paths to purge
if (empty($domain)) {
return ['success' => false, 'error' => 'Domain is required'];
}
$cacheDir = '/var/cache/nginx/fastcgi';
if (!is_dir($cacheDir)) {
return ['success' => false, 'error' => 'Cache directory not found'];
}
$purged = 0;
// Combine single path with paths array if both provided
$pathsToCheck = $paths;
if (!empty($path)) {
$pathsToCheck[] = $path;
}
$pathsToCheck = array_unique($pathsToCheck);
// Generate cache key pattern to find cached files
// The cache key is: $scheme$request_method$host$request_uri
// Files are stored in levels=1:2 subdirectories based on MD5 hash
if (empty($pathsToCheck)) {
// Purge all cache for this domain
// We need to scan all cache files and check the key inside
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($cacheDir, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iterator as $file) {
if ($file->isFile()) {
$content = file_get_contents($file->getPathname(), false, null, 0, 2048);
// Check if this cache file is for our domain
if (strpos($content, $domain) !== false) {
if (unlink($file->getPathname())) {
$purged++;
}
}
}
}
logger("Purged all {$purged} cache files for domain: {$domain}");
} else {
// Purge specific paths - calculate the cache file location for each
$schemes = ['https', 'http'];
foreach ($pathsToCheck as $pathToPurge) {
// Ensure path starts with /
if (!str_starts_with($pathToPurge, '/')) {
$pathToPurge = '/' . $pathToPurge;
}
foreach ($schemes as $scheme) {
// Standard cache key
$key = $scheme . 'GET' . $domain . $pathToPurge;
$hash = md5($key);
$cacheFile = $cacheDir . '/' . substr($hash, -1) . '/' . substr($hash, -3, 2) . '/' . $hash;
if (file_exists($cacheFile)) {
if (unlink($cacheFile)) {
$purged++;
}
}
// Also try with trailing slash variants
if (substr($pathToPurge, -1) !== '/') {
$keyWithSlash = $scheme . 'GET' . $domain . $pathToPurge . '/';
$hashWithSlash = md5($keyWithSlash);
$cacheFileWithSlash = $cacheDir . '/' . substr($hashWithSlash, -1) . '/' . substr($hashWithSlash, -3, 2) . '/' . $hashWithSlash;
if (file_exists($cacheFileWithSlash)) {
if (unlink($cacheFileWithSlash)) {
$purged++;
}
}
}
}
}
logger("Purged {$purged} cache files for " . count($pathsToCheck) . " paths on domain: {$domain}");
}
return [
'success' => true,
'message' => "Purged {$purged} cached files",
'purged_count' => $purged,
'paths_checked' => count($pathsToCheck) ?: 'all',
];
}
/**
* Get page cache status for a domain
*/
function wpPageCacheStatus(array $params): array
{
$username = $params['username'] ?? '';
$domain = $params['domain'] ?? '';
$siteId = $params['site_id'] ?? '';
if (empty($domain)) {
return ['success' => false, 'error' => 'Domain is required'];
}
$configFile = "/etc/nginx/sites-available/{$domain}.conf";
$enabled = false;
if (file_exists($configFile)) {
$config = file_get_contents($configFile);
$enabled = strpos($config, 'fastcgi_cache JABALI') !== false;
}
// Count cached files for this domain
$cachedFiles = 0;
$cacheSize = 0;
$cacheDir = '/var/cache/nginx/fastcgi';
if (is_dir($cacheDir) && $enabled) {
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($cacheDir, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iterator as $file) {
if ($file->isFile()) {
$content = file_get_contents($file->getPathname(), false, null, 0, 2048);
if (strpos($content, $domain) !== false) {
$cachedFiles++;
$cacheSize += $file->getSize();
}
}
}
}
return [
'success' => true,
'enabled' => $enabled,
'cached_files' => $cachedFiles,
'cache_size' => $cacheSize,
'cache_size_human' => formatBytes($cacheSize),
];
}
function wpDelete(array $params): array
{
$username = $params['username'] ?? '';
$siteId = $params['site_id'] ?? '';
$deleteFiles = $params['delete_files'] ?? true;
$deleteDatabase = $params['delete_database'] ?? true;
logger("wpDelete called: username={$username}, siteId={$siteId}, deleteFiles=" . ($deleteFiles ? 'true' : 'false') . ", deleteDatabase=" . ($deleteDatabase ? 'true' : 'false'));
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'];
$wpListFile = "{$userHome}/.wordpress_sites";
$wpSites = [];
if (file_exists($wpListFile)) {
$wpSites = json_decode(file_get_contents($wpListFile), true) ?: [];
}
if (!isset($wpSites[$siteId])) {
return ['success' => false, 'error' => 'WordPress site not found'];
}
$site = $wpSites[$siteId];
// Delete database if requested
$dbDeleted = false;
if ($deleteDatabase && !empty($site['db_name']) && !empty($site['db_user'])) {
$conn = getMysqlConnection();
if ($conn) {
$dbName = $conn->real_escape_string($site['db_name']);
$dbUser = $conn->real_escape_string($site['db_user']);
if ($conn->query("DROP DATABASE IF EXISTS `{$dbName}`")) {
$dbDeleted = true;
logger("Dropped database: {$dbName}");
} else {
logger("Failed to drop database {$dbName}: " . $conn->error);
}
if ($conn->query("DROP USER IF EXISTS '{$dbUser}'@'localhost'")) {
logger("Dropped user: {$dbUser}");
} else {
logger("Failed to drop user {$dbUser}: " . $conn->error);
}
$conn->query("FLUSH PRIVILEGES");
$conn->close();
} else {
logger("Failed to connect to MySQL for database deletion");
}
}
// Delete files if requested
if ($deleteFiles && !empty($site['install_path'])) {
$installPath = $site['install_path'];
// Safety check - make sure it's within user's domain folder
if (strpos($installPath, "{$userHome}/domains/") === 0) {
// If it's the root of public_html, just delete WP files, not the folder
if (preg_match('#/public_html$#', $installPath)) {
exec("rm -rf " . escapeshellarg($installPath) . "/wp-* " . escapeshellarg($installPath) . "/license.txt " . escapeshellarg($installPath) . "/readme.html " . escapeshellarg($installPath) . "/xmlrpc.php " . escapeshellarg($installPath) . "/index.php");
} else {
exec("rm -rf " . escapeshellarg($installPath));
}
}
}
// Remove from list
unset($wpSites[$siteId]);
file_put_contents($wpListFile, json_encode($wpSites, JSON_PRETTY_PRINT));
return ['success' => true, 'message' => 'WordPress site deleted successfully'];
}
function wpAutoLogin(array $params): array
{
$username = $params['username'] ?? '';
$siteId = $params['site_id'] ?? '';
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'];
$wpListFile = "{$userHome}/.wordpress_sites";
$wpSites = [];
if (file_exists($wpListFile)) {
$wpSites = json_decode(file_get_contents($wpListFile), true) ?: [];
}
if (!isset($wpSites[$siteId])) {
return ['success' => false, 'error' => 'WordPress site not found'];
}
$site = $wpSites[$siteId];
$installPath = $site['install_path'];
$adminUser = $site['admin_user'];
// Skip WP-CLI login package (slow) - use direct fallback method
// Generate secure auto-login token
$loginUrl = ''; // Always use fallback
if (true) {
// Fallback: Create a temporary auto-login file
$token = bin2hex(random_bytes(32));
$expiry = time() + 300; // 5 minute expiry
$adminUrl = $site['url'] . '/wp-admin/';
$autoLoginContent = ' $expiry || !isset($_GET["token"]) || $_GET["token"] !== $token) {
@unlink(__FILE__);
die("Login link expired or invalid.");
}
require_once(dirname(__FILE__) . "/wp-load.php");
$user = get_user_by("login", $admin_user);
if ($user) {
wp_set_auth_cookie($user->ID, true);
@unlink(__FILE__);
wp_redirect($admin_url);
exit;
}
@unlink(__FILE__);
die("User not found.");
';
$autoLoginFile = "{$installPath}/jabali-auto-login-{$token}.php";
// Write file as the user using sudo
$tempFile = "/tmp/jabali-auto-login-{$token}.php";
file_put_contents($tempFile, $autoLoginContent);
exec("sudo -u " . escapeshellarg($username) . " cp " . escapeshellarg($tempFile) . " " . escapeshellarg($autoLoginFile));
exec("sudo -u " . escapeshellarg($username) . " chmod 644 " . escapeshellarg($autoLoginFile));
@unlink($tempFile);
$loginUrl = $site['url'] . "/jabali-auto-login-{$token}.php?token={$token}";
}
return ['success' => true, 'login_url' => $loginUrl];
}
function wpUpdate(array $params): array
{
$username = $params['username'] ?? '';
$siteId = $params['site_id'] ?? '';
$updateType = $params['type'] ?? 'core'; // core, plugins, themes, all
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'];
$wpListFile = "{$userHome}/.wordpress_sites";
$wpSites = [];
if (file_exists($wpListFile)) {
$wpSites = json_decode(file_get_contents($wpListFile), true) ?: [];
}
if (!isset($wpSites[$siteId])) {
return ['success' => false, 'error' => 'WordPress site not found'];
}
$site = $wpSites[$siteId];
$installPath = $site['install_path'];
$results = [];
if ($updateType === 'core' || $updateType === 'all') {
exec("cd " . escapeshellarg($installPath) . " && sudo -u " . escapeshellarg($username) . " wp core update 2>&1", $output, $code);
$results['core'] = $code === 0 ? 'Updated' : implode("\n", $output);
}
if ($updateType === 'plugins' || $updateType === 'all') {
exec("cd " . escapeshellarg($installPath) . " && sudo -u " . escapeshellarg($username) . " wp plugin update --all 2>&1", $output2, $code2);
$results['plugins'] = $code2 === 0 ? 'Updated' : implode("\n", $output2);
}
if ($updateType === 'themes' || $updateType === 'all') {
exec("cd " . escapeshellarg($installPath) . " && sudo -u " . escapeshellarg($username) . " wp theme update --all 2>&1", $output3, $code3);
$results['themes'] = $code3 === 0 ? 'Updated' : implode("\n", $output3);
}
// Update version in our records
$newVersion = trim((string)shell_exec("cd " . escapeshellarg($installPath) . " && sudo -u " . escapeshellarg($username) . " wp core version 2>/dev/null") ?: 'Unknown');
$wpSites[$siteId]['version'] = $newVersion;
file_put_contents($wpListFile, json_encode($wpSites, JSON_PRETTY_PRINT));
return ['success' => true, 'results' => $results, 'new_version' => $newVersion];
}
function wpToggleDebug(array $params): array
{
$username = $params['username'] ?? '';
$siteId = $params['site_id'] ?? '';
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'];
$wpListFile = "{$userHome}/.wordpress_sites";
$wpSites = [];
if (file_exists($wpListFile)) {
$wpSites = json_decode(file_get_contents($wpListFile), true) ?: [];
}
if (!isset($wpSites[$siteId])) {
return ['success' => false, 'error' => 'WordPress site not found'];
}
$site = $wpSites[$siteId];
$wpConfigPath = $site['install_path'] . '/wp-config.php';
if (!file_exists($wpConfigPath)) {
return ['success' => false, 'error' => 'wp-config.php not found'];
}
$wpConfig = file_get_contents($wpConfigPath);
// Check current debug status
$currentDebug = false;
if (preg_match("/define\s*\(\s*['\"]WP_DEBUG['\"]\s*,\s*(true|false)\s*\)/i", $wpConfig, $matches)) {
$currentDebug = strtolower($matches[1]) === 'true';
}
$newDebug = !$currentDebug;
$newDebugValue = $newDebug ? 'true' : 'false';
// Update WP_DEBUG
if (preg_match("/define\s*\(\s*['\"]WP_DEBUG['\"]\s*,\s*(true|false)\s*\)/i", $wpConfig)) {
$wpConfig = preg_replace(
"/define\s*\(\s*['\"]WP_DEBUG['\"]\s*,\s*(true|false)\s*\)/i",
"define('WP_DEBUG', {$newDebugValue})",
$wpConfig
);
} else {
// Add WP_DEBUG before "That's all, stop editing!"
$wpConfig = preg_replace(
"/\/\*\s*That's all/",
"define('WP_DEBUG', {$newDebugValue});\n\n/* That's all",
$wpConfig
);
}
// Add or update WP_DEBUG_LOG and WP_DEBUG_DISPLAY
if ($newDebug) {
if (!preg_match("/define\s*\(\s*['\"]WP_DEBUG_LOG['\"]/", $wpConfig)) {
$wpConfig = preg_replace(
"/define\s*\(\s*['\"]WP_DEBUG['\"]\s*,\s*true\s*\)/",
"define('WP_DEBUG', true);\ndefine('WP_DEBUG_LOG', true);\ndefine('WP_DEBUG_DISPLAY', false)",
$wpConfig
);
}
}
file_put_contents($wpConfigPath, $wpConfig);
chown($wpConfigPath, $userInfo['uid']);
chgrp($wpConfigPath, $userInfo['gid']);
// Update site record
$wpSites[$siteId]['debug_enabled'] = $newDebug;
file_put_contents($wpListFile, json_encode($wpSites, JSON_PRETTY_PRINT));
return ['success' => true, 'debug_enabled' => $newDebug];
}
function wpToggleAutoUpdate(array $params): array
{
$username = $params['username'] ?? '';
$siteId = $params['site_id'] ?? '';
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'];
$wpListFile = "{$userHome}/.wordpress_sites";
$wpSites = [];
if (file_exists($wpListFile)) {
$wpSites = json_decode(file_get_contents($wpListFile), true) ?: [];
}
if (!isset($wpSites[$siteId])) {
return ['success' => false, 'error' => 'WordPress site not found'];
}
$site = $wpSites[$siteId];
$wpConfigPath = $site['install_path'] . '/wp-config.php';
if (!file_exists($wpConfigPath)) {
return ['success' => false, 'error' => 'wp-config.php not found'];
}
$wpConfig = file_get_contents($wpConfigPath);
// Check current auto-update status
$currentAutoUpdate = $site['auto_update'] ?? false;
$newAutoUpdate = !$currentAutoUpdate;
// WordPress auto-update constants
$autoUpdateConstants = [
'WP_AUTO_UPDATE_CORE' => $newAutoUpdate ? 'true' : 'false',
'AUTOMATIC_UPDATER_DISABLED' => $newAutoUpdate ? 'false' : 'true',
];
foreach ($autoUpdateConstants as $constant => $value) {
if (preg_match("/define\s*\(\s*['\"]" . $constant . "['\"]\s*,/", $wpConfig)) {
$wpConfig = preg_replace(
"/define\s*\(\s*['\"]" . $constant . "['\"]\s*,\s*[^)]+\)/",
"define('{$constant}', {$value})",
$wpConfig
);
} else {
// Add constant before "That's all, stop editing!"
$wpConfig = preg_replace(
"/\/\*\s*That's all/",
"define('{$constant}', {$value});\n/* That's all",
$wpConfig
);
}
}
file_put_contents($wpConfigPath, $wpConfig);
chown($wpConfigPath, $userInfo['uid']);
chgrp($wpConfigPath, $userInfo['gid']);
// Enable/disable auto-updates via wp-cli
$installPath = $site['install_path'];
if ($newAutoUpdate) {
exec("cd " . escapeshellarg($installPath) . " && sudo -u " . escapeshellarg($username) . " wp plugin auto-updates enable --all 2>&1");
exec("cd " . escapeshellarg($installPath) . " && sudo -u " . escapeshellarg($username) . " wp theme auto-updates enable --all 2>&1");
} else {
exec("cd " . escapeshellarg($installPath) . " && sudo -u " . escapeshellarg($username) . " wp plugin auto-updates disable --all 2>&1");
exec("cd " . escapeshellarg($installPath) . " && sudo -u " . escapeshellarg($username) . " wp theme auto-updates disable --all 2>&1");
}
// Update site record
$wpSites[$siteId]['auto_update'] = $newAutoUpdate;
file_put_contents($wpListFile, json_encode($wpSites, JSON_PRETTY_PRINT));
return ['success' => true, 'auto_update' => $newAutoUpdate];
}
function wpCreateStaging(array $params): array
{
$username = $params['username'] ?? '';
$siteId = $params['site_id'] ?? '';
$subdomain = $params['subdomain'] ?? 'staging';
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'];
$wpListFile = "{$userHome}/.wordpress_sites";
$wpSites = [];
if (file_exists($wpListFile)) {
$wpSites = json_decode(file_get_contents($wpListFile), true) ?: [];
}
if (!isset($wpSites[$siteId])) {
return ['success' => false, 'error' => 'WordPress site not found'];
}
$site = $wpSites[$siteId];
$sourcePath = $site['install_path'];
$sourceDomain = $site['domain'];
// Create staging domain
$stagingDomain = "{$subdomain}.{$sourceDomain}";
$stagingPath = "{$userHome}/domains/{$stagingDomain}/public_html";
// Check if staging already exists
if (is_dir($stagingPath)) {
return ['success' => false, 'error' => 'Staging site already exists at ' . $stagingDomain];
}
// Create staging directory
$stagingDir = dirname($stagingPath);
if (!is_dir($stagingDir)) {
mkdir($stagingDir, 0755, true);
chown($stagingDir, $userInfo['uid']);
chgrp($stagingDir, $userInfo['gid']);
}
// Copy files
exec("cp -r " . escapeshellarg($sourcePath) . " " . escapeshellarg($stagingPath) . " 2>&1", $output, $code);
if ($code !== 0) {
return ['success' => false, 'error' => 'Failed to copy files: ' . implode("\n", $output)];
}
// Fix ownership
exec("chown -R {$userInfo['uid']}:{$userInfo['gid']} " . escapeshellarg($stagingPath));
// Get source database credentials
$wpConfigPath = $stagingPath . '/wp-config.php';
$wpConfig = file_get_contents($wpConfigPath);
preg_match("/define\s*\(\s*['\"]DB_NAME['\"]\s*,\s*['\"]([^'\"]+)['\"]\s*\)/", $wpConfig, $dbNameMatch);
preg_match("/define\s*\(\s*['\"]DB_USER['\"]\s*,\s*['\"]([^'\"]+)['\"]\s*\)/", $wpConfig, $dbUserMatch);
preg_match("/define\s*\(\s*['\"]DB_PASSWORD['\"]\s*,\s*['\"]([^'\"]+)['\"]\s*\)/", $wpConfig, $dbPassMatch);
$sourceDbName = $dbNameMatch[1] ?? '';
$sourceDbUser = $dbUserMatch[1] ?? '';
$sourceDbPass = $dbPassMatch[1] ?? '';
if (empty($sourceDbName)) {
return ['success' => false, 'error' => 'Could not read source database name'];
}
// Create staging database
$stagingDbName = substr($sourceDbName . '_stg', 0, 64);
$stagingDbUser = $sourceDbUser; // Use same user
// Export and import database
$dumpFile = "/tmp/wp_staging_{$siteId}.sql";
exec("mysqldump --defaults-file=/etc/mysql/debian.cnf " . escapeshellarg($sourceDbName) . " > " . escapeshellarg($dumpFile) . " 2>&1", $dumpOutput, $dumpCode);
if ($dumpCode !== 0) {
return ['success' => false, 'error' => 'Failed to export database'];
}
// Create staging database
exec("mysql --defaults-file=/etc/mysql/debian.cnf -e \"CREATE DATABASE IF NOT EXISTS " . escapeshellarg($stagingDbName) . " CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci\" 2>&1", $createOutput, $createCode);
if ($createCode !== 0) {
unlink($dumpFile);
return ['success' => false, 'error' => 'Failed to create staging database'];
}
// Grant permissions
exec("mysql --defaults-file=/etc/mysql/debian.cnf -e \"GRANT ALL PRIVILEGES ON " . escapeshellarg($stagingDbName) . ".* TO '" . addslashes($sourceDbUser) . "'@'localhost'\" 2>&1");
// Import database
exec("mysql --defaults-file=/etc/mysql/debian.cnf " . escapeshellarg($stagingDbName) . " < " . escapeshellarg($dumpFile) . " 2>&1", $importOutput, $importCode);
unlink($dumpFile);
if ($importCode !== 0) {
return ['success' => false, 'error' => 'Failed to import database'];
}
// Update wp-config.php with new database
$wpConfig = preg_replace(
"/define\s*\(\s*['\"]DB_NAME['\"]\s*,\s*['\"][^'\"]+['\"]\s*\)/",
"define('DB_NAME', '{$stagingDbName}')",
$wpConfig
);
file_put_contents($wpConfigPath, $wpConfig);
// Update URLs in staging database
$stagingUrl = "https://{$stagingDomain}";
$sourceUrl = $site['url'];
exec("cd " . escapeshellarg($stagingPath) . " && sudo -u " . escapeshellarg($username) . " wp search-replace " . escapeshellarg($sourceUrl) . " " . escapeshellarg($stagingUrl) . " --all-tables 2>&1");
// Also replace without protocol
exec("cd " . escapeshellarg($stagingPath) . " && sudo -u " . escapeshellarg($username) . " wp search-replace " . escapeshellarg($sourceDomain) . " " . escapeshellarg($stagingDomain) . " --all-tables 2>&1");
// Create Nginx config for staging
$nginxConf = <<&1");
// Add staging site to WordPress sites list
$stagingSiteId = 'staging_' . $siteId . '_' . time();
$wpSites[$stagingSiteId] = [
'id' => $stagingSiteId,
'domain' => $stagingDomain,
'path' => '',
'url' => $stagingUrl,
'install_path' => $stagingPath,
'db_name' => $stagingDbName,
'db_user' => $sourceDbUser,
'version' => $site['version'] ?? 'Unknown',
'is_staging' => true,
'source_site_id' => $siteId,
'created_at' => date('Y-m-d H:i:s'),
];
file_put_contents($wpListFile, json_encode($wpSites, JSON_PRETTY_PRINT));
return [
'success' => true,
'staging_url' => $stagingUrl,
'staging_domain' => $stagingDomain,
'staging_site_id' => $stagingSiteId,
];
}
// ============ PHP SETTINGS MANAGEMENT ============
function phpGetSettings(array $params): array
{
$username = $params['username'] ?? '';
$domain = $params['domain'] ?? '';
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'];
$settingsFile = "{$userHome}/domains/{$domain}/.php-settings.json";
// Default settings (same as createFpmPool defaults)
$defaults = [
'php_version' => '8.4',
'memory_limit' => '512M',
'upload_max_filesize' => '64M',
'post_max_size' => '64M',
'max_execution_time' => '300',
'max_input_time' => '300',
'max_input_vars' => '3000',
];
// First try to read from JSON settings file
if (file_exists($settingsFile)) {
$settings = json_decode(file_get_contents($settingsFile), true) ?: [];
$settings = array_merge($defaults, $settings);
return ['success' => true, 'settings' => $settings];
}
// If no JSON, try to read from the actual FPM pool config
$phpVersion = '8.4';
$poolFile = "/etc/php/{$phpVersion}/fpm/pool.d/{$username}.conf";
if (file_exists($poolFile)) {
$poolContent = file_get_contents($poolFile);
$settings = $defaults;
// Parse settings from pool config
if (preg_match('/php_admin_value\[memory_limit\]\s*=\s*(\S+)/', $poolContent, $m)) {
$settings['memory_limit'] = $m[1];
}
if (preg_match('/php_admin_value\[upload_max_filesize\]\s*=\s*(\S+)/', $poolContent, $m)) {
$settings['upload_max_filesize'] = $m[1];
}
if (preg_match('/php_admin_value\[post_max_size\]\s*=\s*(\S+)/', $poolContent, $m)) {
$settings['post_max_size'] = $m[1];
}
if (preg_match('/php_admin_value\[max_execution_time\]\s*=\s*(\S+)/', $poolContent, $m)) {
$settings['max_execution_time'] = $m[1];
}
if (preg_match('/php_admin_value\[max_input_time\]\s*=\s*(\S+)/', $poolContent, $m)) {
$settings['max_input_time'] = $m[1];
}
if (preg_match('/php_admin_value\[max_input_vars\]\s*=\s*(\S+)/', $poolContent, $m)) {
$settings['max_input_vars'] = $m[1];
}
// Save to JSON for future use
$domainDir = "{$userHome}/domains/{$domain}";
if (is_dir($domainDir)) {
file_put_contents($settingsFile, json_encode($settings, JSON_PRETTY_PRINT));
chown($settingsFile, $userInfo['uid']);
chgrp($settingsFile, $userInfo['gid']);
}
return ['success' => true, 'settings' => $settings];
}
// Return defaults if nothing found
return ['success' => true, 'settings' => $defaults];
}
function phpSetSettings(array $params): array
{
$username = $params['username'] ?? '';
$domain = $params['domain'] ?? '';
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'];
$domainRoot = "{$userHome}/domains/{$domain}";
if (!is_dir($domainRoot)) {
return ['success' => false, 'error' => 'Domain not found'];
}
$phpVersion = $params['php_version'] ?? '8.4';
$memoryLimit = $params['memory_limit'] ?? '256M';
$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';
// Save settings to JSON file
$settings = [
'php_version' => $phpVersion,
'memory_limit' => $memoryLimit,
'upload_max_filesize' => $uploadMaxFilesize,
'post_max_size' => $postMaxSize,
'max_execution_time' => $maxExecutionTime,
'max_input_time' => $maxInputTime,
'max_input_vars' => $maxInputVars,
];
$settingsFile = "{$domainRoot}/.php-settings.json";
file_put_contents($settingsFile, json_encode($settings, JSON_PRETTY_PRINT));
chown($settingsFile, $userInfo['uid']);
chgrp($settingsFile, $userInfo['gid']);
// Update or create PHP-FPM pool with ALL settings
$fpmSocket = "/run/php/php{$phpVersion}-fpm-{$username}.sock";
$poolFile = "/etc/php/{$phpVersion}/fpm/pool.d/{$username}.conf";
// Always write full pool config with all settings
$poolConfig = "[{$username}]
user = {$username}
group = {$username}
listen = {$fpmSocket}
listen.owner = {$username}
listen.group = www-data
listen.mode = 0660
pm = dynamic
pm.max_children = 5
pm.start_servers = 1
pm.min_spare_servers = 1
pm.max_spare_servers = 3
pm.max_requests = 200
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
";
file_put_contents($poolFile, $poolConfig);
// Update Nginx config for this domain to use the correct PHP version
$nginxConfig = "/etc/nginx/sites-available/{$domain}.conf";
if (file_exists($nginxConfig)) {
$nginxContent = file_get_contents($nginxConfig);
$nginxContent = preg_replace(
'/fastcgi_pass unix:\/run\/php\/php[\d.]+-fpm-' . preg_quote($username, '/') . '\.sock;/',
"fastcgi_pass unix:{$fpmSocket};",
$nginxContent
);
file_put_contents($nginxConfig, $nginxContent);
}
// Reload services in background to not interrupt the request
exec("(sleep 1 && systemctl reload php{$phpVersion}-fpm && nginx -t && systemctl reload nginx) > /dev/null 2>&1 &");
return ['success' => true, 'message' => 'PHP settings updated successfully'];
}
// DNS Management Functions
// ============ DNS MANAGEMENT ============
function dnsCreateZone(array $params): array {
$domain = $params['domain'] ?? '';
$records = $params['records'] ?? null;
$ns1 = $params['ns1'] ?? 'ns1.example.com';
$ns2 = $params['ns2'] ?? 'ns2.example.com';
$adminEmail = $params['admin_email'] ?? 'admin.example.com';
$defaultIp = $params['default_ip'] ?? '127.0.0.1';
$defaultIpv6 = $params['default_ipv6'] ?? '';
$defaultTtl = $params['default_ttl'] ?? 3600;
if (empty($domain)) {
return ['success' => false, 'error' => 'Invalid domain name'];
}
if (is_array($records) && $records !== []) {
return dnsSyncZone([
'domain' => $domain,
'records' => $records,
'ns1' => $ns1,
'ns2' => $ns2,
'admin_email' => $adminEmail,
'default_ttl' => $defaultTtl,
]);
}
$zonesDir = '/etc/bind/zones';
$zoneFile = "{$zonesDir}/db.{$domain}";
if (!is_dir($zonesDir)) {
mkdir($zonesDir, 0755, true);
}
$serial = date('Ymd') . '01';
$zoneContent = "\$TTL {$defaultTtl}\n";
$zoneContent .= "@ IN SOA {$ns1}. {$adminEmail}. (\n";
$zoneContent .= " {$serial} ; Serial\n";
$zoneContent .= " 3600 ; Refresh\n";
$zoneContent .= " 1800 ; Retry\n";
$zoneContent .= " 604800 ; Expire\n";
$zoneContent .= " 86400 ) ; Minimum TTL\n\n";
$zoneContent .= "@ IN NS {$ns1}.\n";
$zoneContent .= "@ IN NS {$ns2}.\n\n";
$zoneContent .= "@ IN A {$defaultIp}\n";
$zoneContent .= "www IN A {$defaultIp}\n";
$zoneContent .= "mail IN A {$defaultIp}\n";
if (!empty($defaultIpv6)) {
$zoneContent .= "@ IN AAAA {$defaultIpv6}\n";
$zoneContent .= "www IN AAAA {$defaultIpv6}\n";
$zoneContent .= "mail IN AAAA {$defaultIpv6}\n";
}
$zoneContent .= "@ IN MX 10 mail.{$domain}.\n";
if (file_put_contents($zoneFile, $zoneContent) === false) {
return ['success' => false, 'error' => 'Failed to create zone file'];
}
chmod($zoneFile, 0644);
$namedConf = '/etc/bind/named.conf.local';
$zoneEntry = "\nzone \"{$domain}\" {\n type master;\n file \"{$zoneFile}\";\n allow-transfer { none; };\n};\n";
$existingConf = file_get_contents($namedConf);
if (strpos($existingConf, "zone \"{$domain}\"") === false) {
file_put_contents($namedConf, $zoneEntry, FILE_APPEND);
}
exec('systemctl reload bind9 2>&1 || systemctl reload named 2>&1');
logger("Created DNS zone for {$domain}");
return ['success' => true, 'message' => "Zone created for {$domain}"];
}
function dnsSyncZone(array $params): array {
$domain = $params['domain'] ?? '';
$records = $params['records'] ?? [];
$ns1 = $params['ns1'] ?? 'ns1.example.com';
$ns2 = $params['ns2'] ?? 'ns2.example.com';
$adminEmail = $params['admin_email'] ?? 'admin.example.com';
$defaultTtl = $params['default_ttl'] ?? 3600;
$defaultIpv4 = $params['default_ip'] ?? null;
$defaultIpv6 = $params['default_ipv6'] ?? null;
if (empty($domain)) return ['success' => false, 'error' => 'Domain required'];
$zonesDir = '/etc/bind/zones';
$zoneFile = "{$zonesDir}/db.{$domain}";
$isNew = !file_exists($zoneFile);
$domainName = rtrim($domain, '.');
$ns1 = rtrim($ns1, '.');
$ns2 = rtrim($ns2, '.');
$recordMap = ['A' => [], 'AAAA' => []];
foreach ($records as $r) {
$type = strtoupper($r['type'] ?? 'A');
if (!in_array($type, ['A', 'AAAA'], true)) continue;
$name = trim($r['name'] ?? '@');
if ($name === '' || $name === '@') {
$fqdn = $domainName;
} else {
$fqdn = rtrim($name, '.');
if (substr($fqdn, -strlen(".{$domainName}")) !== ".{$domainName}") {
$fqdn .= ".{$domainName}";
}
}
$recordMap[$type][$fqdn] = $r['content'] ?? '';
}
if (empty($defaultIpv4)) {
$defaultIpv4 = $recordMap['A'][$domainName] ?? (!empty($recordMap['A']) ? reset($recordMap['A']) : null);
}
if (empty($defaultIpv6)) {
$defaultIpv6 = $recordMap['AAAA'][$domainName] ?? (!empty($recordMap['AAAA']) ? reset($recordMap['AAAA']) : null);
}
// Ensure zones directory exists
if (!is_dir($zonesDir)) {
mkdir($zonesDir, 0755, true);
}
$serial = date('Ymd') . sprintf('%02d', (int)date('H') + 1);
$zoneContent = "\$TTL {$defaultTtl}\n@ IN SOA {$ns1}. {$adminEmail}. ({$serial} 3600 1800 604800 86400)\n";
$zoneContent .= "@ IN NS {$ns1}.\n@ IN NS {$ns2}.\n";
$glueNames = [];
foreach (array_filter([$ns1, $ns2]) as $ns) {
if ($ns === $domainName) {
$label = '@';
} elseif (substr($ns, -strlen(".{$domainName}")) === ".{$domainName}") {
$label = substr($ns, 0, -strlen(".{$domainName}"));
} else {
continue;
}
$glueNames[$label] = $ns;
}
foreach ($glueNames as $label => $fqdn) {
if ($label === '@') {
continue;
}
if (!isset($recordMap['A'][$fqdn]) && !empty($defaultIpv4)) {
$zoneContent .= "{$label}\tIN\tA\t{$defaultIpv4}\n";
}
if (!isset($recordMap['AAAA'][$fqdn]) && !empty($defaultIpv6)) {
$zoneContent .= "{$label}\tIN\tAAAA\t{$defaultIpv6}\n";
}
}
foreach ($records as $r) {
$name = $r['name'] ?? '@';
$type = $r['type'] ?? 'A';
$content = $r['content'] ?? '';
$priority = $r['priority'] ?? '';
if ($type === 'NS') continue;
// Use tab separator to handle long names like default._domainkey
$line = "{$name}\tIN\t{$type}\t";
if ($type === 'MX' && $priority) $line .= "{$priority}\t";
if (in_array($type, ['CNAME', 'MX', 'NS']) && substr($content, -1) !== '.') $content .= '.';
if ($type === 'TXT') {
// Remove existing quotes if present
$content = trim($content, '"');
// Split long TXT records into 255-char chunks (BIND requirement)
if (strlen($content) > 255) {
$chunks = str_split($content, 255);
$content = '( "' . implode('" "', $chunks) . '" )';
} else {
$content = "\"{$content}\"";
}
}
$zoneContent .= $line . $content . "\n";
}
file_put_contents($zoneFile, $zoneContent);
chmod($zoneFile, 0644);
// Add zone to named.conf.local if new
if ($isNew) {
$namedConf = '/etc/bind/named.conf.local';
$zoneEntry = "\nzone \"{$domain}\" {\n type master;\n file \"{$zoneFile}\";\n allow-transfer { none; };\n};\n";
$existingConf = file_get_contents($namedConf);
if (strpos($existingConf, "zone \"{$domain}\"") === false) {
file_put_contents($namedConf, $zoneEntry, FILE_APPEND);
}
}
exec('systemctl reload bind9 2>&1 || systemctl reload named 2>&1');
logger(($isNew ? "Created" : "Synced") . " DNS zone for {$domain}");
return ['success' => true, 'message' => "Zone " . ($isNew ? "created" : "synced") . " for {$domain}"];
}
function dnsDeleteZone(array $params): array {
$domain = $params['domain'] ?? '';
if (empty($domain)) return ['success' => false, 'error' => 'Domain required'];
$zoneFile = "/etc/bind/zones/db.{$domain}";
if (file_exists($zoneFile)) unlink($zoneFile);
$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)
$pattern = '/\n?zone\s+"' . preg_quote($domain, '/') . '"\s*\{[\s\S]*?\n\};\n?/';
$content = preg_replace($pattern, "\n", $content);
file_put_contents($namedConf, trim($content) . "\n");
}
exec('systemctl reload bind9 2>&1 || systemctl reload named 2>&1');
logger("Deleted DNS zone for {$domain}");
return ['success' => true, 'message' => "Zone deleted for {$domain}"];
}
function dnsReload(array $params): array {
exec('systemctl reload bind9 2>&1 || systemctl reload named 2>&1', $output, $ret);
return $ret === 0 ? ['success' => true, 'message' => 'BIND reloaded'] : ['success' => false, 'error' => implode("\n", $output)];
}
// ============ DNSSEC MANAGEMENT ============
/**
* Enable DNSSEC for a zone
* Generates KSK and ZSK keys, configures inline signing
*/
function dnsEnableDnssec(array $params): array {
$domain = $params['domain'] ?? '';
if (empty($domain)) {
return ['success' => false, 'error' => 'Domain required'];
}
$zoneFile = "/etc/bind/zones/db.{$domain}";
if (!file_exists($zoneFile)) {
return ['success' => false, 'error' => 'Zone file not found. Create the zone first.'];
}
// Check if dnssec-keygen is available
exec('which dnssec-keygen 2>/dev/null', $output, $ret);
if ($ret !== 0) {
return ['success' => false, 'error' => 'dnssec-keygen not found. Install bind9-dnssec-utils or bind-dnssec-utils.'];
}
// Create keys directory
$keysDir = DNSSEC_KEYS_DIR;
$domainKeysDir = "{$keysDir}/{$domain}";
if (!is_dir($domainKeysDir)) {
mkdir($domainKeysDir, 0750, true);
chown($domainKeysDir, 'bind');
chgrp($domainKeysDir, 'bind');
}
// Check if keys already exist
$existingKeys = glob("{$domainKeysDir}/K{$domain}.+*");
if (!empty($existingKeys)) {
return ['success' => false, 'error' => 'DNSSEC keys already exist for this domain. Disable DNSSEC first to regenerate.'];
}
$algorithm = DNSSEC_ALGORITHM;
// Generate KSK (Key Signing Key) - Algorithm 13, 256 bits for ECDSA
$kskCmd = sprintf(
'cd %s && dnssec-keygen -a %s -f KSK -n ZONE %s 2>&1',
escapeshellarg($domainKeysDir),
escapeshellarg($algorithm),
escapeshellarg($domain)
);
exec($kskCmd, $kskOutput, $kskRet);
if ($kskRet !== 0) {
return ['success' => false, 'error' => 'Failed to generate KSK: ' . implode("\n", $kskOutput)];
}
// Generate ZSK (Zone Signing Key)
$zskCmd = sprintf(
'cd %s && dnssec-keygen -a %s -n ZONE %s 2>&1',
escapeshellarg($domainKeysDir),
escapeshellarg($algorithm),
escapeshellarg($domain)
);
exec($zskCmd, $zskOutput, $zskRet);
if ($zskRet !== 0) {
return ['success' => false, 'error' => 'Failed to generate ZSK: ' . implode("\n", $zskOutput)];
}
// Set proper ownership on key files
exec("chown -R bind:bind " . escapeshellarg($domainKeysDir));
exec("chmod 640 {$domainKeysDir}/*.private");
exec("chmod 644 {$domainKeysDir}/*.key");
// Update named.conf.local to enable inline signing
$namedConf = '/etc/bind/named.conf.local';
$content = file_get_contents($namedConf);
// Check if zone exists
if (strpos($content, "zone \"{$domain}\"") === false) {
return ['success' => false, 'error' => 'Zone not found in named.conf.local'];
}
// Remove existing zone entry and add new one with DNSSEC
// Use [\s\S]*?\n\} to match nested braces like allow-transfer { none; }
$pattern = '/zone\s+"' . preg_quote($domain, '/') . '"\s*\{[\s\S]*?\n\};/';
$signedZoneFile = "/etc/bind/zones/db.{$domain}.signed";
$newZoneEntry = "zone \"{$domain}\" {\n" .
" type master;\n" .
" file \"{$zoneFile}\";\n" .
" key-directory \"{$domainKeysDir}\";\n" .
" auto-dnssec maintain;\n" .
" inline-signing yes;\n" .
" allow-transfer { none; };\n" .
"};";
$content = preg_replace($pattern, $newZoneEntry, $content);
file_put_contents($namedConf, $content);
// Reload BIND to apply changes
exec('systemctl reload bind9 2>&1 || systemctl reload named 2>&1', $reloadOutput, $reloadRet);
if ($reloadRet !== 0) {
logger("DNSSEC: BIND reload warning for {$domain}: " . implode("\n", $reloadOutput));
}
// Wait a moment for signing to begin
sleep(2);
// Force zone re-signing
exec("rndc sign {$domain} 2>&1", $signOutput, $signRet);
logger("DNSSEC enabled for {$domain}");
return [
'success' => true,
'message' => "DNSSEC enabled for {$domain}. Zone signing in progress.",
'note' => 'Please wait a few minutes for the zone to be signed, then retrieve the DS records to provide to your registrar.'
];
}
/**
* Disable DNSSEC for a zone
* Removes keys and disables inline signing
*/
function dnsDisableDnssec(array $params): array {
$domain = $params['domain'] ?? '';
if (empty($domain)) {
return ['success' => false, 'error' => 'Domain required'];
}
$zoneFile = "/etc/bind/zones/db.{$domain}";
$domainKeysDir = DNSSEC_KEYS_DIR . "/{$domain}";
// Update named.conf.local to disable DNSSEC
$namedConf = '/etc/bind/named.conf.local';
$content = file_get_contents($namedConf);
if (strpos($content, "zone \"{$domain}\"") === false) {
return ['success' => false, 'error' => 'Zone not found in named.conf.local'];
}
// Replace zone entry with non-DNSSEC version
// Use [\s\S]*?\n\} to match nested braces like allow-transfer { none; }
$pattern = '/zone\s+"' . preg_quote($domain, '/') . '"\s*\{[\s\S]*?\n\};/';
$newZoneEntry = "zone \"{$domain}\" {\n" .
" type master;\n" .
" file \"{$zoneFile}\";\n" .
" allow-transfer { none; };\n" .
"};";
$content = preg_replace($pattern, $newZoneEntry, $content);
file_put_contents($namedConf, $content);
// Remove keys directory
if (is_dir($domainKeysDir)) {
exec("rm -rf " . escapeshellarg($domainKeysDir));
}
// Remove signed zone file if exists
$signedZoneFile = "{$zoneFile}.signed";
if (file_exists($signedZoneFile)) {
unlink($signedZoneFile);
}
// Remove journal files
foreach (glob("{$zoneFile}*.jnl") as $jnlFile) {
unlink($jnlFile);
}
foreach (glob("{$zoneFile}*.signed.jnl") as $jnlFile) {
unlink($jnlFile);
}
// Reload BIND
exec('systemctl reload bind9 2>&1 || systemctl reload named 2>&1');
logger("DNSSEC disabled for {$domain}");
return [
'success' => true,
'message' => "DNSSEC disabled for {$domain}. Remember to remove the DS records from your registrar."
];
}
/**
* Get DNSSEC status for a zone
*/
function dnsGetDnssecStatus(array $params): array {
$domain = $params['domain'] ?? '';
if (empty($domain)) {
return ['success' => false, 'error' => 'Domain required'];
}
$domainKeysDir = DNSSEC_KEYS_DIR . "/{$domain}";
$zoneFile = "/etc/bind/zones/db.{$domain}";
$signedZoneFile = "{$zoneFile}.signed";
// Check if zone exists
if (!file_exists($zoneFile)) {
return [
'success' => true,
'enabled' => false,
'status' => 'no_zone',
'message' => 'Zone file not found'
];
}
// Check if keys exist
$kskFiles = glob("{$domainKeysDir}/K{$domain}.+*.key");
$hasKeys = !empty($kskFiles);
// Check named.conf.local for inline-signing (use [\s\S]*? to match nested braces)
$namedConf = file_get_contents('/etc/bind/named.conf.local');
$hasInlineSigning = preg_match('/zone\s+"' . preg_quote($domain, '/') . '"\s*\{[\s\S]*?inline-signing\s+yes[\s\S]*?\n\};/', $namedConf);
// Check if signed zone exists
$hasSigned = file_exists($signedZoneFile);
if (!$hasKeys && !$hasInlineSigning) {
return [
'success' => true,
'enabled' => false,
'status' => 'disabled',
'message' => 'DNSSEC is not enabled for this zone'
];
}
// Get key info
$keys = [];
foreach (glob("{$domainKeysDir}/K{$domain}.+*.key") as $keyFile) {
$keyContent = file_get_contents($keyFile);
$keyName = basename($keyFile, '.key');
// Parse key file to determine if KSK or ZSK
$isKsk = strpos($keyContent, '257') !== false; // KSK has flags 257
// Get key ID from filename (format: Kdomain.+algorithm+keyid.key)
preg_match('/\+(\d+)\+(\d+)\.key$/', $keyFile, $matches);
$algorithm = $matches[1] ?? 'unknown';
$keyId = $matches[2] ?? 'unknown';
$keys[] = [
'type' => $isKsk ? 'KSK' : 'ZSK',
'algorithm' => $algorithm,
'key_id' => $keyId,
'file' => basename($keyFile)
];
}
return [
'success' => true,
'enabled' => true,
'status' => $hasSigned ? 'active' : 'pending',
'message' => $hasSigned ? 'DNSSEC is active and zone is signed' : 'DNSSEC is enabled, zone signing in progress',
'keys' => $keys,
'has_signed_zone' => $hasSigned
];
}
/**
* Get DS records for registrar
*/
function dnsGetDsRecords(array $params): array {
$domain = $params['domain'] ?? '';
if (empty($domain)) {
return ['success' => false, 'error' => 'Domain required'];
}
$domainKeysDir = DNSSEC_KEYS_DIR . "/{$domain}";
// Find KSK key file (flags 257)
$kskFiles = glob("{$domainKeysDir}/K{$domain}.+*.key");
$kskFile = null;
foreach ($kskFiles as $keyFile) {
$content = file_get_contents($keyFile);
if (strpos($content, ' 257 ') !== false) {
$kskFile = $keyFile;
break;
}
}
if (!$kskFile) {
return ['success' => false, 'error' => 'KSK not found. Enable DNSSEC first.'];
}
// Check if dnssec-dsfromkey is available
exec('which dnssec-dsfromkey 2>/dev/null', $output, $ret);
if ($ret !== 0) {
return ['success' => false, 'error' => 'dnssec-dsfromkey not found'];
}
// Generate DS records in different digest formats
$dsRecords = [];
// SHA-256 (digest type 2) - recommended
exec("dnssec-dsfromkey -2 " . escapeshellarg($kskFile) . " 2>&1", $ds2Output, $ds2Ret);
if ($ds2Ret === 0 && !empty($ds2Output)) {
$dsRecords['sha256'] = [
'digest_type' => 2,
'digest_name' => 'SHA-256',
'record' => trim($ds2Output[0])
];
// Parse the DS record
if (preg_match('/(\S+)\s+IN\s+DS\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)/', $ds2Output[0], $matches)) {
$dsRecords['sha256']['parsed'] = [
'domain' => $matches[1],
'key_tag' => $matches[2],
'algorithm' => $matches[3],
'digest_type' => $matches[4],
'digest' => $matches[5]
];
}
}
// SHA-384 (digest type 4) - for ECDSA P-384
exec("dnssec-dsfromkey -4 " . escapeshellarg($kskFile) . " 2>&1", $ds4Output, $ds4Ret);
if ($ds4Ret === 0 && !empty($ds4Output)) {
$dsRecords['sha384'] = [
'digest_type' => 4,
'digest_name' => 'SHA-384',
'record' => trim($ds4Output[0])
];
if (preg_match('/(\S+)\s+IN\s+DS\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)/', $ds4Output[0], $matches)) {
$dsRecords['sha384']['parsed'] = [
'domain' => $matches[1],
'key_tag' => $matches[2],
'algorithm' => $matches[3],
'digest_type' => $matches[4],
'digest' => $matches[5]
];
}
}
if (empty($dsRecords)) {
return ['success' => false, 'error' => 'Failed to generate DS records'];
}
// Get DNSKEY record for those who need it
$dnskeyRecord = trim(file_get_contents($kskFile));
return [
'success' => true,
'domain' => $domain,
'ds_records' => $dsRecords,
'dnskey' => $dnskeyRecord,
'instructions' => 'Add the DS record (SHA-256 recommended) to your domain registrar to complete DNSSEC setup.'
];
}
// ============ EMAIL MANAGEMENT ============
function emailEnableDomain(array $params): array
{
$username = $params['username'] ?? '';
$domain = strtolower(trim($params['domain'] ?? ''));
if (empty($username) || !validateUsername($username)) {
return ['success' => false, 'error' => 'Valid username required'];
}
if (empty($domain) || !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'];
}
// Get user's UID/GID
$userInfo = posix_getpwnam($username);
if (!$userInfo) {
return ['success' => false, 'error' => 'System user not found'];
}
$uid = $userInfo['uid'];
$gid = $userInfo['gid'];
// Create mail directory in user's home folder
$userMailDir = "/home/{$username}/mail";
$domainDir = "{$userMailDir}/{$domain}";
if (!is_dir($userMailDir)) {
mkdir($userMailDir, 0750, true);
chown($userMailDir, $uid);
chgrp($userMailDir, $gid);
}
if (!is_dir($domainDir)) {
mkdir($domainDir, 0750, true);
chown($domainDir, $uid);
chgrp($domainDir, $gid);
}
// Add domain to virtual_mailbox_domains
$domainsFile = POSTFIX_VIRTUAL_DOMAINS;
$domainsContent = file_exists($domainsFile) ? file_get_contents($domainsFile) : '';
if (strpos($domainsContent, $domain) === false) {
file_put_contents($domainsFile, trim($domainsContent) . "\n{$domain} OK\n");
exec('postmap ' . escapeshellarg($domainsFile));
}
logger("Enabled email for domain: {$domain} (user: {$username})");
return ['success' => true, 'message' => "Email enabled for {$domain}"];
}
function emailDisableDomain(array $params): array
{
$domain = strtolower(trim($params['domain'] ?? ''));
if (empty($domain)) {
return ['success' => false, 'error' => 'Domain required'];
}
// Remove domain from virtual_mailbox_domains
$domainsFile = POSTFIX_VIRTUAL_DOMAINS;
if (file_exists($domainsFile)) {
$content = file_get_contents($domainsFile);
$content = preg_replace('/^' . preg_quote($domain, '/') . '\s+OK\s*$/m', '', $content);
file_put_contents($domainsFile, trim($content) . "\n");
exec('postmap ' . escapeshellarg($domainsFile));
}
logger("Disabled email for domain: {$domain}");
return ['success' => true, 'message' => "Email disabled for {$domain}"];
}
function emailGenerateDkim(array $params): array
{
$domain = strtolower(trim($params['domain'] ?? ''));
$selector = $params['selector'] ?? 'default';
if (empty($domain)) {
return ['success' => false, 'error' => 'Domain required'];
}
$keyDir = DKIM_KEYS_DIR . '/' . $domain;
if (!is_dir($keyDir)) {
mkdir($keyDir, 0700, true);
}
$privateKey = "{$keyDir}/{$selector}.private";
$publicKey = "{$keyDir}/{$selector}.txt";
// Generate DKIM key pair
$cmd = sprintf(
'opendkim-genkey -s %s -d %s -D %s 2>&1',
escapeshellarg($selector),
escapeshellarg($domain),
escapeshellarg($keyDir)
);
exec($cmd, $output, $exitCode);
if ($exitCode !== 0 || !file_exists($privateKey)) {
return ['success' => false, 'error' => 'Failed to generate DKIM keys: ' . implode("\n", $output)];
}
// Set ownership
chown($keyDir, 'opendkim');
chgrp($keyDir, 'opendkim');
chmod($keyDir, 0750);
chown($privateKey, 'opendkim');
chgrp($privateKey, 'opendkim');
chmod($privateKey, 0600);
if (file_exists($publicKey)) {
chown($publicKey, 'opendkim');
chgrp($publicKey, 'opendkim');
}
// Read public key for DNS record
$publicKeyContent = file_exists($publicKey) ? file_get_contents($publicKey) : '';
// Extract all quoted strings and concatenate them
preg_match_all('/"([^"]*)"/', $publicKeyContent, $allMatches);
$fullRecord = implode('', $allMatches[1] ?? []);
// Extract just the p= value (the public key)
preg_match('/p=([A-Za-z0-9+\/=]+)/', $fullRecord, $matches);
$publicKeyValue = $matches[1] ?? '';
// Update OpenDKIM KeyTable
$keyTableFile = '/etc/opendkim/KeyTable';
$keyTableEntry = "{$selector}._domainkey.{$domain} {$domain}:{$selector}:{$keyDir}/{$selector}.private\n";
$keyTableContent = file_exists($keyTableFile) ? file_get_contents($keyTableFile) : '';
// Remove old entry for this domain if exists
$keyTableContent = preg_replace("/^.*{$domain}:.*\$/m", '', $keyTableContent);
$keyTableContent = trim($keyTableContent) . "\n" . $keyTableEntry;
file_put_contents($keyTableFile, trim($keyTableContent) . "\n");
// Update OpenDKIM SigningTable
$signingTableFile = '/etc/opendkim/SigningTable';
$signingTableEntry = "*@{$domain} {$selector}._domainkey.{$domain}\n";
$signingTableContent = file_exists($signingTableFile) ? file_get_contents($signingTableFile) : '';
// Remove old entry for this domain if exists
$signingTableContent = preg_replace("/^\\*@{$domain}.*\$/m", '', $signingTableContent);
$signingTableContent = trim($signingTableContent) . "\n" . $signingTableEntry;
file_put_contents($signingTableFile, trim($signingTableContent) . "\n");
// Reload OpenDKIM
exec('systemctl reload opendkim 2>&1', $reloadOutput, $reloadRet);
logger("Generated DKIM keys for {$domain} with selector {$selector}");
return [
'success' => true,
'message' => "DKIM keys generated for {$domain}",
'selector' => $selector,
'public_key' => $publicKeyValue,
'dns_record' => "{$selector}._domainkey.{$domain} IN TXT \"v=DKIM1; k=rsa; p={$publicKeyValue}\"",
];
}
function emailGetDomainInfo(array $params): array
{
$domain = strtolower(trim($params['domain'] ?? ''));
$username = $params['username'] ?? '';
if (empty($domain)) {
return ['success' => false, 'error' => 'Domain required'];
}
// Check user's mail directory for the domain
$domainDir = !empty($username) ? "/home/{$username}/mail/{$domain}" : null;
$enabled = $domainDir && is_dir($domainDir);
// Get mailbox count
$mailboxCount = 0;
if ($enabled && is_dir($domainDir)) {
$dirs = glob($domainDir . '/*', GLOB_ONLYDIR);
$mailboxCount = count($dirs);
}
// Check for DKIM
$dkimExists = file_exists(DKIM_KEYS_DIR . '/' . $domain . '/default.private');
return [
'success' => true,
'domain' => $domain,
'enabled' => $enabled,
'mailbox_count' => $mailboxCount,
'dkim_configured' => $dkimExists,
];
}
function emailMailboxCreate(array $params): array
{
$username = $params['username'] ?? '';
$email = strtolower(trim($params['email'] ?? ''));
$password = $params['password'] ?? '';
$quotaBytes = (int) ($params['quota_bytes'] ?? 1073741824);
if (empty($username) || !validateUsername($username)) {
return ['success' => false, 'error' => 'Valid username required'];
}
if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
return ['success' => false, 'error' => 'Invalid email address'];
}
if (empty($password) || strlen($password) < 8) {
return ['success' => false, 'error' => 'Password must be at least 8 characters'];
}
list($localPart, $domain) = explode('@', $email);
// Get user's UID/GID
$userInfo = posix_getpwnam($username);
if (!$userInfo) {
return ['success' => false, 'error' => 'System user not found'];
}
$uid = $userInfo['uid'];
$gid = $userInfo['gid'];
// Check domain directory exists in user's home
$userMailDir = "/home/{$username}/mail";
$domainDir = "{$userMailDir}/{$domain}";
if (!is_dir($domainDir)) {
return ['success' => false, 'error' => 'Email is not enabled for this domain'];
}
// Create mailbox directory (Maildir format)
$mailboxDir = "{$domainDir}/{$localPart}";
$folderExists = is_dir($mailboxDir);
// Create/ensure Maildir subdirectories exist
foreach (['', '/cur', '/new', '/tmp'] as $subdir) {
$dir = $mailboxDir . $subdir;
if (!is_dir($dir)) {
mkdir($dir, 0750, true);
}
chown($dir, $uid);
chgrp($dir, $gid);
}
// Generate password hash using doveadm
$hashCmd = sprintf('doveadm pw -s SHA512-CRYPT -p %s 2>&1', escapeshellarg($password));
$passwordHash = trim(shell_exec($hashCmd));
if (empty($passwordHash) || strpos($passwordHash, '{SHA512-CRYPT}') === false) {
// Fallback to PHP hash if doveadm not available
$passwordHash = '{SHA512-CRYPT}' . crypt($password, '$6$' . bin2hex(random_bytes(8)) . '$');
}
// Full maildir path for database storage (Dovecot will use this)
$maildirPath = "/home/{$username}/mail/{$domain}/{$localPart}/";
// Add to virtual_mailbox_maps (Postfix uses this for delivery)
// Format: email transport:path (lmtp delivers to Dovecot which looks up path from DB)
$mailboxesFile = POSTFIX_VIRTUAL_MAILBOXES;
$mailboxesContent = file_exists($mailboxesFile) ? file_get_contents($mailboxesFile) : '';
if (strpos($mailboxesContent, $email) === false) {
// Store just the relative path for Postfix virtual transport
file_put_contents($mailboxesFile, trim($mailboxesContent) . "\n{$email} {$domain}/{$localPart}/\n");
exec('postmap ' . escapeshellarg($mailboxesFile));
}
$action = $folderExists ? 'Recreated' : 'Created';
logger("{$action} mailbox: {$email} at {$maildirPath}");
return [
'success' => true,
'message' => "Mailbox {$email} {$action}",
'email' => $email,
'password_hash' => $passwordHash,
'maildir_path' => $maildirPath,
'uid' => $uid,
'gid' => $gid,
];
}
function emailMailboxDelete(array $params): array
{
$email = strtolower(trim($params['email'] ?? ''));
$deleteFiles = (bool) ($params['delete_files'] ?? false);
$maildirPath = $params['maildir_path'] ?? '';
$username = $params['username'] ?? '';
if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
return ['success' => false, 'error' => 'Invalid email address'];
}
list($localPart, $domain) = explode('@', $email);
// Remove from virtual_mailbox_maps
$mailboxesFile = POSTFIX_VIRTUAL_MAILBOXES;
if (file_exists($mailboxesFile)) {
$content = file_get_contents($mailboxesFile);
$content = preg_replace('/^' . preg_quote($email, '/') . '\s+.*$/m', '', $content);
file_put_contents($mailboxesFile, trim($content) . "\n");
exec('postmap ' . escapeshellarg($mailboxesFile));
}
// Delete mailbox directory if requested
if ($deleteFiles) {
$mailboxDir = null;
// Try the provided path first
if (!empty($maildirPath) && strpos($maildirPath, '/home/') === 0) {
$mailboxDir = rtrim($maildirPath, '/');
}
// If path doesn't exist or wasn't provided, try to find it
if (empty($mailboxDir) || !is_dir($mailboxDir)) {
// Try /home/{username}/mail/{domain}/{localPart}
if (!empty($username)) {
$tryPath = "/home/$username/mail/$domain/$localPart";
if (is_dir($tryPath)) {
$mailboxDir = $tryPath;
}
}
// Try to find in any user's home if username not provided
if (empty($mailboxDir)) {
$pattern = "/home/*/mail/$domain/$localPart";
$matches = glob($pattern);
if (!empty($matches) && is_dir($matches[0])) {
$mailboxDir = $matches[0];
}
}
}
// Delete if found
if (!empty($mailboxDir) && is_dir($mailboxDir)) {
exec('rm -rf ' . escapeshellarg($mailboxDir));
logger("Deleted mailbox files: {$mailboxDir}");
}
}
logger("Deleted mailbox: {$email}" . ($deleteFiles ? " with files" : ""));
return ['success' => true, 'message' => "Mailbox {$email} deleted"];
}
function emailMailboxChangePassword(array $params): array
{
$email = strtolower(trim($params['email'] ?? ''));
$password = $params['password'] ?? '';
if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
return ['success' => false, 'error' => 'Invalid email address'];
}
if (empty($password) || strlen($password) < 8) {
return ['success' => false, 'error' => 'Password must be at least 8 characters'];
}
// Generate password hash
$hashCmd = sprintf('doveadm pw -s SHA512-CRYPT -p %s 2>&1', escapeshellarg($password));
$passwordHash = trim(shell_exec($hashCmd));
if (empty($passwordHash) || strpos($passwordHash, '{SHA512-CRYPT}') === false) {
$passwordHash = '{SHA512-CRYPT}' . crypt($password, '$6$' . bin2hex(random_bytes(8)) . '$');
}
logger("Changed password for mailbox: {$email}");
return [
'success' => true,
'message' => "Password changed for {$email}",
'password_hash' => $passwordHash,
];
}
/**
* Generate a password hash for email authentication (Dovecot SHA512-CRYPT format).
*/
function emailHashPassword(array $params): array
{
$password = $params['password'] ?? '';
if (empty($password)) {
return ['success' => false, 'error' => 'Password is required'];
}
// Generate password hash using doveadm
$hashCmd = sprintf('doveadm pw -s SHA512-CRYPT -p %s 2>&1', escapeshellarg($password));
$passwordHash = trim(shell_exec($hashCmd));
if (empty($passwordHash) || strpos($passwordHash, '{SHA512-CRYPT}') === false) {
// Fallback to PHP hash if doveadm not available
$passwordHash = '{SHA512-CRYPT}' . crypt($password, '$6$' . bin2hex(random_bytes(8)) . '$');
}
return [
'success' => true,
'password_hash' => $passwordHash,
];
}
function emailMailboxSetQuota(array $params): array
{
$email = strtolower(trim($params['email'] ?? ''));
$quotaBytes = (int) ($params['quota_bytes'] ?? 0);
if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
return ['success' => false, 'error' => 'Invalid email address'];
}
// Quota is managed in the panel database and enforced by Dovecot via SQL query
// This function is a placeholder for any file-based quota management
logger("Set quota for {$email}: {$quotaBytes} bytes");
return [
'success' => true,
'message' => "Quota set for {$email}",
'quota_bytes' => $quotaBytes,
];
}
function emailMailboxGetQuotaUsage(array $params): array
{
$email = strtolower(trim($params['email'] ?? ''));
$maildirPath = $params['maildir_path'] ?? '';
if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
return ['success' => false, 'error' => 'Invalid email address'];
}
// maildir_path is required - it contains the full path to the mailbox
if (empty($maildirPath)) {
return ['success' => false, 'error' => 'maildir_path parameter required'];
}
$mailboxDir = rtrim($maildirPath, '/');
if (!is_dir($mailboxDir)) {
return ['success' => false, 'error' => 'Mailbox not found'];
}
// Calculate directory size
$sizeCmd = sprintf('du -sb %s 2>/dev/null | cut -f1', escapeshellarg($mailboxDir));
$sizeBytes = (int) trim(shell_exec($sizeCmd));
return [
'success' => true,
'email' => $email,
'quota_used_bytes' => $sizeBytes,
];
}
function emailMailboxToggle(array $params): array
{
$email = strtolower(trim($params['email'] ?? ''));
$active = (bool) ($params['active'] ?? true);
if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
return ['success' => false, 'error' => 'Invalid email address'];
}
// Mailbox status is managed in the panel database
// Dovecot SQL query checks is_active field
logger("Toggled mailbox {$email}: " . ($active ? 'active' : 'inactive'));
return [
'success' => true,
'message' => "Mailbox {$email} " . ($active ? 'activated' : 'deactivated'),
];
}
function emailSyncVirtualUsers(array $params): array
{
$domain = strtolower(trim($params['domain'] ?? ''));
if (empty($domain)) {
return ['success' => false, 'error' => 'Domain required'];
}
// Rebuild postfix hash files
exec('postmap ' . escapeshellarg(POSTFIX_VIRTUAL_DOMAINS) . ' 2>&1');
exec('postmap ' . escapeshellarg(POSTFIX_VIRTUAL_MAILBOXES) . ' 2>&1');
exec('postmap ' . escapeshellarg(POSTFIX_VIRTUAL_ALIASES) . ' 2>&1');
logger("Synced virtual users for domain: {$domain}");
return ['success' => true, 'message' => "Virtual users synced for {$domain}"];
}
function emailReloadServices(array $params): array
{
$output = [];
$success = true;
// Reload Postfix
exec('systemctl reload postfix 2>&1', $postfixOutput, $postfixExit);
if ($postfixExit !== 0) {
$success = false;
$output[] = 'Postfix reload failed: ' . implode(' ', $postfixOutput);
}
// Reload Dovecot
exec('systemctl reload dovecot 2>&1', $dovecotOutput, $dovecotExit);
if ($dovecotExit !== 0) {
$success = false;
$output[] = 'Dovecot reload failed: ' . implode(' ', $dovecotOutput);
}
if ($success) {
logger("Reloaded email services");
return ['success' => true, 'message' => 'Email services reloaded'];
}
return ['success' => false, 'error' => implode('; ', $output)];
}
// ============ Email Forwarder Management ============
function emailForwarderCreate(array $params): array
{
$username = $params['username'] ?? '';
$email = $params['email'] ?? '';
$destinations = $params['destinations'] ?? [];
if (empty($username) || empty($email) || empty($destinations)) {
return ['success' => false, 'error' => 'Missing required parameters'];
}
// Validate email format
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return ['success' => false, 'error' => 'Invalid email address'];
}
// Validate destinations
foreach ($destinations as $dest) {
if (!filter_var($dest, FILTER_VALIDATE_EMAIL)) {
return ['success' => false, 'error' => "Invalid destination email: $dest"];
}
}
$virtualAliasFile = '/etc/postfix/virtual_aliases';
$destinationsList = implode(', ', $destinations);
// Read existing aliases
$aliases = [];
if (file_exists($virtualAliasFile)) {
$lines = file($virtualAliasFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
$line = trim($line);
if (empty($line) || $line[0] === '#') continue;
$parts = preg_split('/\s+/', $line, 2);
if (count($parts) === 2) {
$aliases[$parts[0]] = $parts[1];
}
}
}
// Check if alias already exists
if (isset($aliases[$email])) {
return ['success' => false, 'error' => 'Forwarder already exists'];
}
// Add new alias
$aliases[$email] = $destinationsList;
// Write back to file
$content = "# Virtual Aliases - Managed by Jabali Panel\n";
foreach ($aliases as $from => $to) {
$content .= "$from\t$to\n";
}
if (file_put_contents($virtualAliasFile, $content) === false) {
return ['success' => false, 'error' => 'Failed to write virtual aliases file'];
}
// Rebuild alias database
exec('postmap /etc/postfix/virtual_aliases 2>&1', $output, $exitCode);
if ($exitCode !== 0) {
return ['success' => false, 'error' => 'Failed to rebuild alias database'];
}
// Reload Postfix
exec('systemctl reload postfix 2>&1');
logger("Created forwarder: $email -> $destinationsList");
return ['success' => true];
}
function emailForwarderDelete(array $params): array
{
$username = $params['username'] ?? '';
$email = $params['email'] ?? '';
if (empty($email)) {
return ['success' => false, 'error' => 'Missing email parameter'];
}
$virtualAliasFile = '/etc/postfix/virtual_aliases';
if (!file_exists($virtualAliasFile)) {
return ['success' => false, 'error' => 'Virtual aliases file not found'];
}
// Read existing aliases
$aliases = [];
$lines = file($virtualAliasFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
$line = trim($line);
if (empty($line) || $line[0] === '#') continue;
$parts = preg_split('/\s+/', $line, 2);
if (count($parts) === 2 && $parts[0] !== $email) {
$aliases[$parts[0]] = $parts[1];
}
}
// Write back to file
$content = "# Virtual Aliases - Managed by Jabali Panel\n";
foreach ($aliases as $from => $to) {
$content .= "$from\t$to\n";
}
if (file_put_contents($virtualAliasFile, $content) === false) {
return ['success' => false, 'error' => 'Failed to write virtual aliases file'];
}
// Rebuild alias database
exec('postmap /etc/postfix/virtual_aliases 2>&1', $output, $exitCode);
if ($exitCode !== 0) {
return ['success' => false, 'error' => 'Failed to rebuild alias database'];
}
// Reload Postfix
exec('systemctl reload postfix 2>&1');
logger("Deleted forwarder: $email");
return ['success' => true];
}
function emailForwarderUpdate(array $params): array
{
$username = $params['username'] ?? '';
$email = $params['email'] ?? '';
$destinations = $params['destinations'] ?? [];
if (empty($email) || empty($destinations)) {
return ['success' => false, 'error' => 'Missing required parameters'];
}
// Validate destinations
foreach ($destinations as $dest) {
if (!filter_var($dest, FILTER_VALIDATE_EMAIL)) {
return ['success' => false, 'error' => "Invalid destination email: $dest"];
}
}
$virtualAliasFile = '/etc/postfix/virtual_aliases';
$destinationsList = implode(', ', $destinations);
if (!file_exists($virtualAliasFile)) {
return ['success' => false, 'error' => 'Virtual aliases file not found'];
}
// Read existing aliases
$aliases = [];
$found = false;
$lines = file($virtualAliasFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
$line = trim($line);
if (empty($line) || $line[0] === '#') continue;
$parts = preg_split('/\s+/', $line, 2);
if (count($parts) === 2) {
if ($parts[0] === $email) {
$aliases[$parts[0]] = $destinationsList;
$found = true;
} else {
$aliases[$parts[0]] = $parts[1];
}
}
}
if (!$found) {
return ['success' => false, 'error' => 'Forwarder not found'];
}
// Write back to file
$content = "# Virtual Aliases - Managed by Jabali Panel\n";
foreach ($aliases as $from => $to) {
$content .= "$from\t$to\n";
}
if (file_put_contents($virtualAliasFile, $content) === false) {
return ['success' => false, 'error' => 'Failed to write virtual aliases file'];
}
// Rebuild alias database
exec('postmap /etc/postfix/virtual_aliases 2>&1', $output, $exitCode);
if ($exitCode !== 0) {
return ['success' => false, 'error' => 'Failed to rebuild alias database'];
}
// Reload Postfix
exec('systemctl reload postfix 2>&1');
logger("Updated forwarder: $email -> $destinationsList");
return ['success' => true];
}
function emailForwarderToggle(array $params): array
{
$username = $params['username'] ?? '';
$email = $params['email'] ?? '';
$active = $params['active'] ?? true;
if (empty($email)) {
return ['success' => false, 'error' => 'Missing email parameter'];
}
$virtualAliasFile = '/etc/postfix/virtual_aliases';
if (!file_exists($virtualAliasFile)) {
return ['success' => false, 'error' => 'Virtual aliases file not found'];
}
// Read existing aliases and comments (for disabled ones)
$content = "# Virtual Aliases - Managed by Jabali Panel\n";
$found = false;
$lines = file($virtualAliasFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
$line = trim($line);
if (empty($line)) continue;
// Check for disabled forwarder (commented out)
if (preg_match('/^#DISABLED:\s*(.+?)\s+(.+)$/', $line, $matches)) {
$forwarderEmail = $matches[1];
$destinations = $matches[2];
if ($forwarderEmail === $email) {
$found = true;
if ($active) {
// Re-enable: remove comment
$content .= "$forwarderEmail\t$destinations\n";
} else {
// Keep disabled
$content .= "$line\n";
}
} else {
$content .= "$line\n";
}
} elseif ($line[0] === '#') {
// Other comments - keep as-is
$content .= "$line\n";
} else {
// Active forwarder
$parts = preg_split('/\s+/', $line, 2);
if (count($parts) === 2) {
if ($parts[0] === $email) {
$found = true;
if ($active) {
// Keep active
$content .= "$line\n";
} else {
// Disable: comment out
$content .= "#DISABLED: $line\n";
}
} else {
$content .= "$line\n";
}
}
}
}
if (!$found) {
return ['success' => false, 'error' => 'Forwarder not found'];
}
if (file_put_contents($virtualAliasFile, $content) === false) {
return ['success' => false, 'error' => 'Failed to write virtual aliases file'];
}
// Rebuild alias database
exec('postmap /etc/postfix/virtual_aliases 2>&1', $output, $exitCode);
if ($exitCode !== 0) {
return ['success' => false, 'error' => 'Failed to rebuild alias database'];
}
// Reload Postfix
exec('systemctl reload postfix 2>&1');
$status = $active ? 'enabled' : 'disabled';
logger("Forwarder $status: $email");
return ['success' => true];
}
function emailCatchallUpdate(array $params): array
{
$domain = $params['domain'] ?? '';
$enabled = $params['enabled'] ?? false;
$address = $params['address'] ?? '';
if (empty($domain)) {
return ['success' => false, 'error' => 'Missing domain parameter'];
}
$virtualAliasFile = '/etc/postfix/virtual_aliases';
// Read existing content
$content = "# Virtual Aliases - Managed by Jabali Panel\n";
if (file_exists($virtualAliasFile)) {
$lines = file($virtualAliasFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
$line = trim($line);
if (empty($line)) continue;
// Skip existing catch-all for this domain
if (preg_match('/^@' . preg_quote($domain, '/') . '\s+/', $line) ||
preg_match('/^#CATCHALL:\s*@' . preg_quote($domain, '/') . '\s+/', $line)) {
continue;
}
$content .= "$line\n";
}
}
// Add catch-all if enabled
if ($enabled && !empty($address)) {
$content .= "@{$domain}\t{$address}\n";
}
if (file_put_contents($virtualAliasFile, $content) === false) {
return ['success' => false, 'error' => 'Failed to write virtual aliases file'];
}
// Rebuild alias database
exec('postmap /etc/postfix/virtual_aliases 2>&1', $output, $exitCode);
if ($exitCode !== 0) {
return ['success' => false, 'error' => 'Failed to rebuild alias database'];
}
// Reload Postfix
exec('systemctl reload postfix 2>&1');
$status = $enabled ? "enabled (-> $address)" : 'disabled';
logger("Catch-all $status for domain: $domain");
return ['success' => true];
}
/**
* Set or update autoresponder/vacation message using Sieve
*/
function emailAutoresponderSet(array $params): array
{
$email = $params['email'] ?? '';
$subject = $params['subject'] ?? 'Out of Office';
$message = $params['message'] ?? '';
$startDate = $params['start_date'] ?? null;
$endDate = $params['end_date'] ?? null;
$active = $params['active'] ?? true;
if (empty($email) || empty($message)) {
return ['success' => false, 'error' => 'Missing email or message parameter'];
}
// Parse email to get domain and local part
$parts = explode('@', $email);
if (count($parts) !== 2) {
return ['success' => false, 'error' => 'Invalid email address'];
}
$localPart = $parts[0];
$domain = $parts[1];
// Sieve directory for user
$sieveDir = "/var/vmail/{$domain}/{$localPart}/sieve";
$sieveFile = "{$sieveDir}/vacation.sieve";
$sieveActive = "{$sieveDir}/.dovecot.sieve";
// Create sieve directory if it doesn't exist
if (!is_dir($sieveDir)) {
mkdir($sieveDir, 0700, true);
chown($sieveDir, 'vmail');
chgrp($sieveDir, 'vmail');
}
// Build date conditions for Sieve
$dateConditions = '';
if ($startDate || $endDate) {
$dateConditions = 'require "date";' . "\n" . 'require "relational";' . "\n";
}
// Escape message for Sieve (handle multi-line)
$escapedMessage = str_replace(['\\', '"'], ['\\\\', '\\"'], $message);
$escapedSubject = str_replace(['\\', '"'], ['\\\\', '\\"'], $subject);
// Build Sieve script
$sieveScript = << false, 'error' => 'Failed to write Sieve script'];
}
chown($sieveFile, 'vmail');
chgrp($sieveFile, 'vmail');
chmod($sieveFile, 0600);
// Compile Sieve script
exec("sievec " . escapeshellarg($sieveFile) . " 2>&1", $output, $exitCode);
if ($exitCode !== 0) {
logger("Sieve compile warning: " . implode("\n", $output));
// Don't fail - some warnings are OK
}
// Activate or deactivate
if ($active) {
// Create symlink to activate
if (file_exists($sieveActive) || is_link($sieveActive)) {
unlink($sieveActive);
}
symlink($sieveFile, $sieveActive);
chown($sieveActive, 'vmail');
chgrp($sieveActive, 'vmail');
} else {
// Remove symlink to deactivate
if (file_exists($sieveActive) || is_link($sieveActive)) {
unlink($sieveActive);
}
}
logger("Autoresponder " . ($active ? 'enabled' : 'disabled') . " for: $email");
return ['success' => true];
}
/**
* Toggle autoresponder on/off
*/
function emailAutoresponderToggle(array $params): array
{
$email = $params['email'] ?? '';
$active = $params['active'] ?? false;
if (empty($email)) {
return ['success' => false, 'error' => 'Missing email parameter'];
}
$parts = explode('@', $email);
if (count($parts) !== 2) {
return ['success' => false, 'error' => 'Invalid email address'];
}
$localPart = $parts[0];
$domain = $parts[1];
$sieveDir = "/var/vmail/{$domain}/{$localPart}/sieve";
$sieveFile = "{$sieveDir}/vacation.sieve";
$sieveActive = "{$sieveDir}/.dovecot.sieve";
if (!file_exists($sieveFile)) {
return ['success' => false, 'error' => 'No autoresponder configured'];
}
if ($active) {
// Activate
if (file_exists($sieveActive) || is_link($sieveActive)) {
unlink($sieveActive);
}
symlink($sieveFile, $sieveActive);
chown($sieveActive, 'vmail');
chgrp($sieveActive, 'vmail');
} else {
// Deactivate
if (file_exists($sieveActive) || is_link($sieveActive)) {
unlink($sieveActive);
}
}
logger("Autoresponder " . ($active ? 'enabled' : 'disabled') . " for: $email");
return ['success' => true];
}
/**
* Delete autoresponder
*/
function emailAutoresponderDelete(array $params): array
{
$email = $params['email'] ?? '';
if (empty($email)) {
return ['success' => false, 'error' => 'Missing email parameter'];
}
$parts = explode('@', $email);
if (count($parts) !== 2) {
return ['success' => false, 'error' => 'Invalid email address'];
}
$localPart = $parts[0];
$domain = $parts[1];
$sieveDir = "/var/vmail/{$domain}/{$localPart}/sieve";
$sieveFile = "{$sieveDir}/vacation.sieve";
$sieveCompiled = "{$sieveDir}/vacation.svbin";
$sieveActive = "{$sieveDir}/.dovecot.sieve";
// Remove active symlink
if (file_exists($sieveActive) || is_link($sieveActive)) {
unlink($sieveActive);
}
// Remove Sieve script and compiled version
if (file_exists($sieveFile)) {
unlink($sieveFile);
}
if (file_exists($sieveCompiled)) {
unlink($sieveCompiled);
}
logger("Autoresponder deleted for: $email");
return ['success' => true];
}
function emailGetLogs(array $params): array
{
$username = $params['username'] ?? '';
$limit = min($params['limit'] ?? 100, 500);
// Get user's domains to filter logs
$userDomains = [];
if (!empty($username)) {
// Read from database - we'll get domains from the mail log patterns
// For now, parse logs without domain filter for the user
}
$logs = [];
$mailLogFile = '/var/log/mail.log';
if (!file_exists($mailLogFile)) {
return ['success' => true, 'logs' => []];
}
// Read last N lines of mail log
$output = [];
exec("tail -n 1000 " . escapeshellarg($mailLogFile), $output);
$currentMessage = null;
$messageIndex = [];
foreach ($output as $line) {
// Parse Postfix log lines - support both traditional syslog and ISO 8601 timestamp formats
// Traditional: Jan 17 10:30:45 server postfix/smtp[12345]: MSGID: to=, ...
// ISO 8601: 2026-01-18T00:37:30.123456+00:00 server postfix/smtp[12345]: MSGID: ...
$timestamp = null;
$component = null;
$queueId = null;
$message = null;
// Try ISO 8601 format first (modern systemd/journald)
if (preg_match('/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2})?)\s+\S+\s+postfix\/(\w+)\[(\d+)\]:\s+([A-F0-9]+):\s+(.+)$/', $line, $matches)) {
$timestamp = strtotime($matches[1]);
$component = $matches[2];
$queueId = $matches[4];
$message = $matches[5];
}
// Try traditional syslog format
elseif (preg_match('/^(\w+\s+\d+\s+\d+:\d+:\d+)\s+\S+\s+postfix\/(\w+)\[(\d+)\]:\s+([A-F0-9]+):\s+(.+)$/', $line, $matches)) {
$timestamp = strtotime($matches[1] . ' ' . date('Y'));
$component = $matches[2];
$queueId = $matches[4];
$message = $matches[5];
}
if ($timestamp && $queueId && $message) {
// Initialize message entry
if (!isset($messageIndex[$queueId])) {
$messageIndex[$queueId] = [
'timestamp' => $timestamp,
'queue_id' => $queueId,
'from' => null,
'to' => null,
'subject' => null,
'status' => 'unknown',
'message' => '',
];
}
// Parse from
if (preg_match('/from=<([^>]*)>/', $message, $fromMatch)) {
$messageIndex[$queueId]['from'] = $fromMatch[1];
}
// Parse to
if (preg_match('/to=<([^>]*)>/', $message, $toMatch)) {
$messageIndex[$queueId]['to'] = $toMatch[1];
}
// Parse status
if (preg_match('/status=(\w+)/', $message, $statusMatch)) {
$messageIndex[$queueId]['status'] = $statusMatch[1];
$messageIndex[$queueId]['message'] = $message;
}
// Parse delay and relay info
if (preg_match('/relay=([^,]+)/', $message, $relayMatch)) {
$messageIndex[$queueId]['relay'] = $relayMatch[1];
}
}
}
// Convert to array and limit
$logs = array_values($messageIndex);
// Sort by timestamp descending
usort($logs, fn($a, $b) => $b['timestamp'] <=> $a['timestamp']);
// Limit results
$logs = array_slice($logs, 0, $limit);
return ['success' => true, 'logs' => $logs];
}
// ============ UFW Firewall Management ============
function ufwStatus(array $params): array
{
$output = [];
exec('ufw status verbose 2>&1', $output, $exitCode);
$status = implode("\n", $output);
$isActive = strpos($status, 'Status: active') !== false;
// Parse default policies
$defaultIncoming = 'deny';
$defaultOutgoing = 'allow';
if (preg_match('/Default: (\w+) \(incoming\), (\w+) \(outgoing\)/', $status, $matches)) {
$defaultIncoming = $matches[1];
$defaultOutgoing = $matches[2];
}
return [
'success' => true,
'active' => $isActive,
'status_text' => $status,
'default_incoming' => $defaultIncoming,
'default_outgoing' => $defaultOutgoing,
];
}
function ufwListRules(array $params): array
{
$output = [];
exec('ufw status numbered 2>&1', $output, $exitCode);
$rules = [];
$statusLine = '';
foreach ($output as $line) {
if (strpos($line, 'Status:') !== false) {
$statusLine = $line;
continue;
}
// Parse numbered rules like: [ 1] 22/tcp ALLOW IN Anywhere
if (preg_match('/\[\s*(\d+)\]\s+(.+?)\s+(ALLOW|DENY|REJECT|LIMIT)\s+(IN|OUT)?\s*(.*)/', $line, $matches)) {
$rules[] = [
'number' => (int)$matches[1],
'to' => trim($matches[2]),
'action' => $matches[3],
'direction' => $matches[4] ?: 'IN',
'from' => trim($matches[5]),
];
}
}
return [
'success' => true,
'rules' => $rules,
'raw_output' => implode("\n", $output),
];
}
function ufwEnable(array $params): array
{
exec('echo "y" | ufw enable 2>&1', $output, $exitCode);
return [
'success' => $exitCode === 0,
'message' => implode("\n", $output),
];
}
function ufwDisable(array $params): array
{
exec('ufw disable 2>&1', $output, $exitCode);
return [
'success' => $exitCode === 0,
'message' => implode("\n", $output),
];
}
function ufwAllowPort(array $params): array
{
$port = $params['port'] ?? '';
$protocol = $params['protocol'] ?? ''; // tcp, udp, or empty for both
$comment = $params['comment'] ?? '';
if (empty($port)) {
return ['success' => false, 'error' => 'Port is required'];
}
// Validate port
if (!preg_match('/^\d+$/', $port) && !preg_match('/^\d+:\d+$/', $port)) {
return ['success' => false, 'error' => 'Invalid port format'];
}
$rule = $port;
if (!empty($protocol)) {
$rule .= '/' . $protocol;
}
$cmd = "ufw allow $rule";
if (!empty($comment)) {
$cmd .= " comment " . escapeshellarg($comment);
}
exec($cmd . ' 2>&1', $output, $exitCode);
return [
'success' => $exitCode === 0,
'message' => implode("\n", $output),
];
}
function ufwDenyPort(array $params): array
{
$port = $params['port'] ?? '';
$protocol = $params['protocol'] ?? '';
$comment = $params['comment'] ?? '';
if (empty($port)) {
return ['success' => false, 'error' => 'Port is required'];
}
$rule = $port;
if (!empty($protocol)) {
$rule .= '/' . $protocol;
}
$cmd = "ufw deny $rule";
if (!empty($comment)) {
$cmd .= " comment " . escapeshellarg($comment);
}
exec($cmd . ' 2>&1', $output, $exitCode);
return [
'success' => $exitCode === 0,
'message' => implode("\n", $output),
];
}
function ufwAllowIp(array $params): array
{
$ip = $params['ip'] ?? '';
$port = $params['port'] ?? '';
$protocol = $params['protocol'] ?? '';
$comment = $params['comment'] ?? '';
if (empty($ip)) {
return ['success' => false, 'error' => 'IP address is required'];
}
// Validate IP (simple check)
if (!filter_var($ip, FILTER_VALIDATE_IP) && !preg_match('/^\d+\.\d+\.\d+\.\d+\/\d+$/', $ip)) {
return ['success' => false, 'error' => 'Invalid IP address format'];
}
$cmd = "ufw allow from " . escapeshellarg($ip);
if (!empty($port)) {
$cmd .= " to any port $port";
if (!empty($protocol)) {
$cmd .= " proto $protocol";
}
}
if (!empty($comment)) {
$cmd .= " comment " . escapeshellarg($comment);
}
exec($cmd . ' 2>&1', $output, $exitCode);
return [
'success' => $exitCode === 0,
'message' => implode("\n", $output),
];
}
function ufwDenyIp(array $params): array
{
$ip = $params['ip'] ?? '';
$port = $params['port'] ?? '';
$protocol = $params['protocol'] ?? '';
$comment = $params['comment'] ?? '';
if (empty($ip)) {
return ['success' => false, 'error' => 'IP address is required'];
}
if (!filter_var($ip, FILTER_VALIDATE_IP) && !preg_match('/^\d+\.\d+\.\d+\.\d+\/\d+$/', $ip)) {
return ['success' => false, 'error' => 'Invalid IP address format'];
}
$cmd = "ufw deny from " . escapeshellarg($ip);
if (!empty($port)) {
$cmd .= " to any port $port";
if (!empty($protocol)) {
$cmd .= " proto $protocol";
}
}
if (!empty($comment)) {
$cmd .= " comment " . escapeshellarg($comment);
}
exec($cmd . ' 2>&1', $output, $exitCode);
return [
'success' => $exitCode === 0,
'message' => implode("\n", $output),
];
}
function ufwDeleteRule(array $params): array
{
$ruleNumber = $params['rule_number'] ?? '';
if (empty($ruleNumber)) {
return ['success' => false, 'error' => 'Rule number is required'];
}
exec('echo "y" | ufw delete ' . (int)$ruleNumber . ' 2>&1', $output, $exitCode);
return [
'success' => $exitCode === 0,
'message' => implode("\n", $output),
];
}
function ufwSetDefault(array $params): array
{
$direction = $params['direction'] ?? 'incoming'; // incoming or outgoing
$policy = $params['policy'] ?? 'deny'; // allow, deny, reject
if (!in_array($direction, ['incoming', 'outgoing'])) {
return ['success' => false, 'error' => 'Direction must be incoming or outgoing'];
}
if (!in_array($policy, ['allow', 'deny', 'reject'])) {
return ['success' => false, 'error' => 'Policy must be allow, deny, or reject'];
}
exec("ufw default $policy $direction 2>&1", $output, $exitCode);
return [
'success' => $exitCode === 0,
'message' => implode("\n", $output),
];
}
function ufwLimitPort(array $params): array
{
$port = $params['port'] ?? '';
$protocol = $params['protocol'] ?? 'tcp';
if (empty($port)) {
return ['success' => false, 'error' => 'Port is required'];
}
$rule = $port;
if (!empty($protocol)) {
$rule .= '/' . $protocol;
}
exec("ufw limit $rule 2>&1", $output, $exitCode);
return [
'success' => $exitCode === 0,
'message' => implode("\n", $output),
];
}
function ufwReset(array $params): array
{
exec('echo "y" | ufw reset 2>&1', $output, $exitCode);
return [
'success' => $exitCode === 0,
'message' => implode("\n", $output),
];
}
function ufwReload(array $params): array
{
exec('ufw reload 2>&1', $output, $exitCode);
return [
'success' => $exitCode === 0,
'message' => implode("\n", $output),
];
}
function ufwAllowService(array $params): array
{
$service = $params['service'] ?? '';
if (empty($service)) {
return ['success' => false, 'error' => 'Service name is required'];
}
// Whitelist common services
$allowedServices = ['ssh', 'http', 'https', 'ftp', 'smtp', 'pop3', 'imap', 'dns', 'mysql', 'postgresql'];
if (!in_array(strtolower($service), $allowedServices) && !preg_match('/^[a-zA-Z0-9_-]+$/', $service)) {
return ['success' => false, 'error' => 'Invalid service name'];
}
exec("ufw allow " . escapeshellarg($service) . " 2>&1", $output, $exitCode);
return [
'success' => $exitCode === 0,
'message' => implode("\n", $output),
];
}
// ============ PHP VERSION MANAGEMENT ============
function phpInstall(array $params): array
{
$version = $params['version'] ?? '';
if (!preg_match('/^\d+\.\d+$/', $version)) {
return ['success' => false, 'error' => 'Invalid PHP version format'];
}
// Check if dpkg/apt is locked
exec("fuser /var/lib/dpkg/lock-frontend 2>/dev/null", $lockOutput, $lockCode);
if ($lockCode === 0 && !empty($lockOutput)) {
return ['success' => false, 'error' => 'Package manager is busy. Please wait for the current operation to complete.'];
}
logger("Installing PHP $version");
if (!file_exists('/etc/apt/sources.list.d/php.list')) {
exec('apt-get update 2>&1');
exec('apt-get install -y apt-transport-https lsb-release ca-certificates curl 2>&1');
exec('curl -sSL https://packages.sury.org/php/apt.gpg | gpg --dearmor -o /usr/share/keyrings/php-archive-keyring.gpg 2>&1');
$release = trim(shell_exec('lsb_release -sc'));
file_put_contents('/etc/apt/sources.list.d/php.list',
"deb [signed-by=/usr/share/keyrings/php-archive-keyring.gpg] https://packages.sury.org/php/ $release main\n");
exec('apt-get update 2>&1');
}
// Core PHP packages + WordPress recommended modules (imagick, opcache, redis, soap, imap, exif)
$packages = "php{$version}-fpm php{$version}-cli php{$version}-common php{$version}-mysql php{$version}-zip php{$version}-gd php{$version}-mbstring php{$version}-curl php{$version}-xml php{$version}-bcmath php{$version}-intl php{$version}-imagick php{$version}-opcache php{$version}-redis php{$version}-soap php{$version}-imap php{$version}-exif";
exec("DEBIAN_FRONTEND=noninteractive apt-get install -y $packages 2>&1", $output, $exitCode);
if ($exitCode !== 0) {
return ['success' => false, 'error' => 'Failed to install PHP: ' . implode("\n", $output)];
}
exec("systemctl enable php{$version}-fpm 2>&1");
exec("systemctl start php{$version}-fpm 2>&1");
return ['success' => true, 'version' => $version, 'message' => "PHP $version installed successfully"];
}
function phpUninstall(array $params): array
{
$version = $params['version'] ?? '';
if (!preg_match('/^\d+\.\d+$/', $version)) {
return ['success' => false, 'error' => 'Invalid PHP version format'];
}
// Check if dpkg/apt is locked
exec("fuser /var/lib/dpkg/lock-frontend 2>/dev/null", $lockOutput, $lockCode);
if ($lockCode === 0 && !empty($lockOutput)) {
return ['success' => false, 'error' => 'Package manager is busy. Please wait for the current operation to complete.'];
}
exec("systemctl stop php{$version}-fpm 2>/dev/null");
exec("systemctl disable php{$version}-fpm 2>/dev/null");
$output = [];
// Use purge instead of remove to also delete config files
exec("DEBIAN_FRONTEND=noninteractive apt-get purge -y php{$version}-* 2>&1", $output, $exitCode);
if ($exitCode !== 0) {
$errorOutput = implode("\n", $output);
if (strpos($errorOutput, 'lock') !== false || strpos($errorOutput, 'Could not get lock') !== false) {
return ['success' => false, 'error' => 'Package manager is busy. Please wait for the current operation to complete.'];
}
return ['success' => false, 'error' => "Failed to uninstall PHP $version: " . $errorOutput];
}
exec("DEBIAN_FRONTEND=noninteractive apt-get autoremove --purge -y 2>&1");
// Clean up any remaining config directory
$configDir = "/etc/php/{$version}";
if (is_dir($configDir)) {
exec("rm -rf " . escapeshellarg($configDir));
}
return ['success' => true, 'version' => $version, 'message' => "PHP $version uninstalled"];
}
function phpSetDefaultVersion(array $params): array
{
$version = $params['version'] ?? '';
if (!preg_match('/^\d+\.\d+$/', $version)) {
return ['success' => false, 'error' => 'Invalid PHP version format'];
}
exec("update-alternatives --set php /usr/bin/php{$version} 2>&1", $output, $exitCode);
return ['success' => $exitCode === 0, 'version' => $version, 'message' => "PHP $version set as default"];
}
function phpRestartFpm(array $params): array
{
return phpReloadFpm($params);
}
function phpReloadFpm(array $params): array
{
$version = $params['version'] ?? '';
if (!preg_match('/^\d+\.\d+$/', $version)) {
return ['success' => false, 'error' => 'Invalid PHP version format'];
}
// Check if PHP-FPM service exists
$serviceFile = "/lib/systemd/system/php{$version}-fpm.service";
if (!file_exists($serviceFile)) {
return ['success' => false, 'error' => "PHP $version is not installed"];
}
exec("systemctl reload php{$version}-fpm 2>&1", $output, $exitCode);
if ($exitCode !== 0) {
$errorOutput = implode("\n", $output);
return ['success' => false, 'error' => "Failed to reload PHP $version FPM: " . ($errorOutput ?: 'Service error')];
}
return ['success' => true, 'version' => $version, 'message' => "PHP $version FPM reloaded"];
}
function phpRestartAllFpm(array $params): array
{
return phpReloadAllFpm($params);
}
function phpReloadAllFpm(array $params): array
{
$background = $params['background'] ?? false;
$delay = (int) ($params['delay'] ?? 0);
// Find all installed PHP-FPM services
$services = glob('/lib/systemd/system/php*-fpm.service');
$versions = [];
foreach ($services as $serviceFile) {
if (preg_match('/php(\d+\.\d+)-fpm\.service$/', basename($serviceFile), $matches)) {
$versions[] = $matches[1];
}
}
if (empty($versions)) {
return ['success' => false, 'error' => 'No PHP-FPM services found'];
}
// Background reload to avoid blocking the caller
if ($background) {
$delayCmd = $delay > 0 ? "sleep {$delay} && " : '';
$cmd = "({$delayCmd}";
foreach ($versions as $version) {
$cmd .= "systemctl reload php{$version}-fpm 2>/dev/null; ";
}
$cmd .= ") > /dev/null 2>&1 &";
exec($cmd);
return [
'success' => true,
'background' => true,
'delay' => $delay,
'versions' => $versions,
'message' => 'PHP-FPM reload scheduled' . ($delay > 0 ? " in {$delay} seconds" : ' in background'),
];
}
$reloaded = [];
$failed = [];
foreach ($versions as $version) {
exec("systemctl reload php{$version}-fpm 2>&1", $output, $exitCode);
if ($exitCode === 0) {
$reloaded[] = $version;
} else {
$failed[] = $version;
}
}
return [
'success' => empty($failed),
'reloaded' => $reloaded,
'failed' => $failed,
'message' => 'Reloaded PHP-FPM versions: ' . implode(', ', $reloaded) . (empty($failed) ? '' : '. Failed: ' . implode(', ', $failed)),
];
}
function phpListVersions(array $params): array
{
$versions = [];
foreach (glob('/etc/php/*', GLOB_ONLYDIR) as $dir) {
$version = basename($dir);
if (preg_match('/^\d+\.\d+$/', $version)) {
// Check if PHP binary actually exists (not just config directory)
$phpBinary = "/usr/bin/php{$version}";
$fpmService = "/lib/systemd/system/php{$version}-fpm.service";
if (!file_exists($phpBinary) && !file_exists($fpmService)) {
// PHP was uninstalled but config dir remains - skip it
continue;
}
$fpmStatus = trim(shell_exec("systemctl is-active php{$version}-fpm 2>/dev/null") ?? 'inactive');
$versions[] = [
'version' => $version,
'fpm_status' => $fpmStatus ?: 'inactive',
];
}
}
$defaultVersion = null;
$phpV = shell_exec('php -v 2>/dev/null');
if ($phpV && preg_match('/PHP (\d+\.\d+)/', $phpV, $matches)) {
$defaultVersion = $matches[1];
}
return ['success' => true, 'versions' => $versions, 'default' => $defaultVersion];
}
function phpInstallWordPressModules(array $params): array
{
$version = $params['version'] ?? null;
// Get all installed PHP versions if no specific version provided
$versions = [];
if ($version) {
if (!preg_match('/^\d+\.\d+$/', $version)) {
return ['success' => false, 'error' => 'Invalid PHP version format'];
}
$versions[] = $version;
} else {
// Install for all installed PHP versions
foreach (glob('/etc/php/*', GLOB_ONLYDIR) as $dir) {
$ver = basename($dir);
if (preg_match('/^\d+\.\d+$/', $ver)) {
$versions[] = $ver;
}
}
}
if (empty($versions)) {
return ['success' => false, 'error' => 'No PHP versions found'];
}
logger("Installing WordPress PHP modules for versions: " . implode(', ', $versions));
// WordPress recommended modules
$wpModules = ['imagick', 'opcache', 'redis', 'soap', 'imap', 'exif'];
$installed = [];
$failed = [];
foreach ($versions as $ver) {
$packages = [];
foreach ($wpModules as $module) {
$packages[] = "php{$ver}-{$module}";
}
$packageList = implode(' ', $packages);
exec("apt-get install -y $packageList 2>&1", $output, $exitCode);
if ($exitCode === 0) {
$installed[] = $ver;
// Reload PHP-FPM for this version
exec("systemctl reload php{$ver}-fpm 2>/dev/null");
} else {
$failed[] = $ver;
}
}
if (empty($failed)) {
return [
'success' => true,
'message' => 'WordPress PHP modules installed for: ' . implode(', ', $installed),
'modules' => $wpModules,
'versions' => $installed
];
} elseif (!empty($installed)) {
return [
'success' => true,
'message' => 'Installed for: ' . implode(', ', $installed) . '. Failed for: ' . implode(', ', $failed),
'modules' => $wpModules,
'versions' => $installed,
'failed' => $failed
];
} else {
return ['success' => false, 'error' => 'Failed to install modules for all versions'];
}
}
// ============ SSH KEY GENERATION ============
function sshGenerateKey(array $params): array
{
$name = $params['name'] ?? 'key';
$type = $params['type'] ?? 'ed25519';
$passphrase = $params['passphrase'] ?? '';
$tempDir = sys_get_temp_dir() . '/jabali_ssh_' . uniqid();
mkdir($tempDir, 0700, true);
$keyFile = "$tempDir/key";
$pubFile = "$keyFile.pub";
$ppkFile = "$tempDir/key.ppk";
try {
$keyType = $type === 'ed25519' ? 'ed25519' : 'rsa';
$bits = $type === 'rsa' ? '-b 4096' : '';
$passOpt = $passphrase ? "-N " . escapeshellarg($passphrase) : "-N ''";
$cmd = "ssh-keygen -t {$keyType} {$bits} -f " . escapeshellarg($keyFile) . " {$passOpt} -C " . escapeshellarg($name) . " 2>&1";
exec($cmd, $output, $returnCode);
if ($returnCode !== 0 || !file_exists($keyFile)) {
return ['success' => false, 'error' => 'Failed to generate SSH key: ' . implode("\n", $output)];
}
$privateKey = file_get_contents($keyFile);
$publicKey = file_get_contents($pubFile);
// Get fingerprint
exec("ssh-keygen -lf " . escapeshellarg($pubFile) . " 2>&1", $fpOutput);
$fingerprint = $fpOutput[0] ?? '';
// Convert to PPK format
$ppkKey = null;
if ($passphrase) {
$ppkCmd = "puttygen " . escapeshellarg($keyFile) . " -o " . escapeshellarg($ppkFile) . " -O private -P --old-passphrase " . escapeshellarg($passphrase) . " --new-passphrase " . escapeshellarg($passphrase) . " 2>&1";
} else {
$ppkCmd = "puttygen " . escapeshellarg($keyFile) . " -o " . escapeshellarg($ppkFile) . " -O private 2>&1";
}
exec($ppkCmd, $ppkOutput, $ppkReturnCode);
if (file_exists($ppkFile)) {
$ppkKey = file_get_contents($ppkFile);
}
return [
'success' => true,
'name' => $name,
'type' => $type,
'private_key' => $privateKey,
'public_key' => $publicKey,
'ppk_key' => $ppkKey,
'fingerprint' => $fingerprint,
];
} finally {
@unlink($keyFile);
@unlink($pubFile);
@unlink($ppkFile);
@rmdir($tempDir);
}
}
// ============ SSH SHELL ACCESS MANAGEMENT ============
function sshEnableShell(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 does not exist'];
}
// Create user's home directory in jail
$jailHome = "/var/jail/home/$username";
if (!is_dir($jailHome)) {
mkdir($jailHome, 0750, true);
chown($jailHome, $username);
chgrp($jailHome, $username);
}
// Add user to shellusers group and remove from sftpusers
exec("usermod -aG shellusers " . escapeshellarg($username));
exec("gpasswd -d " . escapeshellarg($username) . " sftpusers 2>/dev/null");
// Change shell to bash (relative to chroot /var/jail, so /bin/bash)
exec("usermod -s /bin/bash " . escapeshellarg($username));
// Add user to jail's passwd/group
$passwdLine = "$username:x:{$userInfo['uid']}:{$userInfo['gid']}::/home/$username:/bin/bash\n";
file_put_contents('/var/jail/etc/passwd', $passwdLine, FILE_APPEND);
$groupLine = "$username:x:{$userInfo['gid']}:\n";
file_put_contents('/var/jail/etc/group', $groupLine, FILE_APPEND);
// Bind mount user's actual home to jail home for file access
$realHome = $userInfo['dir'];
if (!is_mounted($jailHome)) {
exec("mount --bind " . escapeshellarg($realHome) . " " . escapeshellarg($jailHome));
// Add to fstab for persistence
$fstabEntry = "$realHome $jailHome none bind 0 0\n";
if (strpos(file_get_contents('/etc/fstab'), $jailHome) === false) {
file_put_contents('/etc/fstab', $fstabEntry, FILE_APPEND);
}
}
logger("Enabled shell access for user $username");
return [
'success' => true,
'message' => "Shell access enabled for $username",
'shell' => '/bin/bash',
];
}
function sshDisableShell(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 does not exist'];
}
// Remove from shellusers and add to sftpusers
exec("gpasswd -d " . escapeshellarg($username) . " shellusers 2>/dev/null");
exec("usermod -aG sftpusers " . escapeshellarg($username));
// Change shell to nologin
exec("usermod -s /usr/sbin/nologin " . escapeshellarg($username));
// Unmount bind mount if exists
$jailHome = "/var/jail/home/$username";
if (is_mounted($jailHome)) {
exec("umount " . escapeshellarg($jailHome));
// Remove from fstab
$fstab = file_get_contents('/etc/fstab');
$fstab = preg_replace('/.*' . preg_quote($jailHome, '/') . '.*\n/', '', $fstab);
file_put_contents('/etc/fstab', $fstab);
}
// Remove from jail passwd/group
if (file_exists('/var/jail/etc/passwd')) {
$passwd = file_get_contents('/var/jail/etc/passwd');
$passwd = preg_replace('/^' . preg_quote($username, '/') . ':.*\n/m', '', $passwd);
file_put_contents('/var/jail/etc/passwd', $passwd);
}
if (file_exists('/var/jail/etc/group')) {
$group = file_get_contents('/var/jail/etc/group');
$group = preg_replace('/^' . preg_quote($username, '/') . ':.*\n/m', '', $group);
file_put_contents('/var/jail/etc/group', $group);
}
logger("Disabled shell access for user $username");
return [
'success' => true,
'message' => "Shell access disabled for $username (SFTP-only)",
'shell' => '/usr/sbin/nologin',
];
}
function sshGetShellStatus(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 does not exist'];
}
$shell = $userInfo['shell'];
$hasShell = !in_array($shell, ['/usr/sbin/nologin', '/bin/false', '/sbin/nologin']);
// Check group membership
exec("groups " . escapeshellarg($username), $groupOutput);
$groups = $groupOutput[0] ?? '';
$inShellUsers = strpos($groups, 'shellusers') !== false;
$inSftpUsers = strpos($groups, 'sftpusers') !== false;
return [
'success' => true,
'username' => $username,
'shell' => $shell,
'shell_enabled' => $hasShell && $inShellUsers,
'sftp_only' => !$hasShell || $inSftpUsers,
'groups' => $groups,
];
}
function is_mounted(string $path): bool
{
exec("mountpoint -q " . escapeshellarg($path), $output, $returnCode);
return $returnCode === 0;
}
// ============ SERVER SETTINGS ============
function setHostname(array $params): array
{
$hostname = $params['hostname'] ?? '';
if (empty($hostname) || !preg_match('/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/', $hostname)) {
return ['success' => false, 'error' => 'Invalid hostname format'];
}
// Set hostname
exec("hostnamectl set-hostname " . escapeshellarg($hostname) . " 2>&1", $output, $exitCode);
if ($exitCode !== 0) {
return ['success' => false, 'error' => 'Failed to set hostname: ' . implode("\n", $output)];
}
// Update /etc/hosts
$hosts = file_get_contents('/etc/hosts');
$shortname = explode('.', $hostname)[0];
// Update or add the 127.0.1.1 line
if (preg_match('/^127\.0\.1\.1\s+.*/m', $hosts)) {
$hosts = preg_replace('/^127\.0\.1\.1\s+.*/m', "127.0.1.1\t$hostname $shortname", $hosts);
} else {
$hosts .= "\n127.0.1.1\t$hostname $shortname\n";
}
file_put_contents('/etc/hosts', $hosts);
return ['success' => true, 'hostname' => $hostname];
}
function setUploadLimits(array $params): array
{
$sizeMb = (int) ($params['size_mb'] ?? 100);
// Validate size (1-500 MB)
if ($sizeMb < 1) $sizeMb = 1;
if ($sizeMb > 500) $sizeMb = 500;
$errors = [];
// Update nginx global config
$nginxConf = '/etc/nginx/nginx.conf';
if (file_exists($nginxConf)) {
$content = file_get_contents($nginxConf);
$content = preg_replace('/client_max_body_size\s+\d+M;/', "client_max_body_size {$sizeMb}M;", $content);
file_put_contents($nginxConf, $content);
}
// Update jabali site config
$jabaliSites = glob('/etc/nginx/sites-available/*');
foreach ($jabaliSites as $site) {
$content = file_get_contents($site);
if (strpos($content, 'client_max_body_size') !== false) {
$content = preg_replace('/client_max_body_size\s+\d+M;/', "client_max_body_size {$sizeMb}M;", $content);
file_put_contents($site, $content);
}
}
// Update PHP-FPM pools
$pools = glob('/etc/php/*/fpm/pool.d/*.conf');
foreach ($pools as $pool) {
$content = file_get_contents($pool);
$content = preg_replace('/^php_admin_value\[upload_max_filesize\].*$/m', "php_admin_value[upload_max_filesize] = {$sizeMb}M", $content);
$content = preg_replace('/^php_admin_value\[post_max_size\].*$/m', "php_admin_value[post_max_size] = {$sizeMb}M", $content);
file_put_contents($pool, $content);
}
// Update PHP ini files
$inis = glob('/etc/php/*/fpm/php.ini');
foreach ($inis as $ini) {
$content = file_get_contents($ini);
$content = preg_replace('/^upload_max_filesize\s*=.*/m', "upload_max_filesize = {$sizeMb}M", $content);
$content = preg_replace('/^post_max_size\s*=.*/m', "post_max_size = {$sizeMb}M", $content);
file_put_contents($ini, $content);
}
// Reload services
exec('systemctl reload nginx 2>&1', $output, $exitCode);
if ($exitCode !== 0) {
$errors[] = 'Failed to reload nginx';
}
exec('systemctl reload php*-fpm 2>&1', $output, $exitCode);
if ($exitCode !== 0) {
// Try specific version
exec('systemctl reload php8.4-fpm 2>&1', $output, $exitCode);
}
logger("Upload limits set to {$sizeMb}MB");
return [
'success' => empty($errors),
'size_mb' => $sizeMb,
'errors' => $errors
];
}
function updateBindConfig(array $params): array
{
$listenIp = $params['listen_ip'] ?? 'any';
$forwarders = $params['forwarders'] ?? ['8.8.8.8', '8.8.4.4'];
$forwardersList = implode("; ", array_map('trim', $forwarders)) . ";";
$config = <<&1', $checkOutput, $checkCode);
if ($checkCode !== 0) {
return ['success' => false, 'error' => 'Invalid BIND config: ' . implode("\n", $checkOutput)];
}
// Reload BIND
exec('systemctl reload bind9 2>&1 || systemctl reload named 2>&1', $reloadOutput, $reloadCode);
return ['success' => true, 'message' => 'BIND configuration updated'];
}
function getServerInfo(array $params): array
{
$info = [];
// Hostname
$info['hostname'] = trim(shell_exec('hostname -f 2>/dev/null') ?: shell_exec('hostname') ?: 'unknown');
// Server IP
$info['server_ip'] = trim(shell_exec("hostname -I 2>/dev/null | awk '{print \$1}'") ?: '');
// OS Info
if (file_exists('/etc/os-release')) {
$osRelease = parse_ini_file('/etc/os-release');
$info['os_info'] = $osRelease['PRETTY_NAME'] ?? 'Linux';
} else {
$info['os_info'] = PHP_OS;
}
// Uptime
if (file_exists('/proc/uptime')) {
$uptime = (float) file_get_contents('/proc/uptime');
$days = floor($uptime / 86400);
$hours = floor(($uptime % 86400) / 3600);
$info['uptime'] = "{$days} days, {$hours} hours";
}
// Load average
$load = sys_getloadavg();
$info['load_average'] = $load ? implode(', ', array_map(fn($v) => number_format($v, 2), $load)) : 'N/A';
// Memory
if (file_exists('/proc/meminfo')) {
$meminfo = file_get_contents('/proc/meminfo');
preg_match('/MemTotal:\s+(\d+)/', $meminfo, $total);
preg_match('/MemAvailable:\s+(\d+)/', $meminfo, $available);
$totalGb = round(($total[1] ?? 0) / 1024 / 1024, 1);
$availGb = round(($available[1] ?? 0) / 1024 / 1024, 1);
$info['memory'] = "{$availGb} GB free / {$totalGb} GB total";
}
// Disk
$diskTotal = disk_total_space('/');
$diskFree = disk_free_space('/');
if ($diskTotal && $diskFree) {
$totalGb = round($diskTotal / 1024 / 1024 / 1024, 1);
$freeGb = round($diskFree / 1024 / 1024 / 1024, 1);
$info['disk'] = "{$freeGb} GB free / {$totalGb} GB total";
}
return ['success' => true, 'info' => $info];
}
/**
* Get DNS resolver configuration from /etc/resolv.conf
*/
function serverGetResolvers(array $params): array
{
$resolvConf = '/etc/resolv.conf';
if (!file_exists($resolvConf)) {
return ['success' => false, 'error' => 'resolv.conf not found'];
}
$content = file_get_contents($resolvConf);
$lines = explode("\n", $content);
$nameservers = [];
$searchDomains = [];
$options = [];
foreach ($lines as $line) {
$line = trim($line);
if (empty($line) || $line[0] === '#') continue;
if (preg_match('/^nameserver\s+(\S+)/', $line, $m)) {
$nameservers[] = $m[1];
} elseif (preg_match('/^search\s+(.+)/', $line, $m)) {
$searchDomains = preg_split('/\s+/', trim($m[1]));
} elseif (preg_match('/^options\s+(.+)/', $line, $m)) {
$options = preg_split('/\s+/', trim($m[1]));
}
}
// Check if managed by systemd-resolved
$isSystemdResolved = is_link($resolvConf) && strpos(readlink($resolvConf), 'systemd') !== false;
return [
'success' => true,
'nameservers' => $nameservers,
'search_domains' => $searchDomains,
'options' => $options,
'is_systemd_resolved' => $isSystemdResolved,
'raw_content' => $content,
];
}
/**
* Set DNS resolver configuration in /etc/resolv.conf
*/
function serverSetResolvers(array $params): array
{
$nameservers = $params['nameservers'] ?? [];
$searchDomains = $params['search_domains'] ?? [];
if (empty($nameservers)) {
return ['success' => false, 'error' => 'At least one nameserver is required'];
}
// Validate nameservers
foreach ($nameservers as $ns) {
if (!filter_var($ns, FILTER_VALIDATE_IP)) {
return ['success' => false, 'error' => "Invalid IP address: $ns"];
}
}
$resolvConf = '/etc/resolv.conf';
// Check if managed by systemd-resolved
if (is_link($resolvConf) && strpos(readlink($resolvConf), 'systemd') !== false) {
// For systemd-resolved, we need to configure differently
// First try to use resolvectl
$nsArgs = implode(' ', array_map('escapeshellarg', $nameservers));
exec("resolvectl dns 2>/dev/null", $output, $code);
if ($code === 0) {
// Use systemd-resolved
exec("resolvectl dns eth0 $nsArgs 2>&1", $output, $code);
if ($code !== 0) {
// Try alternative interface names
foreach (['ens3', 'enp0s3', 'ens192', 'enp3s0'] as $iface) {
exec("resolvectl dns $iface $nsArgs 2>&1", $output, $code);
if ($code === 0) break;
}
}
if (!empty($searchDomains)) {
$searchArgs = implode(' ', array_map('escapeshellarg', $searchDomains));
exec("resolvectl domain eth0 $searchArgs 2>&1", $output, $code);
}
return ['success' => true, 'message' => 'DNS resolvers updated via systemd-resolved'];
}
}
// Build resolv.conf content
$content = "# Generated by Jabali Panel\n";
$content .= "# " . date('Y-m-d H:i:s') . "\n\n";
if (!empty($searchDomains)) {
$content .= "search " . implode(' ', $searchDomains) . "\n";
}
foreach ($nameservers as $ns) {
$content .= "nameserver $ns\n";
}
// Backup existing file
if (file_exists($resolvConf) && !is_link($resolvConf)) {
copy($resolvConf, $resolvConf . '.backup');
}
// Remove symlink if exists and write directly
if (is_link($resolvConf)) {
unlink($resolvConf);
}
if (file_put_contents($resolvConf, $content) === false) {
return ['success' => false, 'error' => 'Failed to write resolv.conf'];
}
// Make immutable to prevent overwriting by DHCP/networkmanager
exec('chattr +i /etc/resolv.conf 2>&1', $output, $code);
return ['success' => true, 'message' => 'DNS resolvers updated'];
}
/**
* Export server configuration including nginx vhosts, DNS zones, SSL certs, and maildir.
*/
function serverExportConfig(array $params): array
{
$outputPath = $params['output_path'] ?? '/tmp/jabali-config-export.tar.gz';
$includeNginx = $params['include_nginx'] ?? true;
$includeDns = $params['include_dns'] ?? true;
$includeSsl = $params['include_ssl'] ?? true;
$includeMaildir = $params['include_maildir'] ?? false;
$tempDir = sys_get_temp_dir() . '/jabali-export-' . uniqid();
mkdir($tempDir, 0755, true);
$manifest = [
'version' => '2.0',
'exported_at' => date('c'),
'hostname' => trim(shell_exec('hostname -f 2>/dev/null') ?: shell_exec('hostname') ?: ''),
'server_ip' => trim(shell_exec("hostname -I | awk '{print \$1}'") ?? ''),
'files' => [],
];
// Export nginx vhost configurations
if ($includeNginx) {
mkdir("$tempDir/nginx", 0755, true);
$nginxDirs = ['/etc/nginx/sites-available', '/etc/nginx/sites-enabled'];
foreach ($nginxDirs as $nginxDir) {
if (is_dir($nginxDir)) {
$files = scandir($nginxDir);
foreach ($files as $file) {
if ($file === '.' || $file === '..' || $file === 'default') continue;
$srcPath = "$nginxDir/$file";
if (is_file($srcPath) && !is_link($srcPath)) {
$destPath = "$tempDir/nginx/" . basename($nginxDir) . "_$file";
copy($srcPath, $destPath);
$manifest['files']['nginx'][] = [
'source' => $srcPath,
'name' => $file,
'dir' => basename($nginxDir),
];
}
}
}
}
}
// Export DNS zone files
if ($includeDns) {
mkdir("$tempDir/dns", 0755, true);
$zonesDir = '/etc/bind/zones';
if (is_dir($zonesDir)) {
$files = scandir($zonesDir);
foreach ($files as $file) {
if ($file === '.' || $file === '..') continue;
$srcPath = "$zonesDir/$file";
if (is_file($srcPath)) {
copy($srcPath, "$tempDir/dns/$file");
$manifest['files']['dns'][] = [
'source' => $srcPath,
'name' => $file,
];
}
}
}
// Export named.conf.local (zone definitions)
if (file_exists('/etc/bind/named.conf.local')) {
copy('/etc/bind/named.conf.local', "$tempDir/dns/named.conf.local");
$manifest['files']['dns'][] = [
'source' => '/etc/bind/named.conf.local',
'name' => 'named.conf.local',
];
}
}
// Export SSL certificates (both fullchain and privkey for full migration)
if ($includeSsl) {
mkdir("$tempDir/ssl", 0755, true);
$sslDir = '/etc/letsencrypt/live';
if (is_dir($sslDir)) {
$domains = scandir($sslDir);
foreach ($domains as $domain) {
if ($domain === '.' || $domain === '..' || $domain === 'README') continue;
$domainDir = "$sslDir/$domain";
if (!is_dir($domainDir)) continue;
mkdir("$tempDir/ssl/$domain", 0755, true);
// Copy all certificate files
foreach (['fullchain.pem', 'privkey.pem', 'cert.pem', 'chain.pem'] as $certFile) {
$certPath = "$domainDir/$certFile";
if (file_exists($certPath)) {
// Resolve symlink to get actual file
$realPath = realpath($certPath);
if ($realPath && file_exists($realPath)) {
copy($realPath, "$tempDir/ssl/$domain/$certFile");
$manifest['files']['ssl'][] = [
'domain' => $domain,
'file' => $certFile,
];
}
}
}
}
}
}
// Export maildir data for all users (can be very large)
if ($includeMaildir) {
mkdir("$tempDir/maildir", 0755, true);
$vhostsDir = '/var/mail/vhosts';
if (is_dir($vhostsDir)) {
$domains = scandir($vhostsDir);
foreach ($domains as $domain) {
if ($domain === '.' || $domain === '..') continue;
$domainPath = "$vhostsDir/$domain";
if (!is_dir($domainPath)) continue;
// Create tar.gz for each domain's mail
$mailArchive = "$tempDir/maildir/{$domain}.tar.gz";
exec("tar -I pigz -cf " . escapeshellarg($mailArchive) . " -C " . escapeshellarg($vhostsDir) . " " . escapeshellarg($domain) . " 2>&1", $output, $retval);
if ($retval === 0) {
$manifest['files']['maildir'][] = [
'domain' => $domain,
'archive' => "{$domain}.tar.gz",
'size' => filesize($mailArchive),
];
}
}
}
}
// Write manifest
file_put_contents("$tempDir/manifest.json", json_encode($manifest, JSON_PRETTY_PRINT));
// Create tar.gz archive
$parentDir = dirname($tempDir);
$dirName = basename($tempDir);
exec("cd " . escapeshellarg($parentDir) . " && tar -I pigz -cf " . escapeshellarg($outputPath) . " " . escapeshellarg($dirName) . " 2>&1", $output, $retval);
// Cleanup temp directory
exec("rm -rf " . escapeshellarg($tempDir));
if ($retval !== 0) {
return ['success' => false, 'error' => 'Failed to create export archive: ' . implode("\n", $output)];
}
$size = filesize($outputPath);
return [
'success' => true,
'path' => $outputPath,
'size' => $size,
'manifest' => $manifest,
];
}
/**
* Import server configuration from export archive.
*/
function serverImportConfig(array $params): array
{
$archivePath = $params['archive_path'] ?? '';
$importNginx = $params['import_nginx'] ?? true;
$importDns = $params['import_dns'] ?? true;
$importSsl = $params['import_ssl'] ?? true;
$importMaildir = $params['import_maildir'] ?? false;
$dryRun = $params['dry_run'] ?? false;
if (!file_exists($archivePath)) {
return ['success' => false, 'error' => 'Archive file not found'];
}
$tempDir = sys_get_temp_dir() . '/jabali-import-' . uniqid();
mkdir($tempDir, 0755, true);
// Extract archive
exec("tar -I pigz -xf " . escapeshellarg($archivePath) . " -C " . escapeshellarg($tempDir) . " 2>&1", $output, $retval);
if ($retval !== 0) {
exec("rm -rf " . escapeshellarg($tempDir));
return ['success' => false, 'error' => 'Failed to extract archive: ' . implode("\n", $output)];
}
// Find the extracted directory
$extractedDirs = array_filter(scandir($tempDir), fn($d) => $d !== '.' && $d !== '..' && is_dir("$tempDir/$d"));
if (empty($extractedDirs)) {
exec("rm -rf " . escapeshellarg($tempDir));
return ['success' => false, 'error' => 'Invalid archive structure'];
}
$extractedDir = "$tempDir/" . reset($extractedDirs);
// Read manifest
$manifestPath = "$extractedDir/manifest.json";
if (!file_exists($manifestPath)) {
exec("rm -rf " . escapeshellarg($tempDir));
return ['success' => false, 'error' => 'Manifest not found in archive'];
}
$manifest = json_decode(file_get_contents($manifestPath), true);
$imported = ['nginx' => [], 'dns' => [], 'ssl' => [], 'maildir' => []];
$errors = [];
// Import nginx configurations
if ($importNginx && isset($manifest['files']['nginx'])) {
foreach ($manifest['files']['nginx'] as $fileInfo) {
$srcFile = "$extractedDir/nginx/{$fileInfo['dir']}_{$fileInfo['name']}";
$destDir = $fileInfo['dir'] === 'sites-available' ? '/etc/nginx/sites-available' : '/etc/nginx/sites-enabled';
$destFile = "$destDir/{$fileInfo['name']}";
if (file_exists($srcFile)) {
if (!$dryRun) {
if (!is_dir($destDir)) {
mkdir($destDir, 0755, true);
}
if (copy($srcFile, $destFile)) {
$imported['nginx'][] = $fileInfo['name'];
} else {
$errors[] = "Failed to copy nginx config: {$fileInfo['name']}";
}
} else {
$imported['nginx'][] = $fileInfo['name'] . ' (dry-run)';
}
}
}
}
// Import DNS zone files
if ($importDns && isset($manifest['files']['dns'])) {
$zonesDir = '/etc/bind/zones';
if (!is_dir($zonesDir) && !$dryRun) {
mkdir($zonesDir, 0755, true);
}
foreach ($manifest['files']['dns'] as $fileInfo) {
$srcFile = "$extractedDir/dns/{$fileInfo['name']}";
if (file_exists($srcFile)) {
if ($fileInfo['name'] === 'named.conf.local') {
// Append zone definitions instead of replacing
if (!$dryRun) {
$content = file_get_contents($srcFile);
// Check if zones already exist before appending
$existingContent = file_exists('/etc/bind/named.conf.local')
? file_get_contents('/etc/bind/named.conf.local')
: '';
// Only append zones that don't exist
preg_match_all('/zone\s+"([^"]+)"/', $content, $newZones);
preg_match_all('/zone\s+"([^"]+)"/', $existingContent, $existingZones);
$zonesToAdd = array_diff($newZones[1] ?? [], $existingZones[1] ?? []);
if (!empty($zonesToAdd)) {
$imported['dns'][] = 'named.conf.local (zones: ' . implode(', ', $zonesToAdd) . ')';
}
} else {
$imported['dns'][] = 'named.conf.local (dry-run)';
}
} else {
$destFile = "$zonesDir/{$fileInfo['name']}";
if (!$dryRun) {
if (copy($srcFile, $destFile)) {
chown($destFile, 'bind');
chgrp($destFile, 'bind');
$imported['dns'][] = $fileInfo['name'];
} else {
$errors[] = "Failed to copy zone file: {$fileInfo['name']}";
}
} else {
$imported['dns'][] = $fileInfo['name'] . ' (dry-run)';
}
}
}
}
}
// Import SSL certificates
if ($importSsl && isset($manifest['files']['ssl'])) {
$sslDir = '/etc/letsencrypt/live';
$archiveDir = '/etc/letsencrypt/archive';
foreach ($manifest['files']['ssl'] as $fileInfo) {
$domain = $fileInfo['domain'];
$file = $fileInfo['file'];
$srcFile = "$extractedDir/ssl/$domain/$file";
if (file_exists($srcFile)) {
if (!$dryRun) {
// Create directories if needed
$domainLiveDir = "$sslDir/$domain";
$domainArchiveDir = "$archiveDir/$domain";
if (!is_dir($domainLiveDir)) {
mkdir($domainLiveDir, 0755, true);
}
if (!is_dir($domainArchiveDir)) {
mkdir($domainArchiveDir, 0755, true);
}
// Copy to archive with version number
$baseName = str_replace('.pem', '', $file);
$archiveFile = "$domainArchiveDir/{$baseName}1.pem";
$liveFile = "$domainLiveDir/$file";
if (copy($srcFile, $archiveFile)) {
chmod($archiveFile, 0644);
// Create symlink in live directory
if (file_exists($liveFile)) {
unlink($liveFile);
}
symlink($archiveFile, $liveFile);
$imported['ssl'][] = "$domain/$file";
} else {
$errors[] = "Failed to copy SSL cert: $domain/$file";
}
} else {
$imported['ssl'][] = "$domain/$file (dry-run)";
}
}
}
}
// Import maildir data
if ($importMaildir && isset($manifest['files']['maildir'])) {
$vhostsDir = '/var/mail/vhosts';
if (!is_dir($vhostsDir) && !$dryRun) {
mkdir($vhostsDir, 0755, true);
}
foreach ($manifest['files']['maildir'] as $mailInfo) {
$domain = $mailInfo['domain'];
$archive = $mailInfo['archive'];
$srcArchive = "$extractedDir/maildir/$archive";
if (file_exists($srcArchive)) {
if (!$dryRun) {
exec("tar -I pigz -xf " . escapeshellarg($srcArchive) . " -C " . escapeshellarg($vhostsDir) . " 2>&1", $mailOutput, $mailRet);
if ($mailRet === 0) {
// Fix permissions
exec("chown -R vmail:vmail " . escapeshellarg("$vhostsDir/$domain"));
$imported['maildir'][] = $domain;
} else {
$errors[] = "Failed to extract maildir for $domain: " . implode("\n", $mailOutput);
}
} else {
$imported['maildir'][] = "$domain (dry-run)";
}
}
}
}
// Cleanup temp directory
exec("rm -rf " . escapeshellarg($tempDir));
// Reload services if not dry run
if (!$dryRun) {
if (!empty($imported['nginx'])) {
exec("nginx -t 2>&1", $testOutput, $testRet);
if ($testRet === 0) {
exec("systemctl reload nginx 2>&1");
} else {
$errors[] = "Nginx config test failed: " . implode("\n", $testOutput);
}
}
if (!empty($imported['dns'])) {
exec("systemctl reload named 2>/dev/null || systemctl reload bind9 2>/dev/null");
}
if (!empty($imported['maildir'])) {
exec("systemctl reload dovecot 2>/dev/null");
}
}
return [
'success' => empty($errors),
'imported' => $imported,
'errors' => $errors,
'manifest' => $manifest,
'dry_run' => $dryRun,
];
}
function createServerZone(array $params): array
{
$hostname = $params['hostname'] ?? '';
$ns1 = $params['ns1'] ?? '';
$ns1_ip = $params['ns1_ip'] ?? '';
$ns2 = $params['ns2'] ?? '';
$ns2_ip = $params['ns2_ip'] ?? '';
$admin_email = $params['admin_email'] ?? 'admin.example.com';
$server_ip = $params['server_ip'] ?? '';
$server_ipv6 = $params['server_ipv6'] ?? '';
$ttl = $params['ttl'] ?? '3600';
$parts = explode('.', $hostname);
if (count($parts) < 2) {
return ['success' => false, 'error' => 'Invalid hostname format'];
}
$baseDomain = implode('.', array_slice($parts, -2));
$serial = date('Ymd') . '01';
$zonesDir = '/etc/bind/zones';
if (!is_dir($zonesDir)) {
mkdir($zonesDir, 0755, true);
}
// Create zone data file
$zoneContent = "\$TTL {$ttl}\n";
$zoneContent .= "@ IN SOA {$ns1}. {$admin_email}. (\n";
$zoneContent .= " {$serial} ; Serial\n";
$zoneContent .= " 3600 ; Refresh\n";
$zoneContent .= " 1800 ; Retry\n";
$zoneContent .= " 604800 ; Expire\n";
$zoneContent .= " 86400 ) ; Minimum TTL\n\n";
$zoneContent .= "; Nameservers\n";
$zoneContent .= "@ IN NS {$ns1}.\n";
$zoneContent .= "@ IN NS {$ns2}.\n\n";
$zoneContent .= "; Nameserver A records\n";
if (str_ends_with($ns1, '.' . $baseDomain) && !empty($ns1_ip)) {
$ns1Short = str_replace('.' . $baseDomain, '', $ns1);
$zoneContent .= "{$ns1Short} IN A {$ns1_ip}\n";
}
if (str_ends_with($ns2, '.' . $baseDomain) && !empty($ns2_ip)) {
$ns2Short = str_replace('.' . $baseDomain, '', $ns2);
$zoneContent .= "{$ns2Short} IN A {$ns2_ip}\n";
}
if (!empty($server_ip)) {
$zoneContent .= "\n; Server\n";
$hostShort = str_replace('.' . $baseDomain, '', $hostname);
if ($hostShort !== $hostname) {
$zoneContent .= "{$hostShort} IN A {$server_ip}\n";
}
$zoneContent .= "@ IN A {$server_ip}\n";
}
if (!empty($server_ipv6)) {
$zoneContent .= "\n; Server IPv6\n";
$hostShort = str_replace('.' . $baseDomain, '', $hostname);
if ($hostShort !== $hostname) {
$zoneContent .= "{$hostShort} IN AAAA {$server_ipv6}\n";
}
$zoneContent .= "@ IN AAAA {$server_ipv6}\n";
}
$zoneFile = "{$zonesDir}/db.{$baseDomain}";
file_put_contents($zoneFile, $zoneContent);
// Create zone config file
$zoneConf = "{$zonesDir}/{$baseDomain}.conf";
$confContent = "zone \"{$baseDomain}\" {\n";
$confContent .= " type master;\n";
$confContent .= " file \"{$zoneFile}\";\n";
$confContent .= " allow-transfer { none; };\n";
$confContent .= "};\n";
file_put_contents($zoneConf, $confContent);
// Add to zones.conf if not already included
$masterConf = "{$zonesDir}/zones.conf";
$masterContent = file_exists($masterConf) ? file_get_contents($masterConf) : "// Panel managed zones\n";
$includeLine = "include \"{$zoneConf}\";";
if (strpos($masterContent, $includeLine) === false) {
file_put_contents($masterConf, $masterContent . $includeLine . "\n");
}
// Check config
exec('named-checkconf 2>&1', $checkOutput, $checkCode);
if ($checkCode !== 0) {
return ['success' => false, 'error' => 'BIND config error: ' . implode("\n", $checkOutput)];
}
// Check zone
exec("named-checkzone {$baseDomain} {$zoneFile} 2>&1", $zoneOutput, $zoneCode);
if ($zoneCode !== 0) {
return ['success' => false, 'error' => 'Zone error: ' . implode("\n", $zoneOutput)];
}
// Reload BIND
exec('systemctl reload bind9 2>&1 || systemctl reload named 2>&1');
return ['success' => true, 'zone' => $baseDomain, 'file' => $zoneFile];
}
// ============ SERVICE MANAGEMENT ============
/**
* List status of specified services
*/
function serviceList(array $params): array
{
$requestedServices = $params['services'] ?? [];
if (empty($requestedServices)) {
return ['success' => false, 'error' => 'No services specified'];
}
$results = [];
foreach ($requestedServices as $service) {
// Sanitize service name
if (!preg_match('/^[a-zA-Z0-9._-]+$/', $service)) {
continue;
}
// Check if service is active
exec("systemctl is-active " . escapeshellarg($service) . " 2>/dev/null", $activeOutput, $activeCode);
$isActive = ($activeCode === 0 && isset($activeOutput[0]) && $activeOutput[0] === 'active');
// Check if service is enabled
exec("systemctl is-enabled " . escapeshellarg($service) . " 2>/dev/null", $enabledOutput, $enabledCode);
$isEnabled = ($enabledCode === 0 && isset($enabledOutput[0]) && in_array($enabledOutput[0], ['enabled', 'enabled-runtime']));
// Get status text
exec("systemctl status " . escapeshellarg($service) . " 2>&1 | head -5", $statusOutput, $statusCode);
$results[$service] = [
'is_active' => $isActive,
'is_enabled' => $isEnabled,
'status' => $isActive ? 'running' : 'stopped',
'status_text' => implode("\n", $statusOutput),
];
}
return ['success' => true, 'services' => $results];
}
/**
* Start a service
*/
function serviceStart(array $params): array
{
$service = $params['service'] ?? '';
if (empty($service) || !preg_match('/^[a-zA-Z0-9._-]+$/', $service)) {
return ['success' => false, 'error' => 'Invalid service name'];
}
exec("systemctl start " . escapeshellarg($service) . " 2>&1", $output, $code);
if ($code !== 0) {
return ['success' => false, 'error' => implode("\n", $output) ?: 'Failed to start service'];
}
return ['success' => true, 'service' => $service, 'action' => 'started'];
}
/**
* Stop a service
*/
function serviceStop(array $params): array
{
$service = $params['service'] ?? '';
if (empty($service) || !preg_match('/^[a-zA-Z0-9._-]+$/', $service)) {
return ['success' => false, 'error' => 'Invalid service name'];
}
exec("systemctl stop " . escapeshellarg($service) . " 2>&1", $output, $code);
if ($code !== 0) {
return ['success' => false, 'error' => implode("\n", $output) ?: 'Failed to stop service'];
}
return ['success' => true, 'service' => $service, 'action' => 'stopped'];
}
/**
* Restart a service
*/
function serviceRestart(array $params): array
{
$service = $params['service'] ?? '';
if (empty($service) || !preg_match('/^[a-zA-Z0-9._-]+$/', $service)) {
return ['success' => false, 'error' => 'Invalid service name'];
}
$action = shouldReloadService($service) ? 'reload' : 'restart';
exec("systemctl {$action} " . escapeshellarg($service) . " 2>&1", $output, $code);
if ($code !== 0) {
return ['success' => false, 'error' => implode("\n", $output) ?: "Failed to {$action} service"];
}
return [
'success' => true,
'service' => $service,
'action' => $action === 'reload' ? 'reloaded' : 'restarted',
];
}
/**
* Enable a service (auto-start on boot)
*/
function serviceEnable(array $params): array
{
$service = $params['service'] ?? '';
if (empty($service) || !preg_match('/^[a-zA-Z0-9._-]+$/', $service)) {
return ['success' => false, 'error' => 'Invalid service name'];
}
exec("systemctl enable " . escapeshellarg($service) . " 2>&1", $output, $code);
if ($code !== 0) {
return ['success' => false, 'error' => implode("\n", $output) ?: 'Failed to enable service'];
}
return ['success' => true, 'service' => $service, 'action' => 'enabled'];
}
/**
* Disable a service (no auto-start on boot)
*/
function serviceDisable(array $params): array
{
$service = $params['service'] ?? '';
if (empty($service) || !preg_match('/^[a-zA-Z0-9._-]+$/', $service)) {
return ['success' => false, 'error' => 'Invalid service name'];
}
exec("systemctl disable " . escapeshellarg($service) . " 2>&1", $output, $code);
if ($code !== 0) {
return ['success' => false, 'error' => implode("\n", $output) ?: 'Failed to disable service'];
}
return ['success' => true, 'service' => $service, 'action' => 'disabled'];
}
// ============ SERVER IMPORT (cPanel/DirectAdmin) ============
/**
* Discover accounts in a backup file or from remote server
*/
function importDiscover(array $params): array
{
$importId = $params['import_id'] ?? 0;
$sourceType = $params['source_type'] ?? 'cpanel';
$importMethod = $params['import_method'] ?? 'backup_file';
$backupPath = $params['backup_path'] ?? null;
$remoteHost = $params['remote_host'] ?? null;
$remotePort = $params['remote_port'] ?? null;
$remoteUser = $params['remote_user'] ?? null;
$remotePassword = $params['remote_password'] ?? null;
logger("Import discover: source=$sourceType, method=$importMethod, backup=$backupPath");
if ($importMethod === 'backup_file') {
if (!$backupPath || !file_exists($backupPath)) {
return ['success' => false, 'error' => 'Backup file not found: ' . $backupPath];
}
// Create temp extraction directory
$extractDir = '/tmp/import_' . $importId . '_' . time();
if (!mkdir($extractDir, 0755, true)) {
return ['success' => false, 'error' => 'Failed to create extraction directory'];
}
try {
if ($sourceType === 'cpanel') {
$accounts = discoverCpanelBackup($backupPath, $extractDir);
} else {
$accounts = discoverDirectAdminBackup($backupPath, $extractDir);
}
// Clean up partial extraction (we only extracted metadata)
exec("rm -rf " . escapeshellarg($extractDir));
return ['success' => true, 'accounts' => $accounts];
} catch (Exception $e) {
exec("rm -rf " . escapeshellarg($extractDir));
return ['success' => false, 'error' => $e->getMessage()];
}
} else {
// Remote server discovery
if ($sourceType === 'cpanel') {
return discoverCpanelRemote($remoteHost, $remotePort, $remoteUser, $remotePassword);
} else {
return discoverDirectAdminRemote($remoteHost, $remotePort, $remoteUser, $remotePassword);
}
}
}
/**
* Discover accounts from a cPanel backup file
*/
function discoverCpanelBackup(string $backupPath, string $extractDir): array
{
logger("Discovering cPanel backup: $backupPath");
$accounts = [];
// Check if this is a full backup or single account backup
// Full backups contain multiple cpmove-username.tar.gz files
// Single account backups are just one tar.gz
// First, list the contents of the archive
$cmd = "tar -I pigz -tf " . escapeshellarg($backupPath) . " 2>/dev/null | head -500";
exec($cmd, $fileList, $code);
if ($code !== 0) {
throw new Exception('Failed to read backup file. Make sure it is a valid tar.gz archive.');
}
$fileListStr = implode("\n", $fileList);
// Check if this is a cpmove backup (single account)
if (preg_match('/^([^\/]+)\/userdata\/main$/', $fileListStr, $matches) ||
preg_match('/^userdata\/main$/', $fileListStr)) {
// Single account backup - extract minimal data to get account info
$username = $matches[1] ?? null;
// Extract userdata directory to get account info
$extractCmd = "tar -I pigz -xf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) . " --wildcards '*/userdata/*' '*/mysql/' '*/homedir/.shadow' 2>/dev/null";
exec($extractCmd);
// Find the userdata/main file
$userdataFiles = glob("$extractDir/*/userdata/main") ?: glob("$extractDir/userdata/main");
foreach ($userdataFiles as $mainFile) {
$dirParts = explode('/', dirname(dirname($mainFile)));
$username = end($dirParts);
if ($username === $extractDir || $username === 'userdata') {
// Get username from file content
$content = file_get_contents($mainFile);
if (preg_match('/^user:\s*(.+)$/m', $content, $m)) {
$username = trim($m[1]);
}
}
$account = parseCpanelUserdata($mainFile, $extractDir, $username);
if ($account) {
$accounts[] = $account;
}
}
} else {
// Full server backup - look for cpmove files
$cpmovePattern = '/cpmove-([a-z0-9_]+)\.tar\.gz/i';
foreach ($fileList as $file) {
if (preg_match($cpmovePattern, $file, $matches)) {
$username = $matches[1];
// Extract just the userdata for this account
$innerBackup = $file;
$extractCmd = "tar -I pigz -xf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) . " " . escapeshellarg($innerBackup) . " 2>/dev/null";
exec($extractCmd);
// Extract from the inner cpmove archive
$innerPath = "$extractDir/$innerBackup";
if (file_exists($innerPath)) {
$innerExtract = "$extractDir/$username";
mkdir($innerExtract, 0755, true);
exec("tar -I pigz -xf " . escapeshellarg($innerPath) . " -C " . escapeshellarg($innerExtract) . " --wildcards '*/userdata/*' 2>/dev/null");
$userdataFile = glob("$innerExtract/*/userdata/main")[0] ?? null;
if ($userdataFile) {
$account = parseCpanelUserdata($userdataFile, $innerExtract, $username);
if ($account) {
$accounts[] = $account;
}
}
}
}
}
}
// If still no accounts found, try parsing as a simple backup
if (empty($accounts)) {
// Look for cp or backup directory structure
foreach ($fileList as $file) {
if (preg_match('/^([^\/]+)\/cp\//', $file, $matches)) {
$username = $matches[1];
if (!in_array($username, array_column($accounts, 'username'))) {
$accounts[] = [
'username' => $username,
'email' => '',
'main_domain' => '',
'addon_domains' => [],
'subdomains' => [],
'databases' => [],
'email_accounts' => [],
'disk_usage' => 0,
];
}
}
}
}
return $accounts;
}
/**
* Parse cPanel userdata/main file to extract account info
*/
function parseCpanelUserdata(string $mainFile, string $extractDir, string $username): ?array
{
if (!file_exists($mainFile)) {
return null;
}
$content = file_get_contents($mainFile);
// Parse YAML-like content
$mainDomain = '';
$email = '';
$addonDomains = [];
$subdomains = [];
if (preg_match('/^main_domain:\s*(.+)$/m', $content, $m)) {
$mainDomain = trim($m[1]);
}
if (preg_match('/^email:\s*(.+)$/m', $content, $m)) {
$email = trim($m[1]);
}
// Look for addon domains in the userdata directory
$userdataDir = dirname($mainFile);
$domainFiles = glob("$userdataDir/*.com") ?: [];
$domainFiles = array_merge($domainFiles, glob("$userdataDir/*.net") ?: []);
$domainFiles = array_merge($domainFiles, glob("$userdataDir/*.org") ?: []);
$domainFiles = array_merge($domainFiles, glob("$userdataDir/*.*") ?: []);
foreach ($domainFiles as $domainFile) {
$domain = basename($domainFile);
if ($domain !== 'main' && $domain !== 'cache' && $domain !== $mainDomain) {
$domainContent = file_get_contents($domainFile);
if (strpos($domainContent, 'addon_domain:') !== false) {
$addonDomains[] = $domain;
} elseif (strpos($domainContent, 'type: sub') !== false) {
$subdomains[] = $domain;
}
}
}
// Look for databases
$databases = [];
$baseDir = dirname(dirname($mainFile));
$mysqlDir = "$baseDir/mysql";
if (is_dir($mysqlDir)) {
$sqlFiles = glob("$mysqlDir/*.sql") ?: [];
foreach ($sqlFiles as $sqlFile) {
$databases[] = basename($sqlFile, '.sql');
}
}
// Look for email accounts
$emailAccounts = [];
$shadowFile = "$baseDir/homedir/.shadow";
if (file_exists($shadowFile)) {
$shadow = file_get_contents($shadowFile);
if (preg_match_all('/^([^:]+):/m', $shadow, $shadowMatches)) {
$emailAccounts = $shadowMatches[1];
}
}
// Get disk usage from quota file if available
$diskUsage = 0;
$quotaFile = "$baseDir/quota";
if (file_exists($quotaFile)) {
$quota = file_get_contents($quotaFile);
if (preg_match('/^(\d+)/m', $quota, $m)) {
$diskUsage = (int)$m[1] * 1024; // Convert KB to bytes
}
}
return [
'username' => $username,
'email' => $email,
'main_domain' => $mainDomain,
'addon_domains' => $addonDomains,
'subdomains' => $subdomains,
'databases' => $databases,
'email_accounts' => $emailAccounts,
'disk_usage' => $diskUsage,
];
}
/**
* Discover accounts from a DirectAdmin backup file
*/
function discoverDirectAdminBackup(string $backupPath, string $extractDir): array
{
logger("Discovering DirectAdmin backup: $backupPath");
$accounts = [];
// List archive contents
$cmd = "tar -I pigz -tf " . escapeshellarg($backupPath) . " 2>/dev/null | head -500";
exec($cmd, $fileList, $code);
if ($code !== 0) {
throw new Exception('Failed to read backup file. Make sure it is a valid tar.gz archive.');
}
$fileListStr = implode("\n", $fileList);
// DirectAdmin backup structure: backup/user.conf, domains/, databases/
// Or: user.username.tar.gz containing the above
// Check for user.conf file (single user backup)
if (preg_match('/(backup\/)?user\.conf/', $fileListStr)) {
// Extract user.conf and domains list
$extractCmd = "tar -I pigz -xf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) . " --wildcards 'backup/user.conf' 'user.conf' 'domains/*' 'backup/domains/*' 2>/dev/null";
exec($extractCmd);
$userConf = null;
foreach (["$extractDir/backup/user.conf", "$extractDir/user.conf"] as $path) {
if (file_exists($path)) {
$userConf = $path;
break;
}
}
if ($userConf) {
$account = parseDirectAdminUserConf($userConf, $extractDir);
if ($account) {
$accounts[] = $account;
}
}
}
// Check for multiple user backups (full server backup)
foreach ($fileList as $file) {
if (preg_match('/user\.([a-z0-9_]+)\.tar\.gz/i', $file, $matches)) {
$username = $matches[1];
// Extract just this user's backup
$innerBackup = $file;
exec("tar -I pigz -xf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) . " " . escapeshellarg($innerBackup) . " 2>/dev/null");
$innerPath = "$extractDir/$innerBackup";
if (file_exists($innerPath)) {
$innerExtract = "$extractDir/$username";
mkdir($innerExtract, 0755, true);
exec("tar -I pigz -xf " . escapeshellarg($innerPath) . " -C " . escapeshellarg($innerExtract) . " --wildcards 'backup/user.conf' 'user.conf' 2>/dev/null");
$userConf = glob("$innerExtract/*/user.conf")[0] ?? glob("$innerExtract/user.conf")[0] ?? null;
if ($userConf) {
$account = parseDirectAdminUserConf($userConf, $innerExtract);
if ($account) {
$account['username'] = $username;
$accounts[] = $account;
}
}
}
}
}
return $accounts;
}
/**
* Parse DirectAdmin user.conf file
*/
function parseDirectAdminUserConf(string $userConf, string $extractDir): ?array
{
if (!file_exists($userConf)) {
return null;
}
$content = file_get_contents($userConf);
// Parse key=value format
$username = '';
$email = '';
$mainDomain = '';
$addonDomains = [];
$diskUsage = 0;
if (preg_match('/^username=(.+)$/m', $content, $m)) {
$username = trim($m[1]);
}
if (preg_match('/^email=(.+)$/m', $content, $m)) {
$email = trim($m[1]);
}
if (preg_match('/^domain=(.+)$/m', $content, $m)) {
$mainDomain = trim($m[1]);
}
if (preg_match('/^bandwidth=(\d+)$/m', $content, $m)) {
$diskUsage = (int)$m[1] * 1048576; // Convert MB to bytes
}
// Look for domains directory
$baseDir = dirname($userConf);
$domainsDir = "$baseDir/domains";
if (!is_dir($domainsDir)) {
$domainsDir = "$extractDir/domains";
}
if (is_dir($domainsDir)) {
$domainDirs = glob("$domainsDir/*", GLOB_ONLYDIR) ?: [];
foreach ($domainDirs as $domainDir) {
$domain = basename($domainDir);
if ($domain !== $mainDomain && $domain !== '.' && $domain !== '..') {
$addonDomains[] = $domain;
}
}
}
// Look for databases
$databases = [];
$dbDir = "$baseDir/databases";
if (!is_dir($dbDir)) {
$dbDir = "$extractDir/backup/databases";
}
if (is_dir($dbDir)) {
$sqlFiles = glob("$dbDir/*.sql") ?: [];
foreach ($sqlFiles as $sqlFile) {
$databases[] = basename($sqlFile, '.sql');
}
}
// Look for email accounts
$emailAccounts = [];
$emailDir = "$baseDir/email";
if (!is_dir($emailDir)) {
$emailDir = "$extractDir/backup/email";
}
if (is_dir($emailDir)) {
$emailDirs = glob("$emailDir/*", GLOB_ONLYDIR) ?: [];
foreach ($emailDirs as $ed) {
$emailAccounts[] = basename($ed);
}
}
return [
'username' => $username,
'email' => $email,
'main_domain' => $mainDomain,
'addon_domains' => $addonDomains,
'subdomains' => [],
'databases' => $databases,
'email_accounts' => $emailAccounts,
'disk_usage' => $diskUsage,
];
}
/**
* Discover accounts from remote cPanel server via WHM API
*/
function discoverCpanelRemote(string $host, int $port, string $user, string $password): array
{
logger("Discovering cPanel accounts from remote: $host:$port");
$url = "https://$host:$port/json-api/listaccts?api.version=1";
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_TIMEOUT, 60);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: WHM ' . $user . ':' . $password,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($error) {
return ['success' => false, 'error' => "Connection failed: $error"];
}
if ($httpCode !== 200) {
return ['success' => false, 'error' => "Server returned HTTP $httpCode"];
}
$data = json_decode($response, true);
if (!$data || isset($data['error'])) {
return ['success' => false, 'error' => $data['error'] ?? 'Invalid response from server'];
}
$accounts = [];
$acctList = $data['data']['acct'] ?? $data['acct'] ?? [];
foreach ($acctList as $acct) {
$accounts[] = [
'username' => $acct['user'] ?? '',
'email' => $acct['email'] ?? '',
'main_domain' => $acct['domain'] ?? '',
'addon_domains' => [],
'subdomains' => [],
'databases' => [],
'email_accounts' => [],
'disk_usage' => ($acct['diskused'] ?? 0) * 1048576, // MB to bytes
];
}
return ['success' => true, 'accounts' => $accounts];
}
/**
* Discover accounts from remote DirectAdmin server via API
*/
function discoverDirectAdminRemote(string $host, int $port, string $user, string $password): array
{
logger("Discovering DirectAdmin accounts from remote: $host:$port");
$url = "https://$host:$port/CMD_API_SHOW_ALL_USERS";
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_TIMEOUT, 60);
curl_setopt($ch, CURLOPT_USERPWD, "$user:$password");
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($error) {
return ['success' => false, 'error' => "Connection failed: $error"];
}
if ($httpCode !== 200) {
return ['success' => false, 'error' => "Server returned HTTP $httpCode"];
}
// DirectAdmin returns URL-encoded data
parse_str($response, $data);
if (isset($data['error']) && $data['error'] === '1') {
return ['success' => false, 'error' => $data['text'] ?? 'Unknown error'];
}
$accounts = [];
// DirectAdmin returns list=user1&list=user2 format
$userList = $data['list'] ?? [];
if (!is_array($userList)) {
$userList = [$userList];
}
foreach ($userList as $username) {
if (empty($username)) continue;
// Get user details
$detailUrl = "https://$host:$port/CMD_API_SHOW_USER_CONFIG?user=" . urlencode($username);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $detailUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
curl_setopt($ch, CURLOPT_USERPWD, "$user:$password");
$userResponse = curl_exec($ch);
curl_close($ch);
parse_str($userResponse, $userData);
$accounts[] = [
'username' => $username,
'email' => $userData['email'] ?? '',
'main_domain' => $userData['domain'] ?? '',
'addon_domains' => [],
'subdomains' => [],
'databases' => [],
'email_accounts' => [],
'disk_usage' => ($userData['bandwidth'] ?? 0) * 1048576,
];
}
return ['success' => true, 'accounts' => $accounts];
}
/**
* Start the import process for selected accounts
*/
function importStart(array $params): array
{
$importId = $params['import_id'] ?? 0;
logger("Starting import process for import ID: $importId");
// This will be called asynchronously - we need to spawn a background process
// that reads the import details from the database and processes accounts
$scriptPath = '/var/www/jabali/artisan';
if (!file_exists($scriptPath)) {
return ['success' => false, 'error' => 'Artisan script not found'];
}
// Dispatch a Laravel job to handle the import in the background
$cmd = "cd /var/www/jabali && php artisan import:process " . escapeshellarg($importId) . " > /dev/null 2>&1 &";
exec($cmd);
return ['success' => true, 'message' => 'Import process started'];
}
// ============ SSL CERTIFICATE MANAGEMENT ============
/**
* Check SSL certificate status for a domain
*/
function sslCheck(array $params): array
{
$domain = $params['domain'] ?? '';
$username = $params['username'] ?? '';
if (empty($domain)) {
return ['success' => false, 'error' => 'Domain is required'];
}
logger("Checking SSL for domain: $domain");
$result = [
'has_ssl' => false,
'type' => 'none',
'issuer' => null,
'valid_from' => null,
'valid_to' => null,
'days_until_expiry' => null,
'is_expired' => false,
'is_self_signed' => false,
'certificate' => null,
];
// Check nginx config for SSL
$nginxConf = "/etc/nginx/sites-available/$domain";
if (!file_exists($nginxConf)) {
return ['success' => true, 'ssl' => $result];
}
$confContent = file_get_contents($nginxConf);
// Look for ssl_certificate directive
if (preg_match('/ssl_certificate\s+([^;]+);/', $confContent, $matches)) {
$certPath = trim($matches[1]);
$result['has_ssl'] = true;
// Read certificate details
if (file_exists($certPath)) {
$certContent = file_get_contents($certPath);
$certData = openssl_x509_parse($certContent);
if ($certData) {
$result['issuer'] = $certData['issuer']['O'] ?? $certData['issuer']['CN'] ?? 'Unknown';
$result['valid_from'] = date('Y-m-d H:i:s', $certData['validFrom_time_t']);
$result['valid_to'] = date('Y-m-d H:i:s', $certData['validTo_time_t']);
$expiryTime = $certData['validTo_time_t'];
$result['days_until_expiry'] = (int) floor(($expiryTime - time()) / 86400);
$result['is_expired'] = $expiryTime < time();
// Check if self-signed (issuer == subject)
$issuerCN = $certData['issuer']['CN'] ?? '';
$subjectCN = $certData['subject']['CN'] ?? '';
$result['is_self_signed'] = ($issuerCN === $subjectCN) && !str_contains(strtolower($issuerCN), 'encrypt');
// Determine type
if ($result['is_self_signed']) {
$result['type'] = 'self_signed';
} elseif (str_contains(strtolower($result['issuer']), 'encrypt')) {
$result['type'] = 'lets_encrypt';
} else {
$result['type'] = 'custom';
}
$result['certificate'] = $certContent;
}
}
}
return ['success' => true, 'ssl' => $result];
}
/**
* Issue Let's Encrypt SSL certificate for a domain
*/
function sslIssue(array $params): array
{
$domain = $params['domain'] ?? '';
$username = $params['username'] ?? '';
$email = $params['email'] ?? 'admin@' . $domain;
$includeWww = $params['include_www'] ?? true;
if (empty($domain) || empty($username)) {
return ['success' => false, 'error' => 'Domain and username are required'];
}
logger("Issuing SSL certificate for domain: $domain (user: $username)");
// Check if certbot is installed
exec('which certbot 2>/dev/null', $output, $code);
if ($code !== 0) {
return ['success' => false, 'error' => 'Certbot is not installed. Please install certbot first.'];
}
// Verify domain resolves to this server
$serverIp = trim(shell_exec("curl -s ifconfig.me 2>/dev/null") ?? '');
$domainIp = gethostbyname($domain);
if ($domainIp === $domain) {
return ['success' => false, 'error' => "Domain $domain does not resolve to any IP address"];
}
// Build certbot command
$domains = "-d " . escapeshellarg($domain);
if ($includeWww) {
$wwwDomain = "www.$domain";
$wwwIp = gethostbyname($wwwDomain);
if ($wwwIp !== $wwwDomain) {
$domains .= " -d " . escapeshellarg($wwwDomain);
}
}
$webroot = "/home/$username/domains/$domain/public_html";
if (!is_dir($webroot)) {
return ['success' => false, 'error' => "Webroot not found: $webroot"];
}
// Create .well-known directory if needed
$wellKnown = "$webroot/.well-known/acme-challenge";
if (!is_dir($wellKnown)) {
mkdir($wellKnown, 0755, true);
chown("$webroot/.well-known", $username);
chown($wellKnown, $username);
}
// Run certbot
$cmd = sprintf(
'certbot certonly --webroot -w %s %s --email %s --agree-tos --non-interactive --force-renewal 2>&1',
escapeshellarg($webroot),
$domains,
escapeshellarg($email)
);
logger("Running certbot: $cmd");
exec($cmd, $output, $exitCode);
$outputStr = implode("\n", $output);
logger("Certbot output: $outputStr");
if ($exitCode !== 0) {
return ['success' => false, 'error' => "Certbot failed: $outputStr"];
}
// Get certificate paths
$certPath = "/etc/letsencrypt/live/$domain/fullchain.pem";
$keyPath = "/etc/letsencrypt/live/$domain/privkey.pem";
if (!file_exists($certPath) || !file_exists($keyPath)) {
return ['success' => false, 'error' => 'Certificate files not found after certbot'];
}
// Install the certificate
$installResult = sslInstallCertificate($domain, $username, $certPath, $keyPath);
if (!$installResult['success']) {
return $installResult;
}
// Read certificate for response
$certContent = file_get_contents($certPath);
$certData = openssl_x509_parse($certContent);
return [
'success' => true,
'message' => "SSL certificate issued for $domain",
'certificate_path' => $certPath,
'key_path' => $keyPath,
'issuer' => "Let's Encrypt",
'valid_from' => date('Y-m-d H:i:s', $certData['validFrom_time_t'] ?? 0),
'valid_to' => date('Y-m-d H:i:s', $certData['validTo_time_t'] ?? 0),
'certificate' => $certContent,
];
}
/**
* Install a custom SSL certificate for a domain
*/
function sslInstall(array $params): array
{
$domain = $params['domain'] ?? '';
$username = $params['username'] ?? '';
$certificate = $params['certificate'] ?? '';
$privateKey = $params['private_key'] ?? '';
$caBundle = $params['ca_bundle'] ?? '';
if (empty($domain) || empty($username)) {
return ['success' => false, 'error' => 'Domain and username are required'];
}
if (empty($certificate) || empty($privateKey)) {
return ['success' => false, 'error' => 'Certificate and private key are required'];
}
logger("Installing custom SSL certificate for domain: $domain");
// Validate certificate format
$certData = openssl_x509_parse($certificate);
if (!$certData) {
return ['success' => false, 'error' => 'Invalid certificate format'];
}
// Validate private key
$keyResource = openssl_pkey_get_private($privateKey);
if (!$keyResource) {
return ['success' => false, 'error' => 'Invalid private key format'];
}
// Verify key matches certificate
$certResource = openssl_x509_read($certificate);
if (!openssl_x509_check_private_key($certResource, $keyResource)) {
return ['success' => false, 'error' => 'Private key does not match certificate'];
}
// Save certificate files
$sslDir = "/home/$username/ssl/$domain";
if (!is_dir($sslDir)) {
mkdir($sslDir, 0750, true);
chown($sslDir, $username);
}
$certPath = "$sslDir/certificate.crt";
$keyPath = "$sslDir/private.key";
// Combine certificate with CA bundle if provided
$fullCert = trim($certificate);
if (!empty($caBundle)) {
$fullCert .= "\n" . trim($caBundle);
}
file_put_contents($certPath, $fullCert);
file_put_contents($keyPath, $privateKey);
chmod($certPath, 0640);
chmod($keyPath, 0600);
chown($certPath, 'root');
chgrp($certPath, $username);
chown($keyPath, 'root');
chgrp($keyPath, $username);
// Install to nginx
$installResult = sslInstallCertificate($domain, $username, $certPath, $keyPath);
if (!$installResult['success']) {
return $installResult;
}
return [
'success' => true,
'message' => "Custom SSL certificate installed for $domain",
'issuer' => $certData['issuer']['O'] ?? $certData['issuer']['CN'] ?? 'Unknown',
'valid_from' => date('Y-m-d H:i:s', $certData['validFrom_time_t']),
'valid_to' => date('Y-m-d H:i:s', $certData['validTo_time_t']),
];
}
/**
* Renew SSL certificate (Let's Encrypt)
*/
function sslRenew(array $params): array
{
$domain = $params['domain'] ?? '';
$username = $params['username'] ?? '';
if (empty($domain)) {
return ['success' => false, 'error' => 'Domain is required'];
}
logger("Renewing SSL certificate for domain: $domain");
// Check if this is a Let's Encrypt certificate
$certPath = "/etc/letsencrypt/live/$domain/fullchain.pem";
if (!file_exists($certPath)) {
return ['success' => false, 'error' => 'No Let\'s Encrypt certificate found for this domain'];
}
// Run certbot renew for this specific domain
$cmd = sprintf('certbot renew --cert-name %s --force-renewal 2>&1', escapeshellarg($domain));
logger("Running certbot renew: $cmd");
exec($cmd, $output, $exitCode);
$outputStr = implode("\n", $output);
logger("Certbot renew output: $outputStr");
if ($exitCode !== 0) {
return ['success' => false, 'error' => "Certificate renewal failed: $outputStr"];
}
// Reload nginx
exec('systemctl reload nginx 2>&1', $output, $code);
// Read updated certificate
$certContent = file_get_contents($certPath);
$certData = openssl_x509_parse($certContent);
return [
'success' => true,
'message' => "SSL certificate renewed for $domain",
'valid_from' => date('Y-m-d H:i:s', $certData['validFrom_time_t'] ?? 0),
'valid_to' => date('Y-m-d H:i:s', $certData['validTo_time_t'] ?? 0),
];
}
/**
* Generate self-signed SSL certificate
*/
function sslGenerateSelfSigned(array $params): array
{
$domain = $params['domain'] ?? '';
$username = $params['username'] ?? '';
$days = $params['days'] ?? 365;
if (empty($domain) || empty($username)) {
return ['success' => false, 'error' => 'Domain and username are required'];
}
logger("Generating self-signed SSL certificate for domain: $domain");
// Create SSL directory
$sslDir = "/home/$username/ssl/$domain";
if (!is_dir($sslDir)) {
mkdir($sslDir, 0750, true);
chown($sslDir, $username);
}
$certPath = "$sslDir/certificate.crt";
$keyPath = "$sslDir/private.key";
// Generate self-signed certificate
$subject = "/C=US/ST=State/L=City/O=Self-Signed/CN=$domain";
$cmd = sprintf(
'openssl req -x509 -nodes -days %d -newkey rsa:2048 -keyout %s -out %s -subj %s 2>&1',
(int) $days,
escapeshellarg($keyPath),
escapeshellarg($certPath),
escapeshellarg($subject)
);
exec($cmd, $output, $exitCode);
if ($exitCode !== 0) {
return ['success' => false, 'error' => 'Failed to generate certificate: ' . implode("\n", $output)];
}
chmod($certPath, 0640);
chmod($keyPath, 0600);
chown($certPath, 'root');
chgrp($certPath, $username);
chown($keyPath, 'root');
chgrp($keyPath, $username);
// Install to nginx
$installResult = sslInstallCertificate($domain, $username, $certPath, $keyPath);
if (!$installResult['success']) {
return $installResult;
}
return [
'success' => true,
'message' => "Self-signed SSL certificate generated for $domain",
'certificate_path' => $certPath,
'key_path' => $keyPath,
'valid_days' => $days,
];
}
/**
* Delete/revoke SSL certificate
*/
function sslDelete(array $params): array
{
$domain = $params['domain'] ?? '';
$username = $params['username'] ?? '';
$deleteFiles = $params['delete_files'] ?? true;
if (empty($domain)) {
return ['success' => false, 'error' => 'Domain is required'];
}
logger("Deleting SSL certificate for domain: $domain");
$deleted = [];
$errors = [];
// Check for Let's Encrypt certificate
$letsencryptPath = "/etc/letsencrypt/live/$domain";
if (is_dir($letsencryptPath)) {
// Use certbot to properly delete/revoke
$cmd = sprintf('certbot delete --cert-name %s --non-interactive 2>&1', escapeshellarg($domain));
exec($cmd, $output, $exitCode);
if ($exitCode === 0) {
$deleted[] = "Let's Encrypt certificate";
logger("Deleted Let's Encrypt certificate for $domain");
} else {
// Fallback: manually remove if certbot fails
exec("rm -rf " . escapeshellarg($letsencryptPath) . " 2>&1");
exec("rm -rf /etc/letsencrypt/renewal/$domain.conf 2>&1");
exec("rm -rf /etc/letsencrypt/archive/$domain 2>&1");
$deleted[] = "Let's Encrypt certificate (manual cleanup)";
logger("Manually cleaned up Let's Encrypt files for $domain");
}
}
// Check for self-signed certificate in user's home
if (!empty($username)) {
$userSslDir = "/home/$username/ssl/$domain";
if (is_dir($userSslDir) && $deleteFiles) {
exec("rm -rf " . escapeshellarg($userSslDir) . " 2>&1");
$deleted[] = "Self-signed certificate";
logger("Deleted self-signed certificate at $userSslDir");
}
}
// Update nginx config to remove SSL directives (optional - revert to HTTP only)
$nginxConf = "/etc/nginx/sites-available/{$domain}.conf";
if (file_exists($nginxConf)) {
// Don't remove SSL from nginx - just note that cert is deleted
// The admin should reconfigure nginx manually if needed
logger("Note: Nginx config for $domain still references SSL - may need manual update");
}
if (empty($deleted)) {
return ['success' => false, 'error' => "No SSL certificate found for $domain"];
}
// Reload nginx
exec('systemctl reload nginx 2>&1');
return [
'success' => true,
'message' => "SSL certificate deleted for $domain",
'deleted' => $deleted,
];
}
/**
* Install SSL certificate to nginx configuration
*/
function sslInstallCertificate(string $domain, string $username, string $certPath, string $keyPath): array
{
$nginxConf = "/etc/nginx/sites-available/{$domain}.conf";
if (!file_exists($nginxConf)) {
return ['success' => false, 'error' => "Nginx config not found for $domain"];
}
$confContent = file_get_contents($nginxConf);
// Check if SSL is already configured
if (str_contains($confContent, 'ssl_certificate')) {
// Update existing SSL paths
$confContent = preg_replace(
'/ssl_certificate\s+[^;]+;/',
'ssl_certificate ' . $certPath . ';',
$confContent
);
$confContent = preg_replace(
'/ssl_certificate_key\s+[^;]+;/',
'ssl_certificate_key ' . $keyPath . ';',
$confContent
);
} else {
// Add SSL configuration
// Find the server block listening on 443
if (preg_match('/listen\s+443\s+ssl/', $confContent)) {
// Already has 443 listener, add ssl directives
$sslDirectives = "\n ssl_certificate $certPath;\n ssl_certificate_key $keyPath;\n";
$confContent = preg_replace(
'/(listen\s+443\s+ssl[^;]*;)/',
"$1$sslDirectives",
$confContent,
1
);
} else {
// Need to add HTTPS server block
$httpsBlock = generateHttpsServerBlock($domain, $username, $certPath, $keyPath);
$confContent .= "\n\n" . $httpsBlock;
}
}
// Write config
file_put_contents($nginxConf, $confContent);
// Test nginx config
exec('nginx -t 2>&1', $output, $exitCode);
if ($exitCode !== 0) {
return ['success' => false, 'error' => 'Nginx config test failed: ' . implode("\n", $output)];
}
// Reload nginx
exec('systemctl reload nginx 2>&1', $output, $code);
return ['success' => true];
}
/**
* Generate HTTPS server block for nginx
*/
function generateHttpsServerBlock(string $domain, string $username, string $certPath, string $keyPath): string
{
$docRoot = "/home/$username/domains/$domain/public_html";
return << date('c'),
'username' => $username,
'user' => null,
'domains' => [],
'email_domains' => [],
'mailboxes' => [],
'email_forwarders' => [],
'dns_records' => [],
'ssl_certificates' => [],
'mysql_credentials' => [],
];
// Connect to panel database
$dbPath = '/var/www/jabali/database/database.sqlite';
$dbDriver = $_ENV['DB_CONNECTION'] ?? 'sqlite';
try {
if ($dbDriver === 'sqlite' && file_exists($dbPath)) {
$pdo = new PDO("sqlite:$dbPath");
} else {
$mysqlHost = $_ENV['DB_HOST'] ?? 'localhost';
$mysqlDb = $_ENV['DB_DATABASE'] ?? 'jabali';
$mysqlUser = $_ENV['DB_USERNAME'] ?? 'root';
$mysqlPass = $_ENV['DB_PASSWORD'] ?? '';
$pdo = new PDO("mysql:host=$mysqlHost;dbname=$mysqlDb", $mysqlUser, $mysqlPass);
}
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Get user record (exclude sensitive fields)
$stmt = $pdo->prepare("SELECT id, name, email, username, is_admin, is_active, home_directory, disk_quota_mb, locale, created_at FROM users WHERE username = ?");
$stmt->execute([$username]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$user) {
return $data;
}
$data['user'] = $user;
$userId = $user['id'];
// Export domains
$stmt = $pdo->prepare("SELECT * FROM domains WHERE user_id = ?");
$stmt->execute([$userId]);
$data['domains'] = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Get domain IDs for related queries
$domainIds = array_column($data['domains'], 'id');
if (!empty($domainIds)) {
$placeholders = implode(',', array_fill(0, count($domainIds), '?'));
// Export DNS records
$stmt = $pdo->prepare("SELECT * FROM dns_records WHERE domain_id IN ($placeholders)");
$stmt->execute($domainIds);
$data['dns_records'] = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Export SSL certificates info
$stmt = $pdo->prepare("SELECT * FROM ssl_certificates WHERE domain_id IN ($placeholders)");
$stmt->execute($domainIds);
$data['ssl_certificates'] = $stmt->fetchAll(PDO::FETCH_ASSOC);
}
// Export email domains (linked via domain_id)
if (!empty($domainIds)) {
$placeholders = implode(',', array_fill(0, count($domainIds), '?'));
$stmt = $pdo->prepare("SELECT ed.*, d.domain as domain_name FROM email_domains ed JOIN domains d ON ed.domain_id = d.id WHERE ed.domain_id IN ($placeholders)");
$stmt->execute($domainIds);
$data['email_domains'] = $stmt->fetchAll(PDO::FETCH_ASSOC);
}
// Get email domain IDs for mailboxes and forwarders
$emailDomainIds = array_column($data['email_domains'], 'id');
if (!empty($emailDomainIds)) {
$placeholders = implode(',', array_fill(0, count($emailDomainIds), '?'));
// Export mailboxes (without passwords, but include domain info)
$stmt = $pdo->prepare("SELECT m.*, ed.domain_id, d.domain as domain_name FROM mailboxes m JOIN email_domains ed ON m.email_domain_id = ed.id JOIN domains d ON ed.domain_id = d.id WHERE m.email_domain_id IN ($placeholders)");
$stmt->execute($emailDomainIds);
$data['mailboxes'] = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($data['mailboxes'] as &$mailbox) {
unset($mailbox['password']);
}
// Export email forwarders
$stmt = $pdo->prepare("SELECT ef.*, ed.domain_id, d.domain as domain_name FROM email_forwarders ef JOIN email_domains ed ON ef.email_domain_id = ed.id JOIN domains d ON ed.domain_id = d.id WHERE ef.email_domain_id IN ($placeholders)");
$stmt->execute($emailDomainIds);
$data['email_forwarders'] = $stmt->fetchAll(PDO::FETCH_ASSOC);
}
// Export MySQL credentials (without encrypted passwords - they can't be decrypted outside the panel)
$stmt = $pdo->prepare("SELECT id, user_id, mysql_username, created_at, updated_at FROM mysql_credentials WHERE user_id = ?");
$stmt->execute([$userId]);
$data['mysql_credentials'] = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (Exception $e) {
logger("Failed to export panel data for $username: " . $e->getMessage());
}
return $data;
}
/**
* Import user's panel data from backup.
* Creates database records for domains, email, DNS, SSL, etc.
*/
function importUserPanelData(string $username, array $data): array
{
loadJabaliEnv();
$imported = [
'user' => 0,
'domains' => 0,
'email_domains' => 0,
'mailboxes' => 0,
'email_forwarders' => 0,
'dns_records' => 0,
'ssl_certificates' => 0,
'mysql_credentials' => 0,
];
// Connect to panel database
$dbPath = '/var/www/jabali/database/database.sqlite';
$dbDriver = $_ENV['DB_CONNECTION'] ?? 'sqlite';
try {
if ($dbDriver === 'sqlite' && file_exists($dbPath)) {
$pdo = new PDO("sqlite:$dbPath");
} else {
$mysqlHost = $_ENV['DB_HOST'] ?? 'localhost';
$mysqlDb = $_ENV['DB_DATABASE'] ?? 'jabali';
$mysqlUser = $_ENV['DB_USERNAME'] ?? 'root';
$mysqlPass = $_ENV['DB_PASSWORD'] ?? '';
$pdo = new PDO("mysql:host=$mysqlHost;dbname=$mysqlDb", $mysqlUser, $mysqlPass);
}
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$now = date('Y-m-d H:i:s');
// Get or create user in panel database
$stmt = $pdo->prepare("SELECT id FROM users WHERE username = ?");
$stmt->execute([$username]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$user) {
// User doesn't exist in panel - create from backup data or system user
$userData = $data['user'] ?? null;
$systemUser = posix_getpwnam($username);
if (!$systemUser) {
return ['success' => false, 'error' => 'System user not found: ' . $username, 'imported' => $imported];
}
// Get user details from backup or defaults
$name = $userData['name'] ?? $username;
$email = $userData['email'] ?? "{$username}@localhost";
$isAdmin = $userData['is_admin'] ?? 0;
$isActive = $userData['is_active'] ?? 1;
$homeDir = $systemUser['dir'];
$diskQuotaMb = $userData['disk_quota_mb'] ?? null;
$locale = $userData['locale'] ?? 'en';
// Generate a random password (admin must reset it)
$tempPassword = bin2hex(random_bytes(16));
$passwordHash = password_hash($tempPassword, PASSWORD_BCRYPT);
// Insert user into panel database
$stmt = $pdo->prepare("INSERT INTO users (name, email, username, password, is_admin, is_active, home_directory, disk_quota_mb, locale, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
$stmt->execute([$name, $email, $username, $passwordHash, $isAdmin, $isActive, $homeDir, $diskQuotaMb, $locale, $now, $now]);
$userId = $pdo->lastInsertId();
$imported['user'] = 1;
logger("Created panel user record for restored user: $username (ID: $userId)");
} else {
$userId = $user['id'];
}
// Map old domain IDs to new domain IDs
$domainIdMap = [];
$emailDomainIdMap = [];
// Import domains
foreach ($data['domains'] ?? [] as $domain) {
$oldId = $domain['id'];
$domainName = $domain['domain'] ?? $domain['name'] ?? '';
if (empty($domainName)) continue;
// Check if domain already exists
$stmt = $pdo->prepare("SELECT id FROM domains WHERE domain = ? AND user_id = ?");
$stmt->execute([$domainName, $userId]);
$existing = $stmt->fetch(PDO::FETCH_ASSOC);
if ($existing) {
$domainIdMap[$oldId] = $existing['id'];
} else {
// Insert new domain
$stmt = $pdo->prepare("INSERT INTO domains (user_id, domain, document_root, is_active, ssl_enabled, directory_index, page_cache_enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)");
$docRoot = $domain['document_root'] ?? "/home/$username/domains/$domainName/public_html";
$isActive = $domain['is_active'] ?? 1;
$sslEnabled = $domain['ssl_enabled'] ?? 0;
$directoryIndex = $domain['directory_index'] ?? 'index.php index.html';
$pageCacheEnabled = $domain['page_cache_enabled'] ?? 0;
$stmt->execute([$userId, $domainName, $docRoot, $isActive, $sslEnabled, $directoryIndex, $pageCacheEnabled, $now, $now]);
$domainIdMap[$oldId] = $pdo->lastInsertId();
$imported['domains']++;
}
}
// Import email domains (linked via domain_id)
foreach ($data['email_domains'] ?? [] as $emailDomain) {
$oldId = $emailDomain['id'];
$oldDomainId = $emailDomain['domain_id'] ?? null;
$domainName = $emailDomain['domain_name'] ?? $emailDomain['domain'] ?? '';
if (empty($domainName)) continue;
// Get the new domain_id for this email domain
$newDomainId = $domainIdMap[$oldDomainId] ?? null;
if (empty($newDomainId)) {
// Try to find domain by name
$stmt = $pdo->prepare("SELECT id FROM domains WHERE domain = ? AND user_id = ?");
$stmt->execute([$domainName, $userId]);
$domainRow = $stmt->fetch(PDO::FETCH_ASSOC);
if ($domainRow) {
$newDomainId = $domainRow['id'];
}
}
if (empty($newDomainId)) continue;
// Check if email domain already exists
$stmt = $pdo->prepare("SELECT id FROM email_domains WHERE domain_id = ?");
$stmt->execute([$newDomainId]);
$existing = $stmt->fetch(PDO::FETCH_ASSOC);
if ($existing) {
$emailDomainIdMap[$oldId] = $existing['id'];
} else {
// Insert new email domain
$stmt = $pdo->prepare("INSERT INTO email_domains (domain_id, is_active, dkim_selector, catch_all_enabled, max_mailboxes, max_quota_bytes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
$isActive = $emailDomain['is_active'] ?? 1;
$dkimSelector = $emailDomain['dkim_selector'] ?? 'default';
$catchAllEnabled = $emailDomain['catch_all_enabled'] ?? 0;
$maxMailboxes = $emailDomain['max_mailboxes'] ?? 10;
$maxQuotaBytes = $emailDomain['max_quota_bytes'] ?? 5368709120;
$stmt->execute([$newDomainId, $isActive, $dkimSelector, $catchAllEnabled, $maxMailboxes, $maxQuotaBytes, $now, $now]);
$emailDomainIdMap[$oldId] = $pdo->lastInsertId();
$imported['email_domains']++;
// Add to Postfix virtual_domains so mail is accepted for this domain
$vdomainsFile = POSTFIX_VIRTUAL_DOMAINS;
if (file_exists($vdomainsFile)) {
$vdomainsContent = file_get_contents($vdomainsFile);
if (strpos($vdomainsContent, $domainName) === false) {
file_put_contents($vdomainsFile, trim($vdomainsContent) . "\n{$domainName}\n");
}
}
// Create mail directory for domain if it doesn't exist
$mailDomainDir = "/home/$username/mail/$domainName";
if (!is_dir($mailDomainDir)) {
mkdir($mailDomainDir, 0750, true);
$userInfo = posix_getpwnam($username);
if ($userInfo) {
chown($mailDomainDir, $userInfo['uid']);
chgrp($mailDomainDir, $userInfo['gid']);
}
}
}
}
// Rebuild Postfix virtual_domains map after importing email domains
if ($imported['email_domains'] > 0) {
exec('postmap ' . escapeshellarg(POSTFIX_VIRTUAL_DOMAINS) . ' 2>/dev/null');
}
// Import mailboxes (note: passwords need to be reset by user)
foreach ($data['mailboxes'] ?? [] as $mailbox) {
$oldEmailDomainId = $mailbox['email_domain_id'] ?? null;
$newEmailDomainId = $emailDomainIdMap[$oldEmailDomainId] ?? null;
$localPart = $mailbox['local_part'] ?? '';
$domainName = $mailbox['domain_name'] ?? '';
if (empty($localPart) || empty($newEmailDomainId)) continue;
// Check if mailbox already exists
$stmt = $pdo->prepare("SELECT id FROM mailboxes WHERE email_domain_id = ? AND local_part = ?");
$stmt->execute([$newEmailDomainId, $localPart]);
if ($stmt->fetch()) continue;
// Insert mailbox with placeholder password (user must reset)
$stmt = $pdo->prepare("INSERT INTO mailboxes (email_domain_id, user_id, local_part, password_hash, name, quota_bytes, quota_used_bytes, is_active, imap_enabled, pop3_enabled, smtp_enabled, maildir_path, system_uid, system_gid, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
$quotaBytes = $mailbox['quota_bytes'] ?? 1073741824;
$quotaUsedBytes = $mailbox['quota_used_bytes'] ?? 0;
$maildirPath = $mailbox['maildir_path'] ?? "/home/$username/mail/$domainName/$localPart/";
// Generate proper password hash - user must reset via panel
$tempPassword = 'changeme';
$hashCmd = sprintf('doveadm pw -s SHA512-CRYPT -p %s 2>&1', escapeshellarg($tempPassword));
$passwordHash = trim(shell_exec($hashCmd));
if (empty($passwordHash) || strpos($passwordHash, '{SHA512-CRYPT}') === false) {
// Fallback if doveadm not available
$passwordHash = '{SHA512-CRYPT}' . crypt($tempPassword, '$6$' . bin2hex(random_bytes(8)) . '$');
}
$name = $mailbox['name'] ?? $localPart;
$isActive = $mailbox['is_active'] ?? 1;
$imapEnabled = $mailbox['imap_enabled'] ?? 1;
$pop3Enabled = $mailbox['pop3_enabled'] ?? 1;
$smtpEnabled = $mailbox['smtp_enabled'] ?? 1;
$systemUid = $mailbox['system_uid'] ?? null;
$systemGid = $mailbox['system_gid'] ?? null;
$stmt->execute([$newEmailDomainId, $userId, $localPart, $passwordHash, $name, $quotaBytes, $quotaUsedBytes, $isActive, $imapEnabled, $pop3Enabled, $smtpEnabled, $maildirPath, $systemUid, $systemGid, $now, $now]);
$imported['mailboxes']++;
// Add to Postfix virtual_mailbox_maps so mail delivery works
$email = "{$localPart}@{$domainName}";
$mailboxesFile = POSTFIX_VIRTUAL_MAILBOXES;
if (file_exists($mailboxesFile)) {
$mailboxesContent = file_get_contents($mailboxesFile);
if (strpos($mailboxesContent, $email) === false) {
file_put_contents($mailboxesFile, trim($mailboxesContent) . "\n{$email} {$domainName}/{$localPart}/\n");
}
}
}
// Rebuild Postfix maps after importing mailboxes
if ($imported['mailboxes'] > 0) {
exec('postmap ' . escapeshellarg(POSTFIX_VIRTUAL_MAILBOXES) . ' 2>/dev/null');
}
// Import email forwarders
foreach ($data['email_forwarders'] ?? [] as $forwarder) {
$oldEmailDomainId = $forwarder['email_domain_id'] ?? null;
$newEmailDomainId = $emailDomainIdMap[$oldEmailDomainId] ?? null;
$localPart = $forwarder['local_part'] ?? '';
$destinations = $forwarder['destinations'] ?? '';
if (empty($localPart) || empty($destinations) || empty($newEmailDomainId)) continue;
// Check if forwarder already exists
$stmt = $pdo->prepare("SELECT id FROM email_forwarders WHERE local_part = ? AND email_domain_id = ?");
$stmt->execute([$localPart, $newEmailDomainId]);
if ($stmt->fetch()) continue;
$stmt = $pdo->prepare("INSERT INTO email_forwarders (email_domain_id, user_id, local_part, destinations, is_active, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)");
$isActive = $forwarder['is_active'] ?? 1;
$stmt->execute([$newEmailDomainId, $userId, $localPart, $destinations, $isActive, $now, $now]);
$imported['email_forwarders']++;
}
// Import DNS records
foreach ($data['dns_records'] ?? [] as $record) {
$oldDomainId = $record['domain_id'] ?? null;
$newDomainId = $domainIdMap[$oldDomainId] ?? null;
if (empty($newDomainId)) continue;
$name = $record['name'] ?? '';
$type = $record['type'] ?? '';
$content = $record['content'] ?? $record['value'] ?? '';
if (empty($type) || empty($content)) continue;
// Check if record already exists
$stmt = $pdo->prepare("SELECT id FROM dns_records WHERE domain_id = ? AND name = ? AND type = ? AND content = ?");
$stmt->execute([$newDomainId, $name, $type, $content]);
if ($stmt->fetch()) continue;
$stmt = $pdo->prepare("INSERT INTO dns_records (domain_id, name, type, content, ttl, priority, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
$ttl = $record['ttl'] ?? 3600;
$priority = $record['priority'] ?? null;
$stmt->execute([$newDomainId, $name, $type, $content, $ttl, $priority, $now, $now]);
$imported['dns_records']++;
}
// Import SSL certificates
foreach ($data['ssl_certificates'] ?? [] as $cert) {
$oldDomainId = $cert['domain_id'] ?? null;
$newDomainId = $domainIdMap[$oldDomainId] ?? null;
if (empty($newDomainId)) continue;
// Check if certificate already exists for this domain
$stmt = $pdo->prepare("SELECT id FROM ssl_certificates WHERE domain_id = ?");
$stmt->execute([$newDomainId]);
if ($stmt->fetch()) continue;
$stmt = $pdo->prepare("INSERT INTO ssl_certificates (domain_id, type, status, issuer, issued_at, expires_at, auto_renew, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)");
$type = $cert['type'] ?? 'lets_encrypt';
$status = $cert['status'] ?? 'active';
$issuer = $cert['issuer'] ?? null;
$issuedAt = $cert['issued_at'] ?? null;
$expiresAt = $cert['expires_at'] ?? null;
$autoRenew = $cert['auto_renew'] ?? 1;
$stmt->execute([$newDomainId, $type, $status, $issuer, $issuedAt, $expiresAt, $autoRenew, $now, $now]);
$imported['ssl_certificates']++;
}
// Note: MySQL credentials are stored with encrypted passwords that can't be restored
// The MySQL users themselves are restored via users.sql in the backup
// The panel credentials will need to be recreated if the user needs them
// Recreate nginx vhosts for imported domains
$imported['nginx_vhosts'] = 0;
$nginxNeedsReload = false;
$userInfo = posix_getpwnam($username);
if ($userInfo) {
$userHome = $userInfo['dir'];
$uid = $userInfo['uid'];
$gid = $userInfo['gid'];
foreach ($data['domains'] ?? [] as $domain) {
$domainName = $domain['domain'] ?? $domain['name'] ?? '';
if (empty($domainName)) continue;
$vhostFile = "/etc/nginx/sites-available/{$domainName}.conf";
// Skip if vhost already exists
if (file_exists($vhostFile)) continue;
$domainRoot = "{$userHome}/domains/{$domainName}";
$publicHtml = $domain['document_root'] ?? "{$domainRoot}/public_html";
$logs = "{$domainRoot}/logs";
// Ensure directories exist
if (!is_dir($publicHtml)) {
@mkdir($publicHtml, 0755, true);
@chown($publicHtml, $uid);
@chgrp($publicHtml, $gid);
}
if (!is_dir($logs)) {
@mkdir($logs, 0755, true);
@chown($logs, $uid);
@chgrp($logs, $gid);
}
// Ensure FPM pool exists (don't reload during migration - we'll do it at the end)
createFpmPool($username, reload: false);
$fpmSocket = getFpmSocketPath($username);
// Generate nginx vhost
$vhostContent = generateNginxVhost($domainName, $publicHtml, $logs, $fpmSocket);
if (file_put_contents($vhostFile, $vhostContent) !== false) {
// Enable the site
exec("ln -sf " . escapeshellarg($vhostFile) . " /etc/nginx/sites-enabled/" . escapeshellarg("{$domainName}.conf") . " 2>&1");
// Set ACLs for nginx to access files
exec("setfacl -m u:www-data:x " . escapeshellarg($userHome) . " 2>/dev/null");
exec("setfacl -m u:www-data:x " . escapeshellarg("{$userHome}/domains") . " 2>/dev/null");
exec("setfacl -m u:www-data:rx " . escapeshellarg($domainRoot) . " 2>/dev/null");
exec("setfacl -R -m u:www-data:rx " . escapeshellarg($publicHtml) . " 2>/dev/null");
exec("setfacl -R -m u:www-data:rwx " . escapeshellarg($logs) . " 2>/dev/null");
exec("setfacl -R -d -m u:www-data:rx " . escapeshellarg($publicHtml) . " 2>/dev/null");
exec("setfacl -R -d -m u:www-data:rwx " . escapeshellarg($logs) . " 2>/dev/null");
$imported['nginx_vhosts']++;
$nginxNeedsReload = true;
logger("Created nginx vhost for restored domain: $domainName");
}
}
// Reload nginx if any vhosts were created
if ($nginxNeedsReload) {
exec("nginx -t 2>&1", $testOutput, $testCode);
if ($testCode === 0) {
exec("systemctl reload nginx 2>&1");
logger("Reloaded nginx after restoring {$imported['nginx_vhosts']} vhost(s)");
} else {
logger("Warning: nginx config test failed after restore: " . implode("\n", $testOutput));
}
}
}
logger("Imported panel data for $username: " . json_encode($imported));
return ['success' => true, 'imported' => $imported];
} catch (Exception $e) {
logger("Failed to import panel data for $username: " . $e->getMessage());
return ['success' => false, 'error' => $e->getMessage(), 'imported' => $imported];
}
}
/**
* Create a backup for a user account.
* Supports both incremental (rsync-based) and full (tar.gz) backup types.
*/
function backupCreate(array $params): array
{
$username = $params['username'] ?? '';
$outputPath = $params['output_path'] ?? '';
$backupType = $params['backup_type'] ?? 'full'; // 'full' or 'incremental'
$includeFiles = $params['include_files'] ?? true;
$includeDatabases = $params['include_databases'] ?? true;
$includeMailboxes = $params['include_mailboxes'] ?? true;
$includeDns = $params['include_dns'] ?? true;
$includeSsl = $params['include_ssl'] ?? true;
$domains = $params['domains'] ?? null; // null = all domains
$databases = $params['databases'] ?? null; // null = all databases
$mailboxes = $params['mailboxes'] ?? null; // null = all mailboxes
$incrementalBase = $params['incremental_base'] ?? ''; // For incremental: path to previous backup
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
if (empty($outputPath)) {
return ['success' => false, 'error' => 'Output path is required'];
}
$homeDir = "/home/$username";
if (!is_dir($homeDir)) {
return ['success' => false, 'error' => "User home directory not found: $homeDir"];
}
// Create temp directory for backup assembly
$tempDir = sys_get_temp_dir() . '/jabali_backup_' . uniqid();
if (!mkdir($tempDir, 0755, true)) {
return ['success' => false, 'error' => 'Failed to create temp directory'];
}
$manifest = [
'version' => '2.0',
'created_at' => date('c'),
'username' => $username,
'backup_type' => $backupType,
'includes' => [
'files' => $includeFiles,
'databases' => $includeDatabases,
'mailboxes' => $includeMailboxes,
'dns' => $includeDns,
'ssl' => $includeSsl,
],
'domains' => [],
'databases' => [],
'mailboxes' => [],
'ssl_certificates' => [],
'dns_zones' => [],
'panel_data' => false,
'file_count' => 0,
'total_size' => 0,
];
try {
// Backup files (domain directories)
if ($includeFiles) {
$filesDir = "$tempDir/files";
mkdir($filesDir, 0755, true);
// Get list of domains for this user
$domainsDir = "$homeDir/domains";
if (is_dir($domainsDir)) {
$domainDirs = array_filter(scandir($domainsDir), fn($d) => $d !== '.' && $d !== '..' && is_dir("$domainsDir/$d"));
foreach ($domainDirs as $domain) {
// Skip if specific domains requested and this isn't one
if ($domains !== null && !in_array($domain, $domains)) {
continue;
}
$domainPath = "$domainsDir/$domain";
$tarFile = "$filesDir/{$domain}.tar.gz";
// Exclusions for cache/temp files
$excludes = [
'*.log',
'cache/*',
'.cache/*',
'tmp/*',
'.tmp/*',
'node_modules/*',
'vendor/*',
];
if ($backupType === 'incremental' && !empty($incrementalBase)) {
// Incremental backup using rsync with --link-dest
$baseDir = "$incrementalBase/files/$domain";
$excludeArgs = implode(' ', array_map(fn($e) => "--exclude=" . escapeshellarg($e), $excludes));
if (is_dir($baseDir)) {
exec("rsync -a $excludeArgs --link-dest=" . escapeshellarg($baseDir) . " " . escapeshellarg("$domainPath/") . " " . escapeshellarg("$filesDir/$domain/") . " 2>&1", $output, $retval);
} else {
// No base exists, do full copy with exclusions
exec("rsync -a $excludeArgs " . escapeshellarg("$domainPath/") . " " . escapeshellarg("$filesDir/$domain/") . " 2>&1", $output, $retval);
}
} else {
// Full backup - create tar.gz with exclusions
$excludeArgs = implode(' ', array_map(fn($e) => "--exclude=" . escapeshellarg($e), $excludes));
exec("tar -I pigz -cf " . escapeshellarg($tarFile) . " $excludeArgs -C " . escapeshellarg($domainsDir) . " " . escapeshellarg($domain) . " 2>&1", $output, $retval);
}
if ($retval === 0) {
$manifest['domains'][] = $domain;
if (file_exists($tarFile)) {
$manifest['total_size'] += filesize($tarFile);
}
}
}
}
}
// Backup databases
if ($includeDatabases) {
$dbDir = "$tempDir/mysql";
mkdir($dbDir, 0755, true);
$mysqli = getMysqlConnection();
if ($mysqli) {
// Get user's databases (prefixed with username_)
$prefix = $username . '_';
$result = $mysqli->query("SHOW DATABASES LIKE '{$mysqli->real_escape_string($prefix)}%'");
while ($row = $result->fetch_row()) {
$dbName = $row[0];
// Skip if specific databases requested and this isn't one
if ($databases !== null && !in_array($dbName, $databases)) {
continue;
}
$sqlFile = "$dbDir/{$dbName}.sql.gz";
// Export database using mysqldump
$mysqlHost = $_ENV['MYSQL_HOST'] ?? 'localhost';
$mysqlUser = $_ENV['MYSQL_USER'] ?? 'root';
$mysqlPass = $_ENV['MYSQL_PASSWORD'] ?? '';
$cmd = "mysqldump --host=" . escapeshellarg($mysqlHost) .
" --user=" . escapeshellarg($mysqlUser) .
" --password=" . escapeshellarg($mysqlPass) .
" --single-transaction --quick " . escapeshellarg($dbName) .
" | pigz > " . escapeshellarg($sqlFile) . " 2>&1";
exec($cmd, $output, $retval);
if ($retval === 0 && file_exists($sqlFile)) {
$manifest['databases'][] = $dbName;
$manifest['total_size'] += filesize($sqlFile);
}
}
// Backup MySQL users and their permissions
$usersFile = "$dbDir/users.sql";
$usersSql = "-- MySQL Users and Permissions Backup\n";
$usersSql .= "-- Generated: " . date('Y-m-d H:i:s') . "\n\n";
// Get all MySQL users matching the username_ pattern
// Escape underscore in LIKE pattern (underscore is a single-char wildcard in MySQL)
$safePrefix = $mysqli->real_escape_string($username) . '\\_';
$userQuery = $mysqli->query("SELECT User, Host FROM mysql.user WHERE User LIKE '{$safePrefix}%'");
$exportedUsers = [];
if ($userQuery) {
while ($userRow = $userQuery->fetch_assoc()) {
$mysqlUser = $userRow['User'];
$mysqlHost = $userRow['Host'];
$userIdentifier = "'{$mysqli->real_escape_string($mysqlUser)}'@'{$mysqli->real_escape_string($mysqlHost)}'";
// Get CREATE USER statement
$createResult = $mysqli->query("SHOW CREATE USER $userIdentifier");
if ($createResult && $createRow = $createResult->fetch_row()) {
// Add DROP USER IF EXISTS for clean restore
$usersSql .= "DROP USER IF EXISTS $userIdentifier;\n";
$usersSql .= $createRow[0] . ";\n";
// Get GRANT statements
$grantsResult = $mysqli->query("SHOW GRANTS FOR $userIdentifier");
if ($grantsResult) {
while ($grantRow = $grantsResult->fetch_row()) {
$usersSql .= $grantRow[0] . ";\n";
}
}
$usersSql .= "\n";
$exportedUsers[] = $mysqlUser;
}
}
}
// Save users.sql if we have any users
if (!empty($exportedUsers)) {
$usersSql .= "FLUSH PRIVILEGES;\n";
file_put_contents($usersFile, $usersSql);
$manifest['mysql_users'] = $exportedUsers;
$manifest['total_size'] += filesize($usersFile);
}
$mysqli->close();
}
}
// Backup mailboxes
if ($includeMailboxes) {
$mailDir = "$tempDir/mailboxes";
mkdir($mailDir, 0755, true);
// Get user's domains
$userDomains = [];
$domainsFile = "$homeDir/.domains";
if (file_exists($domainsFile)) {
$domainsData = json_decode(file_get_contents($domainsFile), true) ?: [];
$userDomains = array_keys($domainsData);
} elseif (is_dir("$homeDir/domains")) {
$userDomains = array_filter(scandir("$homeDir/domains"), fn($d) => $d !== '.' && $d !== '..' && is_dir("$homeDir/domains/$d"));
}
// Backup from /var/mail/vhosts (Dovecot virtual mailboxes)
foreach ($userDomains as $domain) {
$vhostMailPath = "/var/mail/vhosts/$domain";
if (is_dir($vhostMailPath)) {
$mailUsers = array_filter(scandir($vhostMailPath), fn($m) => $m !== '.' && $m !== '..' && is_dir("$vhostMailPath/$m"));
foreach ($mailUsers as $mailUser) {
$mailboxName = "{$mailUser}@{$domain}";
// Skip if specific mailboxes requested
if ($mailboxes !== null && !in_array($mailboxName, $mailboxes)) {
continue;
}
$tarFile = "$mailDir/{$domain}_{$mailUser}.tar.gz";
exec("tar -I pigz -cf " . escapeshellarg($tarFile) . " -C " . escapeshellarg($vhostMailPath) . " " . escapeshellarg($mailUser) . " 2>&1", $output, $retval);
if ($retval === 0 && file_exists($tarFile)) {
$manifest['mailboxes'][] = $mailboxName;
$manifest['total_size'] += filesize($tarFile);
}
}
}
}
// Also backup user's local mail directory if exists
$userMailDir = "$homeDir/mail";
if (is_dir($userMailDir) && count(scandir($userMailDir)) > 2) {
$tarFile = "$mailDir/local_mail.tar.gz";
exec("tar -I pigz -cf " . escapeshellarg($tarFile) . " -C " . escapeshellarg($homeDir) . " mail 2>&1", $output, $retval);
if ($retval === 0 && file_exists($tarFile)) {
$manifest['total_size'] += filesize($tarFile);
}
}
}
// Backup SSL certificates
if ($includeSsl) {
$sslDir = "$tempDir/ssl";
mkdir($sslDir, 0755, true);
// Backup user's SSL directory (custom certificates)
$userSslDir = "$homeDir/ssl";
if (is_dir($userSslDir) && count(scandir($userSslDir)) > 2) {
exec("cp -a " . escapeshellarg($userSslDir) . " " . escapeshellarg("$sslDir/user_ssl") . " 2>&1");
}
$letsencryptDir = '/etc/letsencrypt/live';
if (is_dir($letsencryptDir)) {
// Get user's domains to know which SSL certs belong to them
$userDomains = $manifest['domains'];
if (empty($userDomains) && is_dir("$homeDir/domains")) {
$userDomains = array_filter(scandir("$homeDir/domains"), fn($d) => $d !== '.' && $d !== '..' && is_dir("$homeDir/domains/$d"));
}
foreach ($userDomains as $domain) {
// Skip if specific domains requested and this isn't one
if ($domains !== null && !in_array($domain, $domains)) {
continue;
}
$certPath = "$letsencryptDir/$domain";
if (is_dir($certPath)) {
$domainSslDir = "$sslDir/$domain";
mkdir($domainSslDir, 0755, true);
// Copy certificate files (following symlinks to get actual content)
$certFiles = ['fullchain.pem', 'privkey.pem', 'cert.pem', 'chain.pem'];
foreach ($certFiles as $certFile) {
$srcFile = "$certPath/$certFile";
if (file_exists($srcFile)) {
// Read through symlink and copy actual content
$realPath = realpath($srcFile);
if ($realPath && file_exists($realPath)) {
copy($realPath, "$domainSslDir/$certFile");
}
}
}
$manifest['ssl_certificates'][] = $domain;
}
}
}
}
// Backup DNS zone files
if ($includeDns) {
$dnsDir = "$tempDir/zones";
mkdir($dnsDir, 0755, true);
$zonesDir = '/etc/bind/zones';
if (is_dir($zonesDir)) {
$userDomains = $manifest['domains'];
if (empty($userDomains) && is_dir("$homeDir/domains")) {
$userDomains = array_filter(scandir("$homeDir/domains"), fn($d) => $d !== '.' && $d !== '..' && is_dir("$homeDir/domains/$d"));
}
foreach ($userDomains as $domain) {
$zoneFile = "$zonesDir/db.$domain";
if (file_exists($zoneFile)) {
copy($zoneFile, "$dnsDir/db.$domain");
$manifest['dns_zones'][] = $domain;
}
}
}
}
// Export panel metadata (domains config, email accounts, WordPress sites, etc.)
$metadataDir = "$tempDir/metadata";
mkdir($metadataDir, 0755, true);
// Export user's .domains file
$domainsFile = "$homeDir/.domains";
if (file_exists($domainsFile)) {
copy($domainsFile, "$metadataDir/domains.json");
}
// Export user's .wordpress_sites file
$wpSitesFile = "$homeDir/.wordpress_sites";
if (file_exists($wpSitesFile)) {
copy($wpSitesFile, "$metadataDir/wordpress_sites.json");
}
// Export email configuration from database via PHP
$panelMetadata = exportUserPanelData($username);
if (!empty($panelMetadata)) {
file_put_contents("$metadataDir/panel_data.json", json_encode($panelMetadata, JSON_PRETTY_PRINT));
$manifest['panel_data'] = true;
}
// Save manifest
file_put_contents("$tempDir/manifest.json", json_encode($manifest, JSON_PRETTY_PRINT));
// Create final archive
$outputDir = dirname($outputPath);
if (!is_dir($outputDir)) {
mkdir($outputDir, 0755, true);
}
if ($backupType === 'incremental') {
// For incremental, just copy the temp dir as the backup
exec("cp -a " . escapeshellarg($tempDir) . " " . escapeshellarg($outputPath) . " 2>&1", $output, $retval);
} else {
// For full backup, create a tar.gz archive
exec("tar -I pigz -cf " . escapeshellarg($outputPath) . " -C " . escapeshellarg(dirname($tempDir)) . " " . escapeshellarg(basename($tempDir)) . " 2>&1", $output, $retval);
}
if ($retval !== 0) {
throw new Exception('Failed to create backup archive');
}
// Calculate checksum
$checksum = '';
if (file_exists($outputPath)) {
if (is_file($outputPath)) {
$checksum = hash_file('sha256', $outputPath);
$finalSize = filesize($outputPath);
} else {
// For incremental (directory), calculate size recursively
$finalSize = 0;
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($outputPath));
foreach ($iterator as $file) {
if ($file->isFile()) {
$finalSize += $file->getSize();
}
}
}
}
// Cleanup temp directory
exec("rm -rf " . escapeshellarg($tempDir));
return [
'success' => true,
'path' => $outputPath,
'size' => $finalSize ?? $manifest['total_size'],
'checksum' => $checksum,
'domains' => $manifest['domains'],
'databases' => $manifest['databases'],
'mysql_users' => $manifest['mysql_users'] ?? [],
'mailboxes' => $manifest['mailboxes'],
'ssl_certificates' => $manifest['ssl_certificates'],
'backup_type' => $backupType,
];
} catch (Exception $e) {
// Cleanup on error
exec("rm -rf " . escapeshellarg($tempDir));
return ['success' => false, 'error' => $e->getMessage()];
}
}
/**
* Create a server-wide backup (all users or selected users).
*/
function backupCreateServer(array $params): array
{
$outputPath = $params['output_path'] ?? '';
$backupType = $params['backup_type'] ?? 'full';
$users = $params['users'] ?? null; // null = all users
$includeFiles = $params['include_files'] ?? true;
$includeDatabases = $params['include_databases'] ?? true;
$includeMailboxes = $params['include_mailboxes'] ?? true;
$includeDns = $params['include_dns'] ?? true;
if (empty($outputPath)) {
return ['success' => false, 'error' => 'Output path is required'];
}
// Output path is now a folder (e.g., /var/backups/jabali/2026-01-12_013000/)
if (!is_dir($outputPath)) {
if (!mkdir($outputPath, 0755, true)) {
return ['success' => false, 'error' => 'Failed to create backup directory'];
}
}
$manifest = [
'version' => '1.0',
'created_at' => date('c'),
'type' => 'server',
'backup_type' => $backupType,
'users' => [],
'total_size' => 0,
];
try {
// Get all system users with home directories in /home
$systemUsers = [];
if ($users !== null) {
$systemUsers = $users;
} else {
$homeDirs = array_filter(scandir('/home'), fn($d) => $d !== '.' && $d !== '..' && is_dir("/home/$d") && !isProtectedUser($d));
$systemUsers = $homeDirs;
}
foreach ($systemUsers as $username) {
if (!validateUsername($username) || isProtectedUser($username)) {
continue;
}
// Create individual tar.gz per user
$userBackupPath = "$outputPath/{$username}.tar.gz";
$userResult = backupCreate([
'username' => $username,
'output_path' => $userBackupPath,
'backup_type' => 'full', // Always full for individual user archives
'include_files' => $includeFiles,
'include_databases' => $includeDatabases,
'include_mailboxes' => $includeMailboxes,
'include_dns' => $includeDns,
]);
if ($userResult['success']) {
$userSize = file_exists($userBackupPath) ? filesize($userBackupPath) : 0;
$manifest['users'][$username] = [
'file' => "{$username}.tar.gz",
'domains' => $userResult['domains'] ?? [],
'databases' => $userResult['databases'] ?? [],
'mailboxes' => $userResult['mailboxes'] ?? [],
'size' => $userSize,
'checksum' => file_exists($userBackupPath) ? hash_file('sha256', $userBackupPath) : '',
];
$manifest['total_size'] += $userSize;
}
}
// Save manifest in the backup folder
file_put_contents("$outputPath/manifest.json", json_encode($manifest, JSON_PRETTY_PRINT));
return [
'success' => true,
'path' => $outputPath,
'size' => $manifest['total_size'],
'users' => array_keys($manifest['users']),
'backup_type' => $backupType,
'user_count' => count($manifest['users']),
];
} catch (Exception $e) {
return ['success' => false, 'error' => $e->getMessage()];
}
}
/**
* Restore a backup for a user.
*/
function backupRestore(array $params): array
{
$username = $params['username'] ?? '';
$backupPath = $params['backup_path'] ?? '';
$restoreFiles = $params['restore_files'] ?? true;
$restoreDatabases = $params['restore_databases'] ?? true;
$restoreMailboxes = $params['restore_mailboxes'] ?? true;
$restoreDns = $params['restore_dns'] ?? true;
$restoreSsl = $params['restore_ssl'] ?? true;
$selectedDomains = $params['selected_domains'] ?? null;
$selectedDatabases = $params['selected_databases'] ?? null;
$selectedMailboxes = $params['selected_mailboxes'] ?? null;
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
if (!file_exists($backupPath)) {
return ['success' => false, 'error' => 'Backup file not found'];
}
$homeDir = "/home/$username";
$tempDir = sys_get_temp_dir() . '/jabali_restore_' . uniqid();
// Check if system user exists, create if not
$systemUser = posix_getpwnam($username);
if (!$systemUser) {
logger("System user $username not found, creating...");
// Create system user with proper home directory
$cmd = "useradd -m -s /usr/sbin/nologin -d " . escapeshellarg($homeDir) . " " . escapeshellarg($username) . " 2>&1";
exec($cmd, $useraddOutput, $useraddRetval);
if ($useraddRetval !== 0) {
return ['success' => false, 'error' => "Failed to create system user: " . implode("\n", $useraddOutput)];
}
// Add to sftpusers group for SFTP access
exec("usermod -aG sftpusers " . escapeshellarg($username) . " 2>/dev/null");
// Set up home directory ownership for SFTP chroot
chown($homeDir, "root");
chgrp($homeDir, $username);
chmod($homeDir, 0750);
// Create standard directories
$dirs = ['domains', 'mail', 'logs', 'backups', 'tmp'];
foreach ($dirs as $dir) {
$dirPath = "$homeDir/$dir";
if (!is_dir($dirPath)) {
mkdir($dirPath, 0755, true);
chown($dirPath, $username);
chgrp($dirPath, $username);
}
}
// Create PHP-FPM pool for the user (don't reload during migration - we'll do it at the end)
createFpmPool($username, reload: false);
logger("Created system user $username with home directory $homeDir");
}
try {
mkdir($tempDir, 0755, true);
// Extract or copy backup
if (is_file($backupPath) && preg_match('/\.(tar\.gz|tgz)$/i', $backupPath)) {
exec("tar -I pigz -xf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($tempDir) . " 2>&1", $output, $retval);
if ($retval !== 0) {
throw new Exception('Failed to extract backup archive');
}
// Find the extracted directory
$extracted = array_filter(scandir($tempDir), fn($d) => $d !== '.' && $d !== '..');
if (count($extracted) === 1) {
$tempDir = "$tempDir/" . reset($extracted);
}
} elseif (is_dir($backupPath)) {
// Check if this is a server backup (directory with per-user tar.gz files)
$userTarGz = "$backupPath/$username.tar.gz";
if (file_exists($userTarGz)) {
// Server backup - extract the specific user's tar.gz
exec("tar -I pigz -xf " . escapeshellarg($userTarGz) . " -C " . escapeshellarg($tempDir) . " 2>&1", $output, $retval);
if ($retval !== 0) {
throw new Exception("Failed to extract user backup from server backup: $username.tar.gz");
}
// Find the extracted directory (usually named after the username)
$extracted = array_filter(scandir($tempDir), fn($d) => $d !== '.' && $d !== '..');
if (count($extracted) === 1) {
$tempDir = "$tempDir/" . reset($extracted);
}
} else {
// Incremental/direct backup - copy to temp (use /. to include hidden files)
exec("cp -a " . escapeshellarg(rtrim($backupPath, '/')) . "/. " . escapeshellarg($tempDir) . "/ 2>&1");
}
} else {
throw new Exception('Invalid backup format');
}
// Detect incremental backup format (uses --relative paths like home/user/domains/)
// Convert incremental format to standard format expected by restore
$incrementalUserDir = "$tempDir/home/$username";
if (is_dir($incrementalUserDir)) {
logger("Detected incremental backup format, converting to standard structure");
// Move domains to files/
if (is_dir("$incrementalUserDir/domains")) {
mkdir("$tempDir/files", 0755, true);
exec("mv " . escapeshellarg("$incrementalUserDir/domains") . "/* " . escapeshellarg("$tempDir/files/") . " 2>&1");
}
// Move mysql to standard location
if (is_dir("$incrementalUserDir/mysql")) {
exec("mv " . escapeshellarg("$incrementalUserDir/mysql") . " " . escapeshellarg("$tempDir/") . " 2>&1");
}
// Move zones (DNS) to standard location
if (is_dir("$incrementalUserDir/zones")) {
mkdir("$tempDir/dns", 0755, true);
exec("mv " . escapeshellarg("$incrementalUserDir/zones") . "/* " . escapeshellarg("$tempDir/dns/") . " 2>&1");
}
// Move mail to mailboxes/
if (is_dir("$incrementalUserDir/mail")) {
mkdir("$tempDir/mailboxes", 0755, true);
exec("mv " . escapeshellarg("$incrementalUserDir/mail") . "/* " . escapeshellarg("$tempDir/mailboxes/") . " 2>&1");
}
// Check for var/mail/vhosts (additional mailbox storage)
if (is_dir("$tempDir/var/mail/vhosts")) {
if (!is_dir("$tempDir/mailboxes")) {
mkdir("$tempDir/mailboxes", 0755, true);
}
exec("cp -a " . escapeshellarg("$tempDir/var/mail/vhosts") . "/* " . escapeshellarg("$tempDir/mailboxes/") . " 2>&1");
}
// Restore .wordpress_sites registry file
if (file_exists("$incrementalUserDir/.wordpress_sites")) {
$srcFile = "$incrementalUserDir/.wordpress_sites";
$dstFile = "$homeDir/.wordpress_sites";
logger("Copying $srcFile to $dstFile (homeDir exists: " . (is_dir($homeDir) ? 'yes' : 'no') . ")");
if (copy($srcFile, $dstFile)) {
chown($dstFile, $username);
chgrp($dstFile, $username);
logger("Restored .wordpress_sites registry for $username");
} else {
logger("ERROR: Failed to copy .wordpress_sites from $srcFile to $dstFile");
}
}
// Restore .domains registry file
if (file_exists("$incrementalUserDir/.domains")) {
$srcFile = "$incrementalUserDir/.domains";
$dstFile = "$homeDir/.domains";
if (copy($srcFile, $dstFile)) {
chown($dstFile, $username);
chgrp($dstFile, $username);
logger("Restored .domains registry for $username");
} else {
logger("ERROR: Failed to copy .domains from $srcFile to $dstFile");
}
}
// Move SSL certificates to temp location
if (is_dir("$incrementalUserDir/ssl")) {
exec("mv " . escapeshellarg("$incrementalUserDir/ssl") . " " . escapeshellarg("$tempDir/") . " 2>&1");
}
// Move metadata directory (panel_data.json) to temp location
if (is_dir("$incrementalUserDir/metadata")) {
exec("mv " . escapeshellarg("$incrementalUserDir/metadata") . " " . escapeshellarg("$tempDir/") . " 2>&1");
logger("Moved metadata directory for $username");
}
// Cleanup incremental structure
exec("rm -rf " . escapeshellarg("$tempDir/home") . " " . escapeshellarg("$tempDir/var") . " 2>&1");
}
// Read manifest - check both temp dir and original backup path
$manifestPath = "$tempDir/manifest.json";
if (!file_exists($manifestPath) && is_dir($backupPath)) {
// For server backups, manifest might be in the original directory
$serverManifestPath = "$backupPath/manifest.json";
if (file_exists($serverManifestPath)) {
$manifestPath = $serverManifestPath;
}
}
// For incremental backups without manifest, create a minimal one
if (!file_exists($manifestPath)) {
logger("No manifest found, creating minimal manifest for incremental backup");
$manifest = [
'username' => $username,
'type' => 'incremental',
'domains' => is_dir("$tempDir/files") ? array_filter(scandir("$tempDir/files"), fn($d) => $d !== '.' && $d !== '..') : [],
'databases' => [],
'mailboxes' => [],
];
// Detect databases
$dbDir = is_dir("$tempDir/mysql") ? "$tempDir/mysql" : "$tempDir/databases";
if (is_dir($dbDir)) {
foreach (scandir($dbDir) as $item) {
if (preg_match('/\.sql(\.gz)?$/i', $item)) {
$manifest['databases'][] = preg_replace('/\.sql(\.gz)?$/i', '', $item);
}
}
}
// Detect mailboxes
if (is_dir("$tempDir/mailboxes")) {
foreach (scandir("$tempDir/mailboxes") as $domain) {
if ($domain === '.' || $domain === '..') continue;
if (is_dir("$tempDir/mailboxes/$domain")) {
$manifest['mailboxes'][] = $domain;
}
}
}
} else {
$manifest = json_decode(file_get_contents($manifestPath), true);
}
$restored = [
'files' => [],
'databases' => [],
'mailboxes' => [],
'ssl_certificates' => [],
];
// Restore files
if ($restoreFiles && is_dir("$tempDir/files")) {
$filesDir = "$tempDir/files";
$domainsTarget = "$homeDir/domains";
if (!is_dir($domainsTarget)) {
mkdir($domainsTarget, 0755, true);
}
foreach (scandir($filesDir) as $item) {
if ($item === '.' || $item === '..') continue;
// Extract domain name
$domain = preg_replace('/\.tar\.gz$/i', '', $item);
// SECURITY: Validate domain name (no path traversal)
if (!validateDomain($domain) || strpos($domain, '..') !== false || strpos($domain, '/') !== false) {
logger( "Blocked invalid domain name in file restore: $domain");
continue;
}
if ($selectedDomains !== null && !in_array($domain, $selectedDomains)) {
continue;
}
$itemPath = "$filesDir/$item";
$retval = 1; // Default to failure
if (is_file($itemPath) && preg_match('/\.tar\.gz$/i', $item)) {
// Extract tar.gz
exec("tar -I pigz -xf " . escapeshellarg($itemPath) . " -C " . escapeshellarg($domainsTarget) . " 2>&1", $output, $retval);
} elseif (is_dir($itemPath)) {
// Copy directory contents (incremental) - use rsync to avoid nesting
$targetDomainDir = "$domainsTarget/$domain";
if (!is_dir($targetDomainDir)) {
mkdir($targetDomainDir, 0755, true);
}
// Use trailing slash on source to copy contents, not the directory itself
exec("rsync -a " . escapeshellarg("$itemPath/") . " " . escapeshellarg("$targetDomainDir/") . " 2>&1", $output, $retval);
}
if ($retval === 0) {
// SECURITY: Remove dangerous symlinks that point outside user's home
$domainPath = "$domainsTarget/$domain";
$removedSymlinks = removeDangerousSymlinks($domainPath, $homeDir);
if ($removedSymlinks > 0) {
logger( "Removed $removedSymlinks dangerous symlinks from $domainPath");
}
// Fix ownership
exec("chown -R $username:$username " . escapeshellarg($domainPath));
$restored['files'][] = $domain;
}
}
}
// Restore databases (check both mysql/ and databases/ for backward compatibility)
$dbDir = is_dir("$tempDir/mysql") ? "$tempDir/mysql" : "$tempDir/databases";
$dbPrefix = $username . '_';
if ($restoreDatabases && is_dir($dbDir)) {
$mysqli = getMysqlConnection();
if ($mysqli) {
foreach (scandir($dbDir) as $item) {
if ($item === '.' || $item === '..') continue;
if ($item === 'users.sql') continue; // Handle separately
if (!preg_match('/\.sql(\.gz)?$/i', $item)) continue;
$dbName = preg_replace('/\.sql(\.gz)?$/i', '', $item);
// SECURITY: Verify database name starts with username_ prefix
if (strpos($dbName, $dbPrefix) !== 0) {
logger( "Blocked restore of database not owned by user: $dbName (expected prefix: $dbPrefix)");
continue;
}
if ($selectedDatabases !== null && !in_array($dbName, $selectedDatabases)) {
continue;
}
$sqlFile = "$dbDir/$item";
// SECURITY: Sanitize database dump (remove DEFINER, GRANT, etc.)
if (!sanitizeDatabaseDump($username, $sqlFile)) {
logger( "Failed to sanitize database dump: $sqlFile");
continue;
}
// Ensure database exists
$mysqli->query("CREATE DATABASE IF NOT EXISTS `{$mysqli->real_escape_string($dbName)}`");
$mysqlHost = $_ENV['MYSQL_HOST'] ?? 'localhost';
$mysqlUser = $_ENV['MYSQL_USER'] ?? 'root';
$mysqlPass = $_ENV['MYSQL_PASSWORD'] ?? '';
if (preg_match('/\.gz$/i', $item)) {
$cmd = "pigz -dc " . escapeshellarg($sqlFile) . " | mysql " .
"--host=" . escapeshellarg($mysqlHost) .
" --user=" . escapeshellarg($mysqlUser) .
" --password=" . escapeshellarg($mysqlPass) .
" " . escapeshellarg($dbName) . " 2>&1";
} else {
$cmd = "mysql --host=" . escapeshellarg($mysqlHost) .
" --user=" . escapeshellarg($mysqlUser) .
" --password=" . escapeshellarg($mysqlPass) .
" " . escapeshellarg($dbName) .
" < " . escapeshellarg($sqlFile) . " 2>&1";
}
exec($cmd, $output, $retval);
if ($retval === 0) {
$restored['databases'][] = $dbName;
}
}
// Restore MySQL users and permissions
$usersFile = "$dbDir/users.sql";
if (file_exists($usersFile)) {
// SECURITY: Validate and sanitize users.sql
$sanitizedUsersSql = validateMysqlUsersFile($username, $usersFile);
if ($sanitizedUsersSql !== null && !empty(trim($sanitizedUsersSql))) {
// Write sanitized SQL to temp file
$sanitizedUsersFile = "$dbDir/users_sanitized.sql";
file_put_contents($sanitizedUsersFile, $sanitizedUsersSql);
$mysqlHost = $_ENV['MYSQL_HOST'] ?? 'localhost';
$mysqlUser = $_ENV['MYSQL_USER'] ?? 'root';
$mysqlPass = $_ENV['MYSQL_PASSWORD'] ?? '';
$cmd = "mysql --host=" . escapeshellarg($mysqlHost) .
" --user=" . escapeshellarg($mysqlUser) .
" --password=" . escapeshellarg($mysqlPass) .
" < " . escapeshellarg($sanitizedUsersFile) . " 2>&1";
exec($cmd, $usersOutput, $usersRetval);
if ($usersRetval === 0) {
$restored['mysql_users'] = true;
}
// Cleanup
unlink($sanitizedUsersFile);
} else {
logger( "MySQL users.sql validation failed or empty after sanitization");
}
}
// Fallback: If users.sql wasn't restored, create MySQL users from wp-config.php
if (empty($restored['mysql_users']) && !empty($restored['databases'])) {
logger("No users.sql restored, attempting to create MySQL users from wp-config.php");
$filesDir = "$tempDir/files";
foreach ($restored['databases'] as $dbName) {
// Find wp-config.php files that reference this database
if (is_dir($filesDir)) {
foreach (scandir($filesDir) as $domain) {
if ($domain === '.' || $domain === '..') continue;
$wpConfig = "$filesDir/$domain/public_html/wp-config.php";
if (!file_exists($wpConfig)) continue;
$configContent = file_get_contents($wpConfig);
// Check if this wp-config uses this database
if (strpos($configContent, "'$dbName'") === false && strpos($configContent, "\"$dbName\"") === false) {
continue;
}
// Extract DB credentials
$dbUser = null;
$dbPass = null;
if (preg_match("/define\s*\(\s*['\"]DB_USER['\"]\s*,\s*['\"]([^'\"]+)['\"]/", $configContent, $matches)) {
$dbUser = $matches[1];
}
if (preg_match("/define\s*\(\s*['\"]DB_PASSWORD['\"]\s*,\s*['\"]([^'\"]+)['\"]/", $configContent, $matches)) {
$dbPass = $matches[1];
}
// Security: Only create user if it starts with username_
if ($dbUser && strpos($dbUser, $username . '_') === 0) {
// Check if user already exists
$checkUser = $mysqli->query("SELECT User FROM mysql.user WHERE User = '" . $mysqli->real_escape_string($dbUser) . "' AND Host = 'localhost'");
if ($checkUser && $checkUser->num_rows === 0) {
// Create user and grant permissions
$safeUser = $mysqli->real_escape_string($dbUser);
$safePass = $mysqli->real_escape_string($dbPass);
$safeDb = $mysqli->real_escape_string($dbName);
$mysqli->query("CREATE USER '$safeUser'@'localhost' IDENTIFIED BY '$safePass'");
$mysqli->query("GRANT ALL PRIVILEGES ON `$safeDb`.* TO '$safeUser'@'localhost'");
$mysqli->query("FLUSH PRIVILEGES");
logger("Created MySQL user $dbUser for database $dbName (from wp-config.php)");
$restored['mysql_users_created'][] = $dbUser;
}
}
}
}
}
}
$mysqli->close();
}
}
// Restore mailboxes
if ($restoreMailboxes && is_dir("$tempDir/mailboxes")) {
$mailDir = "$tempDir/mailboxes";
$userDomains = getUserDomains($username);
foreach (scandir($mailDir) as $item) {
if ($item === '.' || $item === '..') continue;
$itemPath = "$mailDir/$item";
// Handle tar.gz files (traditional backup format)
if (is_file($itemPath) && preg_match('/\.tar\.gz$/i', $item)) {
// Parse domain_user.tar.gz format
$parts = explode('_', preg_replace('/\.tar\.gz$/i', '', $item));
if (count($parts) >= 2) {
$mailUser = array_pop($parts);
$domain = implode('_', $parts);
$mailboxName = "{$mailUser}@{$domain}";
// SECURITY: Validate domain ownership and format
if (!validateDomain($domain) || strpos($domain, '..') !== false) {
logger("Blocked invalid domain in mailbox restore: $domain");
continue;
}
// SECURITY: Check if domain belongs to this user
if (!in_array($domain, $userDomains)) {
logger("Blocked mailbox restore for unowned domain: $domain (user: $username)");
continue;
}
if ($selectedMailboxes !== null && !in_array($mailboxName, $selectedMailboxes)) {
continue;
}
$targetPath = "$homeDir/domains/$domain/mail";
if (!is_dir($targetPath)) {
mkdir($targetPath, 0755, true);
}
exec("tar -I pigz -xf " . escapeshellarg($itemPath) . " -C " . escapeshellarg($targetPath) . " 2>&1", $output, $retval);
if ($retval === 0) {
// SECURITY: Remove dangerous symlinks
$mailboxPath = "$targetPath/$mailUser";
if (is_dir($mailboxPath)) {
removeDangerousSymlinks($mailboxPath, $homeDir);
}
exec("chown -R $username:$username " . escapeshellarg($mailboxPath));
$restored['mailboxes'][] = $mailboxName;
}
}
}
// Handle directories (incremental backup format: mailboxes/domain/mailuser/)
elseif (is_dir($itemPath)) {
$domain = $item;
// SECURITY: Validate domain format
if (!validateDomain($domain) || strpos($domain, '..') !== false) {
logger("Blocked invalid domain in mailbox restore: $domain");
continue;
}
// SECURITY: Check if domain belongs to this user
if (!in_array($domain, $userDomains)) {
logger("Blocked mailbox restore for unowned domain: $domain (user: $username)");
continue;
}
// Restore all mailboxes for this domain
foreach (scandir($itemPath) as $mailUser) {
if ($mailUser === '.' || $mailUser === '..') continue;
$mailboxSrc = "$itemPath/$mailUser";
if (!is_dir($mailboxSrc)) continue;
$mailboxName = "{$mailUser}@{$domain}";
if ($selectedMailboxes !== null && !in_array($mailboxName, $selectedMailboxes)) {
continue;
}
// Copy mailbox to user's home mail directory
$mailTarget = "$homeDir/mail/$domain/$mailUser";
if (!is_dir(dirname($mailTarget))) {
mkdir(dirname($mailTarget), 0755, true);
}
// Remove existing mailbox directory if present
if (is_dir($mailTarget)) {
exec("rm -rf " . escapeshellarg($mailTarget));
}
exec("cp -a " . escapeshellarg($mailboxSrc) . " " . escapeshellarg($mailTarget) . " 2>&1", $output, $retval);
if ($retval === 0) {
// SECURITY: Remove dangerous symlinks
removeDangerousSymlinks($mailTarget, $homeDir);
// Fix ownership for system user
exec("chown -R $username:$username " . escapeshellarg($mailTarget));
// Add to Postfix virtual_mailbox_maps if not already there
$mailboxesFile = POSTFIX_VIRTUAL_MAILBOXES;
$mailboxesContent = file_exists($mailboxesFile) ? file_get_contents($mailboxesFile) : '';
if (strpos($mailboxesContent, $mailboxName) === false) {
file_put_contents($mailboxesFile, trim($mailboxesContent) . "\n{$mailboxName} {$domain}/{$mailUser}/\n");
}
$restored['mailboxes'][] = $mailboxName;
logger("Restored mailbox: $mailboxName to $mailTarget");
}
}
}
}
// Rebuild Postfix hash file if mailboxes were restored
if (!empty($restored['mailboxes'])) {
exec('postmap ' . escapeshellarg(POSTFIX_VIRTUAL_MAILBOXES));
}
}
// Restore SSL certificates
if ($restoreSsl && is_dir("$tempDir/ssl")) {
$sslBackupDir = "$tempDir/ssl";
$letsencryptDir = '/etc/letsencrypt/live';
$userDomains = getUserDomains($username);
// Create letsencrypt directory if needed
if (!is_dir($letsencryptDir)) {
mkdir($letsencryptDir, 0755, true);
}
foreach (scandir($sslBackupDir) as $domain) {
if ($domain === '.' || $domain === '..') continue;
if (!is_dir("$sslBackupDir/$domain")) continue;
// SECURITY: Validate domain format and no path traversal
if (!validateDomain($domain) || strpos($domain, '..') !== false || strpos($domain, '/') !== false) {
logger( "Blocked invalid domain in SSL restore: $domain");
continue;
}
// SECURITY: Verify domain belongs to this user
if (!in_array($domain, $userDomains)) {
logger( "Blocked SSL restore for unowned domain: $domain (user: $username)");
continue;
}
if ($selectedDomains !== null && !in_array($domain, $selectedDomains)) {
continue;
}
$targetDir = "$letsencryptDir/$domain";
// Create target directory
if (!is_dir($targetDir)) {
mkdir($targetDir, 0755, true);
}
// Copy certificate files (only allow specific filenames)
$certFiles = ['fullchain.pem', 'privkey.pem', 'cert.pem', 'chain.pem'];
$copied = false;
foreach ($certFiles as $certFile) {
$srcFile = "$sslBackupDir/$domain/$certFile";
if (file_exists($srcFile) && is_file($srcFile)) {
// SECURITY: Verify source is a regular file, not a symlink
if (is_link($srcFile)) {
logger( "Blocked symlink in SSL backup: $srcFile");
continue;
}
copy($srcFile, "$targetDir/$certFile");
chmod("$targetDir/$certFile", $certFile === 'privkey.pem' ? 0600 : 0644);
$copied = true;
}
}
if ($copied) {
$restored['ssl_certificates'][] = $domain;
}
}
}
// Restore metadata files (for full backups that store them in metadata/)
$metadataDir = "$tempDir/metadata";
if (is_dir($metadataDir)) {
// Restore .wordpress_sites from metadata/wordpress_sites.json
if (file_exists("$metadataDir/wordpress_sites.json")) {
copy("$metadataDir/wordpress_sites.json", "$homeDir/.wordpress_sites");
chown("$homeDir/.wordpress_sites", $username);
chgrp("$homeDir/.wordpress_sites", $username);
logger("Restored .wordpress_sites from metadata for $username");
}
// Restore .domains from metadata/domains.json
if (file_exists("$metadataDir/domains.json")) {
copy("$metadataDir/domains.json", "$homeDir/.domains");
chown("$homeDir/.domains", $username);
chgrp("$homeDir/.domains", $username);
logger("Restored .domains from metadata for $username");
}
}
// Restore DNS zones (check both zones/ and dns/ for backward compatibility)
$dnsDir = is_dir("$tempDir/zones") ? "$tempDir/zones" : "$tempDir/dns";
if ($restoreDns && is_dir($dnsDir)) {
$zonesTargetDir = '/etc/bind/zones';
$restored['dns_zones'] = [];
$userDomains = getUserDomains($username);
foreach (scandir($dnsDir) as $item) {
if ($item === '.' || $item === '..') continue;
if (!preg_match('/^db\.(.+)$/', $item, $matches)) continue;
$domain = $matches[1];
// SECURITY: Validate domain format and no path traversal
if (!validateDomain($domain) || strpos($domain, '..') !== false || strpos($domain, '/') !== false) {
logger( "Blocked invalid domain in DNS restore: $domain");
continue;
}
// SECURITY: Verify domain belongs to this user
if (!in_array($domain, $userDomains)) {
logger( "Blocked DNS zone restore for unowned domain: $domain (user: $username)");
continue;
}
if ($selectedDomains !== null && !in_array($domain, $selectedDomains)) {
continue;
}
$srcFile = "$dnsDir/$item";
$targetFile = "$zonesTargetDir/$item";
// SECURITY: Verify source is a regular file, not a symlink
if (is_link($srcFile)) {
logger( "Blocked symlink in DNS backup: $srcFile");
continue;
}
// SECURITY: Validate zone file using named-checkzone before copying
exec("named-checkzone " . escapeshellarg($domain) . " " . escapeshellarg($srcFile) . " 2>&1", $checkOutput, $checkRetval);
if ($checkRetval !== 0) {
logger( "Invalid DNS zone file rejected: $srcFile - " . implode(' ', $checkOutput));
continue;
}
if (copy($srcFile, $targetFile)) {
chmod($targetFile, 0644);
chown($targetFile, 'bind');
chgrp($targetFile, 'bind');
$restored['dns_zones'][] = $domain;
}
}
// Reload BIND if we restored any zones
if (!empty($restored['dns_zones'])) {
exec('rndc reload 2>&1');
}
}
// Import panel data (database records) from metadata/panel_data.json
$panelDataFile = "$tempDir/metadata/panel_data.json";
if (file_exists($panelDataFile)) {
$panelData = json_decode(file_get_contents($panelDataFile), true);
if ($panelData) {
$importResult = importUserPanelData($username, $panelData);
if ($importResult['success'] ?? false) {
$restored['panel_data'] = $importResult['imported'];
logger("Imported panel data for $username: " . json_encode($importResult['imported']));
} else {
logger("Failed to import panel data for $username: " . ($importResult['error'] ?? 'Unknown error'));
}
}
} else {
// No panel_data.json - create minimal panel user record from restored data
logger("No panel_data.json found, creating minimal panel user record for $username");
// Build minimal panel data from restored files and registry
$minimalPanelData = [
'username' => $username,
'user' => [
'name' => $username,
'email' => "{$username}@localhost",
'username' => $username,
'is_admin' => 0,
'is_active' => 1,
'home_directory' => $homeDir,
'disk_quota_mb' => 10240,
],
'domains' => [],
'email_domains' => [],
'mailboxes' => [],
];
// Read domains from .domains registry file
$domainsFile = "$homeDir/.domains";
if (file_exists($domainsFile)) {
$domainsData = json_decode(file_get_contents($domainsFile), true) ?: [];
foreach ($domainsData as $domainName => $domainInfo) {
$minimalPanelData['domains'][] = [
'domain' => $domainName,
'document_root' => $domainInfo['document_root'] ?? "$homeDir/domains/$domainName/public_html",
'is_active' => 1,
];
}
}
// Read email domains from restored mailboxes
if (!empty($restored['mailboxes'])) {
$seenDomains = [];
foreach ($restored['mailboxes'] as $mailboxEmail) {
$parts = explode('@', $mailboxEmail);
if (count($parts) === 2) {
$localPart = $parts[0];
$domain = $parts[1];
if (!isset($seenDomains[$domain])) {
$minimalPanelData['email_domains'][] = ['domain' => $domain];
$seenDomains[$domain] = true;
}
$minimalPanelData['mailboxes'][] = [
'local_part' => $localPart,
'domain' => $domain,
'name' => $localPart,
'quota_bytes' => 1073741824,
'maildir_path' => "$homeDir/mail/$domain/$localPart/",
];
}
}
}
$importResult = importUserPanelData($username, $minimalPanelData);
if ($importResult['success'] ?? false) {
$restored['panel_data'] = $importResult['imported'];
logger("Created minimal panel data for $username: " . json_encode($importResult['imported']));
} else {
logger("Failed to create minimal panel data for $username: " . ($importResult['error'] ?? 'Unknown error'));
}
}
// Fix ownership for all restored files and directories
// Home directory should be root:username with 750 for SFTP chroot security
$userInfo = posix_getpwnam($username);
if ($userInfo) {
$uid = $userInfo['uid'];
$gid = $userInfo['gid'];
// Home dir: root:user 750 (required for SFTP chroot)
chown($homeDir, "root");
chgrp($homeDir, $username);
chmod($homeDir, 0750);
// Create and fix ownership of directories inside home
$dirsToFix = ['domains', 'mail', 'logs', 'backups', 'tmp', 'metadata', '.ssh', 'mysql', 'zones', 'ssl'];
foreach ($dirsToFix as $subdir) {
$subdirPath = "$homeDir/$subdir";
if (!is_dir($subdirPath)) {
mkdir($subdirPath, 0755, true);
}
chown($subdirPath, $username);
chgrp($subdirPath, $username);
if (is_dir($subdirPath)) {
exec("chown -R " . escapeshellarg($username) . ":" . escapeshellarg($username) . " " . escapeshellarg($subdirPath) . " 2>&1");
}
}
// Fix ownership of registry files
$filesToFix = ['.wordpress_sites', '.domains', '.redis_credentials'];
foreach ($filesToFix as $file) {
$filePath = "$homeDir/$file";
if (file_exists($filePath)) {
chown($filePath, $username);
chgrp($filePath, $username);
}
}
// Set proper ACLs for nginx access
exec("setfacl -m u:www-data:x " . escapeshellarg($homeDir) . " 2>/dev/null");
if (is_dir("$homeDir/domains")) {
exec("setfacl -m u:www-data:x " . escapeshellarg("$homeDir/domains") . " 2>/dev/null");
exec("setfacl -R -m u:www-data:rx " . escapeshellarg("$homeDir/domains") . " 2>/dev/null");
exec("setfacl -R -d -m u:www-data:rx " . escapeshellarg("$homeDir/domains") . " 2>/dev/null");
}
logger("Fixed ownership for restored user: $username");
}
// Cleanup temp directory
exec("rm -rf " . escapeshellarg($tempDir));
// Log successful restore summary
$summary = [];
if (!empty($restored['files'])) $summary[] = count($restored['files']) . ' domain(s)';
if (!empty($restored['databases'])) $summary[] = count($restored['databases']) . ' database(s)';
if (!empty($restored['mailboxes'])) $summary[] = count($restored['mailboxes']) . ' mailbox(es)';
if (!empty($restored['ssl_certificates'])) $summary[] = count($restored['ssl_certificates']) . ' SSL cert(s)';
if (!empty($restored['dns_zones'])) $summary[] = count($restored['dns_zones']) . ' DNS zone(s)';
if ($restored['mysql_users'] ?? false) $summary[] = 'MySQL users';
if (!empty($summary)) {
logger("Restore completed for user $username: " . implode(', ', $summary));
} else {
logger("Restore completed for user $username: nothing was restored");
}
return [
'success' => true,
'restored' => $restored,
'files_count' => count($restored['files']),
'databases_count' => count($restored['databases']),
'mailboxes_count' => count($restored['mailboxes']),
'ssl_certificates_count' => count($restored['ssl_certificates']),
'dns_zones_count' => count($restored['dns_zones'] ?? []),
];
} catch (Exception $e) {
exec("rm -rf " . escapeshellarg($tempDir));
return ['success' => false, 'error' => $e->getMessage()];
}
}
/**
* List backups for a user.
*/
function backupList(array $params): array
{
$username = $params['username'] ?? '';
$path = $params['path'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$backupDir = !empty($path) ? $path : "/home/$username/backups";
if (!is_dir($backupDir)) {
return ['success' => true, 'backups' => []];
}
$backups = [];
foreach (scandir($backupDir) as $item) {
if ($item === '.' || $item === '..') continue;
$itemPath = "$backupDir/$item";
// Check for backup files or directories
if (is_file($itemPath) && preg_match('/\.(tar\.gz|tgz)$/i', $item)) {
$manifest = null;
// Try to read manifest from archive
$output = [];
exec("tar -I pigz -tf " . escapeshellarg($itemPath) . " 2>/dev/null | grep manifest.json | head -1", $output);
if (!empty($output)) {
$manifestContent = [];
exec("tar -I pigz -xf " . escapeshellarg($itemPath) . " -O " . escapeshellarg(trim($output[0])) . " 2>/dev/null", $manifestContent);
if (!empty($manifestContent)) {
$manifest = json_decode(implode("\n", $manifestContent), true);
}
}
$backups[] = [
'name' => $item,
'path' => $itemPath,
'type' => 'full',
'size' => filesize($itemPath),
'created_at' => date('c', filemtime($itemPath)),
'manifest' => $manifest,
];
} elseif (is_dir($itemPath) && file_exists("$itemPath/manifest.json")) {
$manifest = json_decode(file_get_contents("$itemPath/manifest.json"), true);
// Calculate directory size
$size = 0;
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($itemPath));
foreach ($iterator as $file) {
if ($file->isFile()) {
$size += $file->getSize();
}
}
$backups[] = [
'name' => $item,
'path' => $itemPath,
'type' => 'incremental',
'size' => $size,
'created_at' => $manifest['created_at'] ?? date('c', filemtime($itemPath)),
'manifest' => $manifest,
];
}
}
// Sort by creation date, newest first
usort($backups, fn($a, $b) => strtotime($b['created_at']) - strtotime($a['created_at']));
return ['success' => true, 'backups' => $backups];
}
/**
* Delete a backup file or directory.
*/
function backupDelete(array $params): array
{
$username = $params['username'] ?? '';
$backupPath = $params['backup_path'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
if (empty($backupPath)) {
return ['success' => false, 'error' => 'Backup path is required'];
}
// Security: Ensure path is within user's backup directory
$homeDir = "/home/$username";
$realPath = realpath($backupPath);
if ($realPath === false || strpos($realPath, $homeDir) !== 0) {
return ['success' => false, 'error' => 'Invalid backup path'];
}
if (!file_exists($backupPath)) {
return ['success' => false, 'error' => 'Backup not found'];
}
if (is_file($backupPath)) {
unlink($backupPath);
} else {
exec("rm -rf " . escapeshellarg($backupPath));
}
return ['success' => true, 'message' => 'Backup deleted'];
}
/**
* Delete a server backup file.
*/
function backupDeleteServer(array $params): array
{
$backupPath = $params['backup_path'] ?? '';
if (empty($backupPath)) {
return ['success' => false, 'error' => 'Backup path is required'];
}
// Security: Ensure path is within server backup directory
$allowedDirs = ['/var/backups/jabali'];
$realPath = realpath($backupPath);
if ($realPath === false) {
return ['success' => false, 'error' => 'Backup not found'];
}
$allowed = false;
foreach ($allowedDirs as $dir) {
if (strpos($realPath, $dir) === 0) {
$allowed = true;
break;
}
}
if (!$allowed) {
return ['success' => false, 'error' => 'Invalid backup path - must be in /var/backups/jabali/'];
}
if (!file_exists($backupPath)) {
return ['success' => false, 'error' => 'Backup not found'];
}
if (is_file($backupPath)) {
unlink($backupPath);
} else {
exec("rm -rf " . escapeshellarg($backupPath));
}
return ['success' => true, 'message' => 'Server backup deleted'];
}
/**
* Verify backup integrity.
*/
function backupVerify(array $params): array
{
$backupPath = $params['backup_path'] ?? '';
if (empty($backupPath) || !file_exists($backupPath)) {
return ['success' => false, 'error' => 'Backup not found'];
}
$issues = [];
if (is_file($backupPath)) {
// Verify tar.gz archive
exec("tar -I pigz -tf " . escapeshellarg($backupPath) . " > /dev/null 2>&1", $output, $retval);
if ($retval !== 0) {
$issues[] = 'Archive is corrupted or invalid';
}
// Check for manifest
$output = [];
exec("tar -I pigz -tf " . escapeshellarg($backupPath) . " 2>/dev/null | grep manifest.json", $output);
if (empty($output)) {
$issues[] = 'Manifest file not found in archive';
}
} elseif (is_dir($backupPath)) {
if (!file_exists("$backupPath/manifest.json")) {
$issues[] = 'Manifest file not found';
}
}
return [
'success' => empty($issues),
'valid' => empty($issues),
'issues' => $issues,
'checksum' => is_file($backupPath) ? hash_file('sha256', $backupPath) : null,
];
}
/**
* Get backup info/manifest.
*/
function backupGetInfo(array $params): array
{
$backupPath = $params['backup_path'] ?? '';
if (empty($backupPath) || !file_exists($backupPath)) {
return ['success' => false, 'error' => 'Backup not found'];
}
$manifest = null;
$size = 0;
if (is_file($backupPath)) {
$size = filesize($backupPath);
// Extract manifest from archive
$output = [];
exec("tar -I pigz -tf " . escapeshellarg($backupPath) . " 2>/dev/null | grep manifest.json | head -1", $output);
if (!empty($output)) {
$manifestContent = [];
exec("tar -I pigz -xf " . escapeshellarg($backupPath) . " -O " . escapeshellarg(trim($output[0])) . " 2>/dev/null", $manifestContent);
if (!empty($manifestContent)) {
$manifest = json_decode(implode("\n", $manifestContent), true);
}
}
} elseif (is_dir($backupPath)) {
if (file_exists("$backupPath/manifest.json")) {
$manifest = json_decode(file_get_contents("$backupPath/manifest.json"), true);
}
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($backupPath));
foreach ($iterator as $file) {
if ($file->isFile()) {
$size += $file->getSize();
}
}
}
return [
'success' => true,
'path' => $backupPath,
'size' => $size,
'manifest' => $manifest,
'type' => is_file($backupPath) ? 'full' : 'incremental',
'modified_at' => date('c', filemtime($backupPath)),
];
}
/**
* Upload backup to remote destination (SFTP, NFS, S3).
* For incremental backups with SFTP/NFS, uses rsync with --link-dest for dirvish-style snapshots.
*/
function backupUploadRemote(array $params): array
{
$localPath = $params['local_path'] ?? '';
$destination = $params['destination'] ?? [];
$backupType = $params['backup_type'] ?? 'full';
if (empty($localPath) || !file_exists($localPath)) {
return ['success' => false, 'error' => 'Local backup file not found'];
}
$type = $destination['type'] ?? '';
$remotePath = $destination['path'] ?? '';
$filename = basename($localPath);
// For incremental backups with SFTP or NFS, use rsync with --link-dest
if ($backupType === 'incremental' && in_array($type, ['sftp', 'nfs'])) {
return syncViaRsyncIncremental($localPath, $destination);
}
switch ($type) {
case 'sftp':
return uploadViaSftp($localPath, $destination);
case 'nfs':
return uploadViaNfs($localPath, $destination);
case 's3':
return uploadViaS3($localPath, $destination);
default:
return ['success' => false, 'error' => 'Unsupported destination type: ' . $type];
}
}
/**
* Upload via SFTP.
*/
function uploadViaSftp(string $localPath, array $destination): array
{
$host = $destination['host'] ?? '';
$port = $destination['port'] ?? 22;
$username = $destination['username'] ?? '';
$password = $destination['password'] ?? '';
$privateKey = $destination['private_key'] ?? '';
$remotePath = rtrim($destination['path'] ?? '/backups', '/');
$localName = basename($localPath);
if (empty($host) || empty($username)) {
return ['success' => false, 'error' => 'SFTP host and username required'];
}
$isDir = is_dir($localPath);
$remoteFullPath = "$remotePath/$localName";
// Build sftp commands
$sftpCommands = "mkdir $remotePath\n";
if ($isDir) {
// Upload folder: create remote folder and upload all files
$sftpCommands .= "mkdir $remoteFullPath\n";
$files = scandir($localPath);
foreach ($files as $file) {
if ($file === '.' || $file === '..') continue;
$filePath = "$localPath/$file";
if (is_file($filePath)) {
$sftpCommands .= "put " . escapeshellarg($filePath) . " " . escapeshellarg("$remoteFullPath/$file") . "\n";
}
}
} else {
// Upload single file
$sftpCommands .= "put " . escapeshellarg($localPath) . " " . escapeshellarg($remoteFullPath) . "\n";
}
if (!empty($privateKey)) {
$keyFile = tempnam(sys_get_temp_dir(), 'sftp_key_');
file_put_contents($keyFile, $privateKey);
chmod($keyFile, 0600);
$cmd = "sftp -o StrictHostKeyChecking=no -o Port=$port -i " . escapeshellarg($keyFile) .
" " . escapeshellarg("$username@$host") . " 2>&1 <&1 < false, 'error' => 'SFTP upload failed: ' . implode("\n", $output)];
}
return [
'success' => true,
'remote_path' => $remoteFullPath,
'message' => $isDir ? 'Backup folder uploaded via SFTP' : 'Backup uploaded via SFTP',
];
}
/**
* Dirvish-style incremental backup using rsync with --link-dest.
*
* Structure: /remote_path/YYYY-MM-DD_HHMMSS/
* Uses hard links from previous backup to save space for unchanged files.
*
* For SFTP: Uses rsync over SSH
* For NFS: Uses rsync locally to mounted share
*/
function syncViaRsyncIncremental(string $localPath, array $destination): array
{
$type = $destination['type'] ?? 'sftp';
$host = $destination['host'] ?? '';
$port = $destination['port'] ?? 22;
$username = $destination['username'] ?? '';
$password = $destination['password'] ?? '';
$privateKey = $destination['private_key'] ?? '';
$remotePath = rtrim($destination['path'] ?? '/backups', '/');
// Create dated backup folder name
$timestamp = date('Y-m-d_His');
$backupFolder = "$remotePath/$timestamp";
if ($type === 'sftp') {
if (empty($host) || empty($username)) {
return ['success' => false, 'error' => 'SFTP host and username required for rsync'];
}
// SSH options
$sshOpts = "-o StrictHostKeyChecking=no -o Port=$port";
$keyFile = null;
if (!empty($privateKey)) {
$keyFile = tempnam(sys_get_temp_dir(), 'rsync_key_');
file_put_contents($keyFile, $privateKey);
chmod($keyFile, 0600);
$sshOpts .= " -i " . escapeshellarg($keyFile);
}
// Find previous backup folder for --link-dest via SSH
$findPrevCmd = "ssh $sshOpts ";
if (!empty($password)) {
$findPrevCmd = "sshpass -p " . escapeshellarg($password) . " $findPrevCmd";
}
$findPrevCmd .= escapeshellarg("$username@$host") . " 'ls -1d " . escapeshellarg($remotePath) . "/????-??-??_?????? 2>/dev/null | sort -r | head -1'";
$previousBackup = trim(shell_exec($findPrevCmd) ?? '');
// rsync options
$rsyncOpts = "-avz --delete";
// Add --link-dest if previous backup exists
if (!empty($previousBackup) && $previousBackup !== $backupFolder) {
$rsyncOpts .= " --link-dest=" . escapeshellarg($previousBackup);
}
// Source path (trailing slash to copy contents)
$source = is_dir($localPath) ? rtrim($localPath, '/') . '/' : $localPath;
// Build rsync command
if (!empty($password)) {
$cmd = "sshpass -p " . escapeshellarg($password) .
" rsync $rsyncOpts -e 'ssh $sshOpts' " .
escapeshellarg($source) . " " .
escapeshellarg("$username@$host:$backupFolder/") . " 2>&1";
} else {
$cmd = "rsync $rsyncOpts -e 'ssh $sshOpts' " .
escapeshellarg($source) . " " .
escapeshellarg("$username@$host:$backupFolder/") . " 2>&1";
}
exec($cmd, $output, $retval);
// Cleanup key file
if ($keyFile && file_exists($keyFile)) {
unlink($keyFile);
}
} else if ($type === 'nfs') {
// NFS: Mount first, then rsync locally
$server = $destination['server'] ?? '';
$share = $destination['share'] ?? '';
$mountPoint = $destination['mount_point'] ?? '/mnt/backup_nfs';
if (empty($server) || empty($share)) {
return ['success' => false, 'error' => 'NFS server and share required'];
}
// Ensure mount point exists and is mounted
if (!is_dir($mountPoint)) {
mkdir($mountPoint, 0755, true);
}
exec("mount | grep " . escapeshellarg($mountPoint), $mountOutput, $mountCheck);
if ($mountCheck !== 0) {
exec("mount -t nfs " . escapeshellarg("$server:$share") . " " . escapeshellarg($mountPoint) . " 2>&1", $mountOutput, $mountRet);
if ($mountRet !== 0) {
return ['success' => false, 'error' => 'Failed to mount NFS share: ' . implode("\n", $mountOutput)];
}
}
$nfsBackupPath = rtrim($mountPoint, '/') . '/' . ltrim($remotePath, '/');
$localBackupFolder = "$nfsBackupPath/$timestamp";
// Ensure remote path exists
if (!is_dir($nfsBackupPath)) {
mkdir($nfsBackupPath, 0755, true);
}
// Find previous backup
$prevBackups = glob("$nfsBackupPath/????-??-??_??????");
rsort($prevBackups);
$previousBackup = !empty($prevBackups) ? $prevBackups[0] : '';
// rsync options
$rsyncOpts = "-av --delete";
if (!empty($previousBackup) && $previousBackup !== $localBackupFolder) {
$rsyncOpts .= " --link-dest=" . escapeshellarg($previousBackup);
}
$source = is_dir($localPath) ? rtrim($localPath, '/') . '/' : $localPath;
$cmd = "rsync $rsyncOpts " . escapeshellarg($source) . " " . escapeshellarg("$localBackupFolder/") . " 2>&1";
exec($cmd, $output, $retval);
$backupFolder = "$remotePath/$timestamp";
} else {
return ['success' => false, 'error' => 'Rsync incremental only supported for SFTP and NFS'];
}
if ($retval !== 0) {
return ['success' => false, 'error' => 'Rsync incremental backup failed: ' . implode("\n", $output)];
}
return [
'success' => true,
'remote_path' => $backupFolder,
'message' => 'Incremental backup completed using rsync with hard links',
'type' => 'incremental',
'previous_backup' => $previousBackup ?? null,
];
}
/**
* Dirvish-style server backup - rsync directly from user files to remote.
* Creates timestamped folders with hard links to previous backup.
*
* Structure on remote:
* /backups/2024-01-12_103000/
* user1/
* domains/
* databases/
* mailboxes/
* user2/
* ...
* /backups/2024-01-13_103000/
* user1/ <- unchanged files are hard-linked to previous
* ...
*/
function backupServerIncrementalDirect(array $params): array
{
$destination = $params['destination'] ?? [];
$users = $params['users'] ?? null; // null = all users
$includeFiles = $params['include_files'] ?? true;
$includeDatabases = $params['include_databases'] ?? true;
$includeMailboxes = $params['include_mailboxes'] ?? true;
$includeDns = $params['include_dns'] ?? true;
logger("backupServerIncrementalDirect started");
logger("Destination config: " . json_encode($destination));
logger("Users param: " . json_encode($users));
logger("Include files: " . ($includeFiles ? 'yes' : 'no'));
$type = $destination['type'] ?? '';
if (!in_array($type, ['sftp', 'nfs'])) {
return ['success' => false, 'error' => 'Incremental backups only supported for SFTP and NFS destinations'];
}
$host = $destination['host'] ?? '';
$port = $destination['port'] ?? 22;
$username = $destination['username'] ?? '';
$password = $destination['password'] ?? '';
$privateKey = $destination['private_key'] ?? '';
$remotePath = rtrim($destination['path'] ?? '/backups', '/');
// Create timestamped backup folder
$timestamp = date('Y-m-d_His');
$backupFolder = "$remotePath/$timestamp";
// Get list of users to backup
if ($users === null) {
// Get all non-system users (UID >= 1000)
$users = [];
foreach (scandir('/home') as $dir) {
if ($dir === '.' || $dir === '..') continue;
$userInfo = posix_getpwnam($dir);
if ($userInfo && $userInfo['uid'] >= 1000) {
$users[] = $dir;
}
}
}
if (empty($users)) {
return ['success' => false, 'error' => 'No users to backup'];
}
$keyFile = null;
$sshOpts = "-o StrictHostKeyChecking=no";
if ($type === 'sftp') {
if (empty($host) || empty($username)) {
return ['success' => false, 'error' => 'SFTP host and username required'];
}
$sshOpts .= " -o Port=$port";
if (!empty($privateKey)) {
$keyFile = tempnam(sys_get_temp_dir(), 'rsync_key_');
file_put_contents($keyFile, $privateKey);
chmod($keyFile, 0600);
$sshOpts .= " -i " . escapeshellarg($keyFile);
$sshOpts .= " -o BatchMode=yes"; // Only use BatchMode with key auth
} elseif (!empty($password)) {
// For password auth, disable pubkey and enable password explicitly
$sshOpts .= " -o PreferredAuthentications=password -o PubkeyAuthentication=no";
}
// Find previous backup folder via SSH for --link-dest
$findPrevCmd = "ssh $sshOpts ";
if (!empty($password) && empty($privateKey)) {
$findPrevCmd = "sshpass -p " . escapeshellarg($password) . " $findPrevCmd";
}
$findPrevCmd .= escapeshellarg("$username@$host") . " 'ls -1d " . escapeshellarg($remotePath) . "/????-??-??_?????? 2>/dev/null | sort -r | head -1'";
$previousBackup = trim(shell_exec($findPrevCmd) ?? '');
// Log whether this is initialization or incremental
if (empty($previousBackup)) {
logger("INITIALIZATION: Creating first full backup image at $backupFolder");
$isInitialization = true;
} else {
logger("INCREMENTAL: Using hard links from previous backup $previousBackup");
$isInitialization = false;
}
// Create remote backup directory
$mkdirCmd = "ssh $sshOpts ";
if (!empty($password) && empty($privateKey)) {
$mkdirCmd = "sshpass -p " . escapeshellarg($password) . " $mkdirCmd";
}
$mkdirCmd .= escapeshellarg("$username@$host") . " 'mkdir -p " . escapeshellarg($backupFolder) . "'";
exec($mkdirCmd);
}
$backedUpUsers = [];
$errors = [];
$totalSize = 0;
foreach ($users as $user) {
$userHome = "/home/$user";
if (!is_dir($userHome)) {
continue;
}
// Build list of paths to rsync for this user
$sourcePaths = [];
if ($includeFiles && is_dir("$userHome/domains")) {
$sourcePaths[] = "$userHome/domains";
}
// Include WordPress registry file
if ($includeFiles && file_exists("$userHome/.wordpress_sites")) {
$sourcePaths[] = "$userHome/.wordpress_sites";
logger("Including .wordpress_sites registry for $user");
}
// Include domains registry file
if ($includeFiles && file_exists("$userHome/.domains")) {
$sourcePaths[] = "$userHome/.domains";
logger("Including .domains registry for $user");
}
// Export databases to mysql/ directory
$tempDbDir = null;
if ($includeDatabases) {
$tempDbDir = "$userHome/mysql";
@mkdir($tempDbDir, 0755, true);
// Clean any old dumps
array_map('unlink', glob("$tempDbDir/*.sql.gz"));
// Get user's databases from MySQL
$dbResult = shell_exec("mysql -N -e \"SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME LIKE '{$user}_%'\" 2>/dev/null");
if ($dbResult) {
$databases = array_filter(explode("\n", trim($dbResult)));
foreach ($databases as $db) {
$dumpFile = "$tempDbDir/$db.sql.gz";
exec("mysqldump --single-transaction " . escapeshellarg($db) . " 2>/dev/null | pigz > " . escapeshellarg($dumpFile));
logger("Exported database $db to $dumpFile");
}
if (!empty($databases)) {
$sourcePaths[] = $tempDbDir;
logger("Including " . count($databases) . " database(s) for $user");
}
}
// Export MySQL users and their permissions
$mysqli = @new mysqli('localhost', 'root', '');
if (!$mysqli->connect_error) {
$usersFile = "$tempDbDir/users.sql";
$usersSql = "-- MySQL Users and Permissions Backup\n";
$usersSql .= "-- Generated: " . date('Y-m-d H:i:s') . "\n\n";
// Get all MySQL users matching the username_ pattern
$safePrefix = $mysqli->real_escape_string($user) . '\\_';
$userQuery = $mysqli->query("SELECT User, Host FROM mysql.user WHERE User LIKE '{$safePrefix}%'");
$exportedUsers = [];
if ($userQuery) {
while ($userRow = $userQuery->fetch_assoc()) {
$mysqlUser = $userRow['User'];
$mysqlHost = $userRow['Host'];
$userIdentifier = "'{$mysqli->real_escape_string($mysqlUser)}'@'{$mysqli->real_escape_string($mysqlHost)}'";
// Get CREATE USER statement
$createResult = $mysqli->query("SHOW CREATE USER $userIdentifier");
if ($createResult && $createRow = $createResult->fetch_row()) {
// Add DROP USER IF EXISTS for clean restore
$usersSql .= "DROP USER IF EXISTS $userIdentifier;\n";
$usersSql .= $createRow[0] . ";\n";
// Get GRANT statements
$grantsResult = $mysqli->query("SHOW GRANTS FOR $userIdentifier");
if ($grantsResult) {
while ($grantRow = $grantsResult->fetch_row()) {
$usersSql .= $grantRow[0] . ";\n";
}
}
$usersSql .= "\n";
$exportedUsers[] = $mysqlUser;
}
}
}
// Save users.sql if we have any users
if (!empty($exportedUsers)) {
$usersSql .= "FLUSH PRIVILEGES;\n";
file_put_contents($usersFile, $usersSql);
logger("Exported " . count($exportedUsers) . " MySQL user(s) for $user");
}
$mysqli->close();
}
}
// Export DNS zones to zones/ directory
$tempZonesDir = null;
if ($includeDns && is_dir('/etc/bind/zones')) {
$domainsFile = "$userHome/.domains";
if (file_exists($domainsFile)) {
$domains = json_decode(file_get_contents($domainsFile), true) ?: [];
$userDomains = array_keys($domains);
if (!empty($userDomains)) {
$tempZonesDir = "$userHome/zones";
@mkdir($tempZonesDir, 0755, true);
// Clean any old zone files
array_map('unlink', glob("$tempZonesDir/db.*"));
foreach ($userDomains as $domain) {
$zoneFile = "/etc/bind/zones/db.$domain";
if (file_exists($zoneFile)) {
copy($zoneFile, "$tempZonesDir/db.$domain");
logger("Copied DNS zone for $domain");
}
}
if (count(glob("$tempZonesDir/db.*")) > 0) {
$sourcePaths[] = $tempZonesDir;
logger("Including DNS zones for $user");
}
}
}
}
if ($includeMailboxes && is_dir("$userHome/mail")) {
// Include user's mail directory (contains all mailboxes)
$sourcePaths[] = "$userHome/mail";
logger("Including mail directory for $user");
}
// Include SSL certificates (both custom and Let's Encrypt)
$tempSslDir = "$userHome/ssl";
$sslIncluded = false;
// Create ssl directory if needed
if (!is_dir($tempSslDir)) {
@mkdir($tempSslDir, 0755, true);
}
// Copy Let's Encrypt certificates for user's domains
if (is_dir('/etc/letsencrypt/live')) {
$domainsFile = "$userHome/.domains";
if (file_exists($domainsFile)) {
$domains = json_decode(file_get_contents($domainsFile), true) ?: [];
foreach (array_keys($domains) as $domain) {
$certPath = "/etc/letsencrypt/live/$domain";
if (is_dir($certPath)) {
$domainSslDir = "$tempSslDir/$domain";
@mkdir($domainSslDir, 0755, true);
$certFiles = ['fullchain.pem', 'privkey.pem', 'cert.pem', 'chain.pem'];
foreach ($certFiles as $certFile) {
$srcFile = "$certPath/$certFile";
if (file_exists($srcFile)) {
$realPath = realpath($srcFile);
if ($realPath && file_exists($realPath)) {
copy($realPath, "$domainSslDir/$certFile");
}
}
}
logger("Copied SSL certificate for $domain");
$sslIncluded = true;
}
}
}
}
// Include ssl directory if it has content
if (is_dir($tempSslDir) && count(scandir($tempSslDir)) > 2) {
$sourcePaths[] = $tempSslDir;
logger("Including SSL certificates for $user");
}
// Include user mail directory if exists
if ($includeMailboxes && is_dir("$userHome/mail")) {
$sourcePaths[] = "$userHome/mail";
logger("Including local mail for $user");
}
// Export panel data (database records) to metadata/panel_data.json
$metadataDir = "$userHome/metadata";
@mkdir($metadataDir, 0755, true);
$panelData = exportUserPanelData($user);
if (!empty($panelData['domains']) || !empty($panelData['email_domains'])) {
file_put_contents("$metadataDir/panel_data.json", json_encode($panelData, JSON_PRETTY_PRINT));
$sourcePaths[] = $metadataDir;
logger("Including panel metadata for $user");
}
if (empty($sourcePaths)) {
logger("User $user has no source paths to backup, skipping");
continue;
}
logger("User $user source paths: " . json_encode($sourcePaths));
// Build rsync command with exclusions
$excludes = [
'backups',
'*.log',
'cache',
'.cache',
'tmp',
'.tmp',
'node_modules',
];
$excludeArgs = implode(' ', array_map(fn($e) => "--exclude=" . escapeshellarg($e), $excludes));
$rsyncOpts = "-avz --relative --delete $excludeArgs";
// Add --link-dest for incremental
if (!empty($previousBackup)) {
$rsyncOpts .= " --link-dest=" . escapeshellarg("$previousBackup/$user");
}
// Remote destination for this user
$userRemotePath = "$backupFolder/$user/";
if ($type === 'sftp') {
$sshCmd = "ssh $sshOpts";
if (!empty($password) && empty($privateKey)) {
$rsyncPrefix = "sshpass -p " . escapeshellarg($password);
} else {
$rsyncPrefix = "";
}
// Rsync all source paths
$userSyncSuccess = false;
foreach ($sourcePaths as $srcPath) {
$cmd = trim("$rsyncPrefix rsync $rsyncOpts -e '$sshCmd' " .
escapeshellarg($srcPath) . " " .
escapeshellarg("$username@$host:$userRemotePath") . " 2>&1");
logger("Executing rsync: $cmd");
$output = [];
exec($cmd, $output, $retval);
logger("Rsync result for $srcPath: retval=$retval, output=" . implode("\n", $output));
if ($retval === 0 || $retval === 24) { // 24 = some files vanished (ok)
$userSyncSuccess = true;
} else {
$errors[] = "Failed to rsync $srcPath for $user: " . implode("\n", $output);
}
}
if (!$userSyncSuccess) {
// Cleanup database exports before continuing
if ($tempDbDir && is_dir($tempDbDir)) {
array_map('unlink', glob("$tempDbDir/*.sql.gz"));
}
continue; // Don't count this user as backed up
}
} else if ($type === 'nfs') {
$server = $destination['server'] ?? '';
$share = $destination['share'] ?? '';
$mountPoint = $destination['mount_point'] ?? '/mnt/backup_nfs';
// Mount NFS if needed
if (!is_dir($mountPoint)) {
mkdir($mountPoint, 0755, true);
}
exec("mount | grep " . escapeshellarg($mountPoint), $mountOutput, $mountCheck);
if ($mountCheck !== 0) {
exec("mount -t nfs " . escapeshellarg("$server:$share") . " " . escapeshellarg($mountPoint) . " 2>&1", $mountOutput, $mountRet);
if ($mountRet !== 0) {
return ['success' => false, 'error' => 'Failed to mount NFS share'];
}
}
$localBackupPath = "$mountPoint/" . ltrim($backupFolder, '/') . "/$user/";
mkdir($localBackupPath, 0755, true);
// Find previous backup for link-dest
$prevBackups = glob("$mountPoint/" . ltrim($remotePath, '/') . "/????-??-??_??????");
rsort($prevBackups);
$previousBackup = !empty($prevBackups) ? $prevBackups[0] : '';
// Track initialization status for the overall backup
if (!isset($isInitialization)) {
$isInitialization = empty($previousBackup);
if ($isInitialization) {
logger("INITIALIZATION (NFS): Creating first full backup image at $backupFolder");
} else {
logger("INCREMENTAL (NFS): Using hard links from previous backup $previousBackup");
}
}
$rsyncOpts = "-av --relative --delete";
if (!empty($previousBackup)) {
$rsyncOpts .= " --link-dest=" . escapeshellarg("$previousBackup/$user");
}
$userSyncSuccess = false;
foreach ($sourcePaths as $srcPath) {
$cmd = "rsync $rsyncOpts " . escapeshellarg($srcPath) . " " . escapeshellarg($localBackupPath) . " 2>&1";
$output = [];
exec($cmd, $output, $retval);
if ($retval === 0 || $retval === 24) {
$userSyncSuccess = true;
} else {
$errors[] = "Failed to rsync $srcPath for $user: " . implode("\n", $output);
}
}
if (!$userSyncSuccess) {
if ($tempDbDir && is_dir($tempDbDir)) {
exec("rm -rf " . escapeshellarg($tempDbDir));
}
continue;
}
}
// Cleanup database exports after successful backup
if ($tempDbDir && is_dir($tempDbDir)) {
array_map('unlink', glob("$tempDbDir/*.sql.gz"));
}
$backedUpUsers[] = $user;
}
// Cleanup key file
if ($keyFile && file_exists($keyFile)) {
unlink($keyFile);
}
// Export DNS zones if requested
if ($includeDns && is_dir('/etc/bind/zones')) {
// TODO: rsync DNS zones to remote
}
if (empty($backedUpUsers)) {
return ['success' => false, 'error' => 'No users backed up. Errors: ' . implode('; ', $errors)];
}
// Calculate total backup size
// Note: $backupFolder already contains the full path: "$remotePath/$timestamp"
$totalSize = 0;
if ($type === 'sftp') {
// For SFTP, run du on the remote server
$sshCmd = "ssh $sshOpts " . escapeshellarg("$username@$host") . " " .
escapeshellarg("du -sb " . $backupFolder . " 2>/dev/null | cut -f1");
if (!empty($password) && empty($privateKey)) {
$sshCmd = "sshpass -p " . escapeshellarg($password) . " $sshCmd";
}
logger("Calculating size with: $sshCmd");
exec($sshCmd, $sizeOutput, $sizeRet);
logger("Size result: ret=$sizeRet, output=" . json_encode($sizeOutput));
if ($sizeRet === 0 && !empty($sizeOutput[0])) {
$totalSize = (int)$sizeOutput[0];
}
} elseif ($type === 'nfs') {
// For NFS, the backup is on the mounted filesystem
$mountPoint = $destination['mount_point'] ?? '/mnt/backup_nfs';
// $backupFolder is like "/backups/2026-01-20_123456", strip leading slash for mount path
$backupPath = "$mountPoint/" . ltrim($backupFolder, '/');
logger("Calculating NFS size at: $backupPath");
if (is_dir($backupPath)) {
exec("du -sb " . escapeshellarg($backupPath) . " 2>/dev/null | cut -f1", $sizeOutput);
$totalSize = (int)($sizeOutput[0] ?? 0);
}
}
return [
'success' => true,
'remote_path' => $backupFolder,
'users' => $backedUpUsers,
'user_count' => count($backedUpUsers),
'size' => $totalSize,
'type' => 'incremental',
'is_initialization' => $isInitialization ?? empty($previousBackup),
'previous_backup' => $previousBackup ?? null,
'errors' => $errors,
'message' => ($isInitialization ?? empty($previousBackup))
? 'Initial full backup image created'
: 'Incremental backup completed with hard links to previous backup',
];
}
/**
* Upload via NFS mount.
*/
function uploadViaNfs(string $localPath, array $destination): array
{
$server = $destination['server'] ?? '';
$share = $destination['share'] ?? '';
$mountPoint = $destination['mount_point'] ?? '/mnt/backup_nfs';
$remotePath = rtrim($destination['path'] ?? '', '/');
$filename = basename($localPath);
if (empty($server) || empty($share)) {
return ['success' => false, 'error' => 'NFS server and share required'];
}
// Create mount point if not exists
if (!is_dir($mountPoint)) {
mkdir($mountPoint, 0755, true);
}
// Check if already mounted
$mounted = false;
exec("mount | grep " . escapeshellarg($mountPoint), $output, $retval);
if ($retval !== 0) {
// Mount NFS share
exec("mount -t nfs " . escapeshellarg("$server:$share") . " " . escapeshellarg($mountPoint) . " 2>&1", $output, $retval);
if ($retval !== 0) {
return ['success' => false, 'error' => 'Failed to mount NFS share: ' . implode("\n", $output)];
}
}
// Create target directory
$targetDir = "$mountPoint/$remotePath";
if (!is_dir($targetDir)) {
mkdir($targetDir, 0755, true);
}
// Copy file
$targetPath = "$targetDir/$filename";
if (!copy($localPath, $targetPath)) {
return ['success' => false, 'error' => 'Failed to copy file to NFS share'];
}
return [
'success' => true,
'remote_path' => "$remotePath/$filename",
'message' => 'Backup uploaded via NFS',
];
}
/**
* Upload via S3-compatible storage.
*/
function uploadViaS3(string $localPath, array $destination): array
{
$endpoint = $destination['endpoint'] ?? '';
$bucket = $destination['bucket'] ?? '';
$accessKey = $destination['access_key'] ?? '';
$secretKey = $destination['secret_key'] ?? '';
$region = $destination['region'] ?? 'us-east-1';
$remotePath = rtrim($destination['path'] ?? '', '/');
$filename = basename($localPath);
if (empty($bucket) || empty($accessKey) || empty($secretKey)) {
return ['success' => false, 'error' => 'S3 bucket and credentials required'];
}
// Use aws cli or s3cmd
$s3Path = "s3://$bucket/$remotePath/$filename";
$envVars = "AWS_ACCESS_KEY_ID=" . escapeshellarg($accessKey) . " " .
"AWS_SECRET_ACCESS_KEY=" . escapeshellarg($secretKey);
$endpointArg = !empty($endpoint) ? "--endpoint-url " . escapeshellarg($endpoint) : '';
$regionArg = "--region " . escapeshellarg($region);
$cmd = "$envVars aws s3 cp " . escapeshellarg($localPath) . " " .
escapeshellarg($s3Path) . " $endpointArg $regionArg 2>&1";
exec($cmd, $output, $retval);
if ($retval !== 0) {
return ['success' => false, 'error' => 'S3 upload failed: ' . implode("\n", $output)];
}
return [
'success' => true,
'remote_path' => "$remotePath/$filename",
'bucket' => $bucket,
'message' => 'Backup uploaded to S3',
];
}
/**
* Download backup from remote destination.
*/
function backupDownloadRemote(array $params): array
{
$remotePath = $params['remote_path'] ?? '';
$localPath = $params['local_path'] ?? '';
$destination = $params['destination'] ?? [];
if (empty($remotePath) || empty($localPath)) {
return ['success' => false, 'error' => 'Remote and local paths required'];
}
$type = $destination['type'] ?? '';
switch ($type) {
case 'sftp':
return downloadViaSftp($remotePath, $localPath, $destination);
case 'nfs':
return downloadViaNfs($remotePath, $localPath, $destination);
case 's3':
return downloadViaS3($remotePath, $localPath, $destination);
default:
return ['success' => false, 'error' => 'Unsupported destination type: ' . $type];
}
}
/**
* Download via SFTP using rsync (handles both files and directories).
*/
function downloadViaSftp(string $remotePath, string $localPath, array $destination): array
{
$host = $destination['host'] ?? '';
$port = $destination['port'] ?? 22;
$username = $destination['username'] ?? '';
$password = $destination['password'] ?? '';
$privateKey = $destination['private_key'] ?? '';
$basePath = rtrim($destination['path'] ?? '', '/');
// Check if remotePath is already an absolute path (starts with basePath or /)
// This handles backup records that store full paths like /home/jabali/backups/2026-01-19_210219
if (str_starts_with($remotePath, $basePath . '/') || (str_starts_with($remotePath, '/') && !empty($basePath) && str_starts_with($remotePath, $basePath))) {
$fullRemotePath = $remotePath;
} elseif (str_starts_with($remotePath, '/') && empty($basePath)) {
$fullRemotePath = $remotePath;
} else {
$fullRemotePath = "$basePath/$remotePath";
}
// Ensure local directory exists
if (!is_dir($localPath)) {
mkdir($localPath, 0755, true);
}
// Use rsync for downloading - handles both files and directories
// Add trailing slash to remote to copy contents into local dir
$fullRemotePath = rtrim($fullRemotePath, '/') . '/';
if (!empty($privateKey)) {
$keyFile = tempnam(sys_get_temp_dir(), 'sftp_key_');
file_put_contents($keyFile, $privateKey);
chmod($keyFile, 0600);
$sshCmd = "ssh -o StrictHostKeyChecking=no -o Port=$port -i " . escapeshellarg($keyFile);
$cmd = "rsync -avz -e " . escapeshellarg($sshCmd) . " " .
escapeshellarg("$username@$host:$fullRemotePath") . " " .
escapeshellarg(rtrim($localPath, '/') . '/') . " 2>&1";
logger("Executing rsync download: $cmd");
exec($cmd, $output, $retval);
unlink($keyFile);
} else {
$sshCmd = "ssh -o StrictHostKeyChecking=no -o Port=$port -o PreferredAuthentications=password -o PubkeyAuthentication=no";
$cmd = "sshpass -p " . escapeshellarg($password) .
" rsync -avz -e " . escapeshellarg($sshCmd) . " " .
escapeshellarg("$username@$host:$fullRemotePath") . " " .
escapeshellarg(rtrim($localPath, '/') . '/') . " 2>&1";
logger("Executing rsync download: $cmd");
exec($cmd, $output, $retval);
}
logger("Rsync download result: retval=$retval, lines=" . count($output));
if ($retval !== 0) {
return ['success' => false, 'error' => 'SFTP/rsync download failed: ' . implode("\n", $output)];
}
// Check if anything was downloaded
$files = glob(rtrim($localPath, '/') . '/*');
if (empty($files)) {
return ['success' => false, 'error' => 'Download completed but no files found'];
}
return ['success' => true, 'local_path' => $localPath];
}
/**
* Download via NFS (handles both files and directories).
*/
function downloadViaNfs(string $remotePath, string $localPath, array $destination): array
{
$server = $destination['server'] ?? '';
$share = $destination['share'] ?? '';
$mountPoint = $destination['mount_point'] ?? '/mnt/backup_nfs';
$basePath = rtrim($destination['path'] ?? '', '/');
// Mount if needed
if (!is_dir($mountPoint)) {
mkdir($mountPoint, 0755, true);
}
exec("mount | grep " . escapeshellarg($mountPoint), $output, $retval);
if ($retval !== 0) {
exec("mount -t nfs " . escapeshellarg("$server:$share") . " " . escapeshellarg($mountPoint) . " 2>&1", $output, $retval);
if ($retval !== 0) {
return ['success' => false, 'error' => 'Failed to mount NFS share'];
}
}
// Check if remotePath is already an absolute path (starts with basePath)
if (str_starts_with($remotePath, $basePath . '/') || (str_starts_with($remotePath, '/') && !empty($basePath) && str_starts_with($remotePath, $basePath))) {
$sourcePath = "$mountPoint$remotePath";
} elseif (str_starts_with($remotePath, '/') && empty($basePath)) {
$sourcePath = "$mountPoint$remotePath";
} else {
$sourcePath = "$mountPoint/$basePath/$remotePath";
}
if (!file_exists($sourcePath)) {
return ['success' => false, 'error' => 'Remote path not found: ' . $sourcePath];
}
// Ensure local directory exists
if (!is_dir($localPath)) {
mkdir($localPath, 0755, true);
}
// Use cp -a to preserve attributes and handle both files and directories
if (is_dir($sourcePath)) {
// Copy directory contents
$cmd = "cp -a " . escapeshellarg(rtrim($sourcePath, '/') . '/.') . " " . escapeshellarg(rtrim($localPath, '/') . '/') . " 2>&1";
} else {
// Copy single file
$cmd = "cp -a " . escapeshellarg($sourcePath) . " " . escapeshellarg($localPath . '/' . basename($sourcePath)) . " 2>&1";
}
exec($cmd, $output, $retval);
if ($retval !== 0) {
return ['success' => false, 'error' => 'Failed to copy from NFS share: ' . implode("\n", $output)];
}
// Check if anything was copied
$files = glob(rtrim($localPath, '/') . '/*');
if (empty($files)) {
return ['success' => false, 'error' => 'Copy completed but no files found'];
}
return ['success' => true, 'local_path' => $localPath];
}
/**
* Download via S3.
*/
function downloadViaS3(string $remotePath, string $localPath, array $destination): array
{
$endpoint = $destination['endpoint'] ?? '';
$bucket = $destination['bucket'] ?? '';
$accessKey = $destination['access_key'] ?? '';
$secretKey = $destination['secret_key'] ?? '';
$region = $destination['region'] ?? 'us-east-1';
$basePath = rtrim($destination['path'] ?? '', '/');
$s3Path = "s3://$bucket/$basePath/$remotePath";
$localDir = dirname($localPath);
if (!is_dir($localDir)) {
mkdir($localDir, 0755, true);
}
$envVars = "AWS_ACCESS_KEY_ID=" . escapeshellarg($accessKey) . " " .
"AWS_SECRET_ACCESS_KEY=" . escapeshellarg($secretKey);
$endpointArg = !empty($endpoint) ? "--endpoint-url " . escapeshellarg($endpoint) : '';
$regionArg = "--region " . escapeshellarg($region);
$cmd = "$envVars aws s3 cp " . escapeshellarg($s3Path) . " " .
escapeshellarg($localPath) . " $endpointArg $regionArg 2>&1";
exec($cmd, $output, $retval);
if ($retval !== 0 || !file_exists($localPath)) {
return ['success' => false, 'error' => 'S3 download failed: ' . implode("\n", $output)];
}
return ['success' => true, 'local_path' => $localPath];
}
/**
* List backups on remote destination.
*/
function backupListRemote(array $params): array
{
$destination = $params['destination'] ?? [];
$path = $params['path'] ?? '';
$type = $destination['type'] ?? '';
switch ($type) {
case 'sftp':
return listRemoteSftp($destination, $path);
case 'nfs':
return listRemoteNfs($destination, $path);
case 's3':
return listRemoteS3($destination, $path);
default:
return ['success' => false, 'error' => 'Unsupported destination type'];
}
}
/**
* List files via SFTP.
*/
function listRemoteSftp(array $destination, string $path): array
{
$host = $destination['host'] ?? '';
$port = $destination['port'] ?? 22;
$username = $destination['username'] ?? '';
$password = $destination['password'] ?? '';
$privateKey = $destination['private_key'] ?? '';
$basePath = rtrim($destination['path'] ?? '', '/');
$fullPath = $path ? "$basePath/$path" : $basePath;
if (!empty($privateKey)) {
$keyFile = tempnam(sys_get_temp_dir(), 'sftp_key_');
file_put_contents($keyFile, $privateKey);
chmod($keyFile, 0600);
$cmd = "sftp -o StrictHostKeyChecking=no -o Port=$port -i " . escapeshellarg($keyFile) .
" " . escapeshellarg("$username@$host") . " 2>&1 <&1 < $filename,
'path' => "$fullPath/$filename",
'is_directory' => $line[0] === 'd',
];
}
}
}
return ['success' => true, 'files' => $files];
}
/**
* List files via NFS.
*/
function listRemoteNfs(array $destination, string $path): array
{
$server = $destination['server'] ?? '';
$share = $destination['share'] ?? '';
$mountPoint = $destination['mount_point'] ?? '/mnt/backup_nfs';
$basePath = rtrim($destination['path'] ?? '', '/');
// Mount if needed
if (!is_dir($mountPoint)) {
mkdir($mountPoint, 0755, true);
}
exec("mount | grep " . escapeshellarg($mountPoint), $output, $retval);
if ($retval !== 0) {
exec("mount -t nfs " . escapeshellarg("$server:$share") . " " . escapeshellarg($mountPoint) . " 2>&1", $output, $retval);
if ($retval !== 0) {
return ['success' => false, 'error' => 'Failed to mount NFS share'];
}
}
$fullPath = $path ? "$mountPoint/$basePath/$path" : "$mountPoint/$basePath";
if (!is_dir($fullPath)) {
return ['success' => true, 'files' => []];
}
$files = [];
foreach (scandir($fullPath) as $item) {
if ($item === '.' || $item === '..') continue;
$itemPath = "$fullPath/$item";
$files[] = [
'name' => $item,
'path' => ($path ? "$path/" : '') . $item,
'is_directory' => is_dir($itemPath),
'size' => is_file($itemPath) ? filesize($itemPath) : null,
'modified_at' => date('c', filemtime($itemPath)),
];
}
return ['success' => true, 'files' => $files];
}
/**
* List files via S3.
*/
function listRemoteS3(array $destination, string $path): array
{
$endpoint = $destination['endpoint'] ?? '';
$bucket = $destination['bucket'] ?? '';
$accessKey = $destination['access_key'] ?? '';
$secretKey = $destination['secret_key'] ?? '';
$region = $destination['region'] ?? 'us-east-1';
$basePath = rtrim($destination['path'] ?? '', '/');
$s3Path = "s3://$bucket/" . ($basePath ? "$basePath/" : '') . ($path ? "$path/" : '');
$envVars = "AWS_ACCESS_KEY_ID=" . escapeshellarg($accessKey) . " " .
"AWS_SECRET_ACCESS_KEY=" . escapeshellarg($secretKey);
$endpointArg = !empty($endpoint) ? "--endpoint-url " . escapeshellarg($endpoint) : '';
$regionArg = "--region " . escapeshellarg($region);
$cmd = "$envVars aws s3 ls " . escapeshellarg($s3Path) . " $endpointArg $regionArg 2>&1";
exec($cmd, $output, $retval);
$files = [];
foreach ($output as $line) {
// Parse S3 ls output: "2024-01-15 14:30:22 12345 filename"
if (preg_match('/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})\s+(\d+)\s+(.+)$/', trim($line), $matches)) {
$files[] = [
'name' => $matches[4],
'path' => ($path ? "$path/" : '') . $matches[4],
'size' => (int)$matches[3],
'modified_at' => "{$matches[1]}T{$matches[2]}",
'is_directory' => false,
];
} elseif (preg_match('/^\s*PRE\s+(.+)\/$/', trim($line), $matches)) {
$files[] = [
'name' => $matches[1],
'path' => ($path ? "$path/" : '') . $matches[1],
'is_directory' => true,
];
}
}
return ['success' => true, 'files' => $files];
}
/**
* Delete backup from remote destination.
*/
function backupDeleteRemote(array $params): array
{
$remotePath = $params['remote_path'] ?? '';
$destination = $params['destination'] ?? [];
if (empty($remotePath)) {
return ['success' => false, 'error' => 'Remote path required'];
}
$type = $destination['type'] ?? '';
switch ($type) {
case 'sftp':
return deleteRemoteSftp($remotePath, $destination);
case 'nfs':
return deleteRemoteNfs($remotePath, $destination);
case 's3':
return deleteRemoteS3($remotePath, $destination);
default:
return ['success' => false, 'error' => 'Unsupported destination type'];
}
}
/**
* Delete via SFTP (handles both files and directories).
*/
function deleteRemoteSftp(string $remotePath, array $destination): array
{
$host = $destination['host'] ?? '';
$port = $destination['port'] ?? 22;
$username = $destination['username'] ?? '';
$password = $destination['password'] ?? '';
$privateKey = $destination['private_key'] ?? '';
$basePath = rtrim($destination['path'] ?? '', '/');
// Check if remotePath is already an absolute path (starts with basePath)
if (str_starts_with($remotePath, $basePath . '/') || (str_starts_with($remotePath, '/') && !empty($basePath) && str_starts_with($remotePath, $basePath))) {
$fullPath = $remotePath;
} elseif (str_starts_with($remotePath, '/') && empty($basePath)) {
$fullPath = $remotePath;
} else {
$fullPath = "$basePath/$remotePath";
}
$sshOpts = "-o StrictHostKeyChecking=no -o Port=$port";
if (!empty($privateKey)) {
$keyFile = tempnam(sys_get_temp_dir(), 'sftp_key_');
file_put_contents($keyFile, $privateKey);
chmod($keyFile, 0600);
$sshOpts .= " -i " . escapeshellarg($keyFile);
// Use SSH rm -rf to delete files or directories
$cmd = "ssh $sshOpts " . escapeshellarg("$username@$host") .
" 'rm -rf " . escapeshellarg($fullPath) . "' 2>&1";
exec($cmd, $output, $retval);
unlink($keyFile);
} else {
$sshOpts .= " -o PreferredAuthentications=password -o PubkeyAuthentication=no";
$cmd = "sshpass -p " . escapeshellarg($password) .
" ssh $sshOpts " . escapeshellarg("$username@$host") .
" 'rm -rf " . escapeshellarg($fullPath) . "' 2>&1";
exec($cmd, $output, $retval);
}
logger("Delete remote SFTP: $fullPath, retval=$retval");
return ['success' => $retval === 0, 'message' => $retval === 0 ? 'Deleted successfully' : 'Delete failed: ' . implode("\n", $output)];
}
/**
* Delete via NFS.
*/
function deleteRemoteNfs(string $remotePath, array $destination): array
{
$server = $destination['server'] ?? '';
$share = $destination['share'] ?? '';
$mountPoint = $destination['mount_point'] ?? '/mnt/backup_nfs';
$basePath = rtrim($destination['path'] ?? '', '/');
// Mount if needed
exec("mount | grep " . escapeshellarg($mountPoint), $output, $retval);
if ($retval !== 0) {
exec("mount -t nfs " . escapeshellarg("$server:$share") . " " . escapeshellarg($mountPoint) . " 2>&1", $output, $retval);
if ($retval !== 0) {
return ['success' => false, 'error' => 'Failed to mount NFS share'];
}
}
// Check if remotePath is already an absolute path (starts with basePath)
if (str_starts_with($remotePath, $basePath . '/') || (str_starts_with($remotePath, '/') && !empty($basePath) && str_starts_with($remotePath, $basePath))) {
$fullPath = "$mountPoint$remotePath";
} elseif (str_starts_with($remotePath, '/') && empty($basePath)) {
$fullPath = "$mountPoint$remotePath";
} else {
$fullPath = "$mountPoint/$basePath/$remotePath";
}
if (!file_exists($fullPath)) {
return ['success' => false, 'error' => 'Path not found: ' . $fullPath];
}
if (is_file($fullPath)) {
unlink($fullPath);
} else {
exec("rm -rf " . escapeshellarg($fullPath));
}
logger("Delete remote NFS: $fullPath");
return ['success' => true, 'message' => 'Deleted successfully'];
}
/**
* Delete via S3.
*/
function deleteRemoteS3(string $remotePath, array $destination): array
{
$endpoint = $destination['endpoint'] ?? '';
$bucket = $destination['bucket'] ?? '';
$accessKey = $destination['access_key'] ?? '';
$secretKey = $destination['secret_key'] ?? '';
$region = $destination['region'] ?? 'us-east-1';
$basePath = rtrim($destination['path'] ?? '', '/');
$s3Path = "s3://$bucket/$basePath/$remotePath";
$envVars = "AWS_ACCESS_KEY_ID=" . escapeshellarg($accessKey) . " " .
"AWS_SECRET_ACCESS_KEY=" . escapeshellarg($secretKey);
$endpointArg = !empty($endpoint) ? "--endpoint-url " . escapeshellarg($endpoint) : '';
$regionArg = "--region " . escapeshellarg($region);
$cmd = "$envVars aws s3 rm " . escapeshellarg($s3Path) . " $endpointArg $regionArg 2>&1";
exec($cmd, $output, $retval);
return ['success' => $retval === 0, 'message' => $retval === 0 ? 'File deleted' : 'Delete failed'];
}
/**
* Test remote destination connection.
*/
/**
* Download a user's folder from a server backup and create a tar.gz archive.
* This creates a "ready to restore" backup file for the user.
*
* @param array $params
* - username: The system username
* - remote_path: Relative path like "2026-01-20_110001/user" (already includes user folder)
* - destination: Destination config with base path
* - output_path: Where to save the tar.gz
*/
function backupDownloadUserArchive(array $params): array
{
$username = $params['username'] ?? '';
$remotePath = $params['remote_path'] ?? ''; // e.g., "2026-01-20_110001/user"
$destination = $params['destination'] ?? [];
$outputPath = $params['output_path'] ?? '';
if (empty($username) || empty($remotePath) || empty($outputPath)) {
return ['success' => false, 'error' => 'Username, remote_path, and output_path required'];
}
$type = $destination['type'] ?? '';
if (!in_array($type, ['sftp', 'nfs'])) {
return ['success' => false, 'error' => 'Only SFTP and NFS destinations supported'];
}
// Create temp directory for download
$tempDir = sys_get_temp_dir() . '/jabali_download_' . uniqid();
mkdir($tempDir, 0755, true);
try {
// The remote_path is relative (e.g., "2026-01-20_110001/user")
// Combine with destination's base path to get full path
$basePath = rtrim($destination['path'] ?? '', '/');
$fullRemotePath = $basePath . '/' . ltrim($remotePath, '/');
logger("Downloading user backup: $fullRemotePath to $tempDir");
if ($type === 'sftp') {
$result = downloadUserFolderSftp($fullRemotePath, $tempDir, $destination);
} else {
$result = downloadUserFolderNfs($fullRemotePath, $tempDir, $destination);
}
if (!($result['success'] ?? false)) {
throw new Exception($result['error'] ?? 'Download failed');
}
// Check if user folder was downloaded
$userFolder = $tempDir . '/' . $username;
if (!is_dir($userFolder)) {
// Maybe it downloaded directly into tempDir
$userFolder = $tempDir;
}
// Ensure output directory exists with proper ownership
$outputDir = dirname($outputPath);
if (!is_dir($outputDir)) {
mkdir($outputDir, 0755, true);
$userInfo = posix_getpwnam($username);
if ($userInfo) {
chown($outputDir, $userInfo['uid']);
chgrp($outputDir, $userInfo['gid']);
}
}
// Create tar.gz archive
logger("Creating archive: $outputPath from $userFolder");
$tarCmd = "tar -I pigz -cf " . escapeshellarg($outputPath) . " -C " . escapeshellarg(dirname($userFolder)) . " " . escapeshellarg(basename($userFolder)) . " 2>&1";
exec($tarCmd, $tarOutput, $tarRetval);
if ($tarRetval !== 0) {
throw new Exception('Failed to create archive: ' . implode("\n", $tarOutput));
}
// Get archive size
$size = filesize($outputPath);
// Set ownership to the user
$userInfo = posix_getpwnam($username);
if ($userInfo) {
chown($outputPath, $userInfo['uid']);
chgrp($outputPath, $userInfo['gid']);
}
// Cleanup temp directory
exec("rm -rf " . escapeshellarg($tempDir));
return [
'success' => true,
'local_path' => $outputPath,
'size' => $size,
];
} catch (Exception $e) {
// Cleanup on error
exec("rm -rf " . escapeshellarg($tempDir));
return ['success' => false, 'error' => $e->getMessage()];
}
}
/**
* Download user folder via SFTP.
*/
function downloadUserFolderSftp(string $remotePath, string $localPath, array $destination): array
{
$host = $destination['host'] ?? '';
$port = $destination['port'] ?? 22;
$username = $destination['username'] ?? '';
$password = $destination['password'] ?? '';
$privateKey = $destination['private_key'] ?? '';
$keyFile = null;
$sshOpts = "-o StrictHostKeyChecking=no -o Port=$port";
if (!empty($privateKey)) {
$keyFile = tempnam(sys_get_temp_dir(), 'sftp_key_');
file_put_contents($keyFile, $privateKey);
chmod($keyFile, 0600);
$sshOpts .= " -i " . escapeshellarg($keyFile);
} else {
$sshOpts .= " -o PreferredAuthentications=password -o PubkeyAuthentication=no";
}
// Rsync the user folder
$sshCmd = "ssh $sshOpts";
if (!empty($password) && empty($privateKey)) {
$rsyncCmd = "sshpass -p " . escapeshellarg($password) . " rsync -avz -e " . escapeshellarg($sshCmd) . " " .
escapeshellarg("$username@$host:" . rtrim($remotePath, '/') . '/') . " " .
escapeshellarg(rtrim($localPath, '/') . '/') . " 2>&1";
} else {
$rsyncCmd = "rsync -avz -e " . escapeshellarg($sshCmd) . " " .
escapeshellarg("$username@$host:" . rtrim($remotePath, '/') . '/') . " " .
escapeshellarg(rtrim($localPath, '/') . '/') . " 2>&1";
}
logger("Executing: $rsyncCmd");
exec($rsyncCmd, $output, $retval);
if ($keyFile && file_exists($keyFile)) {
unlink($keyFile);
}
if ($retval !== 0 && $retval !== 24) {
return ['success' => false, 'error' => 'Rsync failed: ' . implode("\n", $output)];
}
return ['success' => true];
}
/**
* Download user folder via NFS.
*/
function downloadUserFolderNfs(string $remotePath, string $localPath, array $destination): array
{
$host = $destination['host'] ?? '';
$path = $destination['path'] ?? '';
$mountPoint = $destination['mount_point'] ?? '/mnt/backup_nfs';
// Mount if needed
if (!is_dir($mountPoint)) {
mkdir($mountPoint, 0755, true);
}
exec("mount | grep " . escapeshellarg($mountPoint), $output, $retval);
if ($retval !== 0) {
exec("mount -t nfs " . escapeshellarg("$host:$path") . " " . escapeshellarg($mountPoint) . " 2>&1", $output, $retval);
if ($retval !== 0) {
return ['success' => false, 'error' => 'Failed to mount NFS share'];
}
}
// Construct source path
$sourcePath = $mountPoint . '/' . ltrim($remotePath, '/');
if (!is_dir($sourcePath)) {
return ['success' => false, 'error' => 'User folder not found: ' . $sourcePath];
}
// Copy with rsync
$cmd = "rsync -av " . escapeshellarg(rtrim($sourcePath, '/') . '/') . " " .
escapeshellarg(rtrim($localPath, '/') . '/') . " 2>&1";
exec($cmd, $output, $retval);
if ($retval !== 0 && $retval !== 24) {
return ['success' => false, 'error' => 'Copy failed: ' . implode("\n", $output)];
}
return ['success' => true];
}
function backupTestDestination(array $params): array
{
$destination = $params['destination'] ?? [];
$type = $destination['type'] ?? '';
switch ($type) {
case 'sftp':
return testSftpConnection($destination);
case 'nfs':
return testNfsConnection($destination);
case 's3':
return testS3Connection($destination);
default:
return ['success' => false, 'error' => 'Unsupported destination type'];
}
}
/**
* Test SFTP connection.
*/
function testSftpConnection(array $destination): array
{
$host = $destination['host'] ?? '';
$port = $destination['port'] ?? 22;
$username = $destination['username'] ?? '';
$password = $destination['password'] ?? '';
$privateKey = $destination['private_key'] ?? '';
$remotePath = $destination['path'] ?? '/backups';
if (empty($host) || empty($username)) {
return ['success' => false, 'error' => 'Host and username required'];
}
$testFile = ".jabali_write_test_" . uniqid();
$keyFile = null;
// Build SFTP batch commands: connect, cd to path, create test file, delete it
$sftpCommands = "cd " . escapeshellarg($remotePath) . "
put /dev/null $testFile
rm $testFile
quit";
if (!empty($privateKey)) {
$keyFile = tempnam(sys_get_temp_dir(), 'sftp_key_');
file_put_contents($keyFile, $privateKey);
chmod($keyFile, 0600);
$cmd = "echo " . escapeshellarg($sftpCommands) . " | sftp -o StrictHostKeyChecking=no -o BatchMode=yes -o ConnectTimeout=10 -o Port=$port -i " .
escapeshellarg($keyFile) . " " . escapeshellarg("$username@$host") . " 2>&1";
exec($cmd, $output, $retval);
unlink($keyFile);
} else {
$cmd = "echo " . escapeshellarg($sftpCommands) . " | sshpass -p " . escapeshellarg($password) .
" sftp -o StrictHostKeyChecking=no -o ConnectTimeout=10 -o Port=$port " .
escapeshellarg("$username@$host") . " 2>&1";
exec($cmd, $output, $retval);
}
$outputStr = implode("\n", $output);
if ($retval !== 0) {
// Check for specific errors
if (stripos($outputStr, 'No such file') !== false || stripos($outputStr, 'not found') !== false) {
return ['success' => false, 'error' => "Remote path '$remotePath' does not exist"];
}
if (stripos($outputStr, 'Permission denied') !== false) {
return ['success' => false, 'error' => "No write permission to '$remotePath'"];
}
return ['success' => false, 'error' => 'SFTP connection failed: ' . $outputStr];
}
return ['success' => true, 'message' => 'SFTP connection and write permission verified'];
}
/**
* Test NFS connection.
*/
function testNfsConnection(array $destination): array
{
$server = $destination['server'] ?? '';
$share = $destination['share'] ?? '';
$subPath = $destination['path'] ?? '';
if (empty($server) || empty($share)) {
return ['success' => false, 'error' => 'Server and share required'];
}
// Test with showmount
exec("showmount -e " . escapeshellarg($server) . " 2>&1", $output, $retval);
if ($retval !== 0) {
return ['success' => false, 'error' => 'Cannot reach NFS server: ' . implode("\n", $output)];
}
// Check if share is exported
$shareFound = false;
foreach ($output as $line) {
if (strpos($line, $share) !== false) {
$shareFound = true;
break;
}
}
if (!$shareFound) {
return ['success' => false, 'error' => "Share '$share' not found on server"];
}
// Test write permission by mounting temporarily
$tempMount = "/tmp/nfs_test_" . uniqid();
mkdir($tempMount, 0755);
$mountCmd = "mount -t nfs " . escapeshellarg("$server:$share") . " " . escapeshellarg($tempMount) . " 2>&1";
exec($mountCmd, $mountOutput, $mountRetval);
if ($mountRetval !== 0) {
rmdir($tempMount);
return ['success' => false, 'error' => 'Failed to mount NFS share: ' . implode("\n", $mountOutput)];
}
// Determine test path
$testDir = $tempMount;
if (!empty($subPath)) {
$testDir = $tempMount . '/' . ltrim($subPath, '/');
if (!is_dir($testDir)) {
exec("umount " . escapeshellarg($tempMount) . " 2>&1");
rmdir($tempMount);
return ['success' => false, 'error' => "Sub-directory '$subPath' does not exist on NFS share"];
}
}
// Try to write a test file
$testFile = $testDir . "/.jabali_write_test_" . uniqid();
$writeSuccess = @file_put_contents($testFile, "test") !== false;
if ($writeSuccess) {
@unlink($testFile);
}
// Unmount
exec("umount " . escapeshellarg($tempMount) . " 2>&1");
rmdir($tempMount);
if (!$writeSuccess) {
return ['success' => false, 'error' => 'No write permission to NFS share'];
}
return ['success' => true, 'message' => 'NFS connection and write permission verified'];
}
/**
* Test S3 connection.
*/
function testS3Connection(array $destination): array
{
$endpoint = $destination['endpoint'] ?? '';
$bucket = $destination['bucket'] ?? '';
$accessKey = $destination['access_key'] ?? '';
$secretKey = $destination['secret_key'] ?? '';
$region = $destination['region'] ?? 'us-east-1';
$path = $destination['path'] ?? 'backups';
if (empty($bucket) || empty($accessKey) || empty($secretKey)) {
return ['success' => false, 'error' => 'Bucket and credentials required'];
}
$envVars = "AWS_ACCESS_KEY_ID=" . escapeshellarg($accessKey) . " " .
"AWS_SECRET_ACCESS_KEY=" . escapeshellarg($secretKey);
$endpointArg = !empty($endpoint) ? "--endpoint-url " . escapeshellarg($endpoint) : '';
$regionArg = "--region " . escapeshellarg($region);
// First, test bucket access
$cmd = "$envVars aws s3 ls " . escapeshellarg("s3://$bucket/") . " $endpointArg $regionArg 2>&1";
exec($cmd, $output, $retval);
if ($retval !== 0) {
$outputStr = implode("\n", $output);
if (stripos($outputStr, 'NoSuchBucket') !== false) {
return ['success' => false, 'error' => "Bucket '$bucket' does not exist"];
}
if (stripos($outputStr, 'AccessDenied') !== false) {
return ['success' => false, 'error' => 'Access denied - check credentials'];
}
return ['success' => false, 'error' => 'S3 connection failed: ' . $outputStr];
}
// Test write permission by uploading a test object
$testKey = trim($path, '/') . "/.jabali_write_test_" . uniqid();
$tempFile = tempnam(sys_get_temp_dir(), 'jabali_s3_test_');
file_put_contents($tempFile, "test");
$putCmd = "$envVars aws s3 cp " . escapeshellarg($tempFile) . " " .
escapeshellarg("s3://$bucket/$testKey") . " $endpointArg $regionArg 2>&1";
exec($putCmd, $putOutput, $putRetval);
unlink($tempFile);
if ($putRetval !== 0) {
$putOutputStr = implode("\n", $putOutput);
if (stripos($putOutputStr, 'AccessDenied') !== false) {
return ['success' => false, 'error' => 'No write permission to bucket'];
}
return ['success' => false, 'error' => 'Write test failed: ' . $putOutputStr];
}
// Clean up test object
$rmCmd = "$envVars aws s3 rm " . escapeshellarg("s3://$bucket/$testKey") . " $endpointArg $regionArg 2>&1";
exec($rmCmd);
return ['success' => true, 'message' => 'S3 connection and write permission verified'];
}
// ============ FAIL2BAN MANAGEMENT ============
function fail2banStatusLight(array $params): array
{
// Check if fail2ban-client exists (use file_exists for reliability after fresh install)
$installed = file_exists('/usr/bin/fail2ban-client') || file_exists('/usr/sbin/fail2ban-client');
if (!$installed) {
return ['success' => true, 'installed' => false];
}
exec('fail2ban-client version 2>/dev/null', $versionOutput);
$version = $versionOutput[0] ?? 'Unknown';
exec('systemctl is-active fail2ban 2>/dev/null', $statusOutput, $statusCode);
$running = $statusCode === 0;
return [
'success' => true,
'installed' => true,
'running' => $running,
'version' => $version,
];
}
function fail2banStatus(array $params): array
{
// Check if fail2ban-client exists (use file_exists for reliability after fresh install)
$installed = file_exists('/usr/bin/fail2ban-client') || file_exists('/usr/sbin/fail2ban-client');
if (!$installed) {
return ['success' => true, 'installed' => false];
}
exec('fail2ban-client version 2>/dev/null', $versionOutput);
$version = $versionOutput[0] ?? 'Unknown';
exec('systemctl is-active fail2ban 2>/dev/null', $statusOutput, $statusCode);
$running = $statusCode === 0;
$jails = [];
$totalBanned = 0;
if ($running) {
exec('fail2ban-client status 2>/dev/null', $output);
$jailList = [];
foreach ($output as $line) {
if (str_contains($line, 'Jail list:')) {
$jails_str = trim(str_replace('Jail list:', '', $line));
$jailList = array_filter(array_map('trim', explode(',', $jails_str)));
}
}
foreach ($jailList as $jail) {
exec("fail2ban-client status " . escapeshellarg($jail) . " 2>/dev/null", $jailOutput);
$currentlyBanned = 0;
$bannedIps = [];
foreach ($jailOutput as $line) {
if (str_contains($line, 'Currently banned:')) {
$currentlyBanned = (int) trim(str_replace('Currently banned:', '', $line));
}
if (str_contains($line, 'Banned IP list:')) {
$ips = trim(str_replace('Banned IP list:', '', $line));
$bannedIps = array_filter(array_map('trim', explode(' ', $ips)));
}
}
$jails[] = ['name' => $jail, 'banned' => $currentlyBanned, 'banned_ips' => $bannedIps];
$totalBanned += $currentlyBanned;
}
}
// Load settings
$maxRetry = 5;
$banTime = 600;
$findTime = 600;
$configFile = '/etc/fail2ban/jail.local';
if (file_exists($configFile)) {
$content = file_get_contents($configFile);
if (preg_match('/maxretry\s*=\s*(\d+)/i', $content, $m)) $maxRetry = (int) $m[1];
if (preg_match('/bantime\s*=\s*(\d+)/i', $content, $m)) $banTime = (int) $m[1];
if (preg_match('/findtime\s*=\s*(\d+)/i', $content, $m)) $findTime = (int) $m[1];
}
return [
'success' => true,
'installed' => true,
'running' => $running,
'version' => $version,
'jails' => $jails,
'total_banned' => $totalBanned,
'max_retry' => $maxRetry,
'ban_time' => $banTime,
'find_time' => $findTime,
];
}
function fail2banInstall(array $params): array
{
exec('apt-get update && apt-get install -y fail2ban 2>&1', $output, $code);
if ($code !== 0) {
return ['success' => false, 'error' => 'Installation failed: ' . implode("\n", $output)];
}
// Create default config
fail2banSaveSettings(['max_retry' => 5, 'ban_time' => 600, 'find_time' => 600]);
exec('systemctl enable fail2ban && systemctl start fail2ban 2>&1');
return ['success' => true, 'message' => 'Fail2ban installed'];
}
function fail2banStart(array $params): array
{
exec('systemctl start fail2ban 2>&1', $output, $code);
return ['success' => $code === 0, 'error' => $code !== 0 ? implode("\n", $output) : null];
}
function fail2banStop(array $params): array
{
exec('systemctl stop fail2ban 2>&1', $output, $code);
return ['success' => $code === 0, 'error' => $code !== 0 ? implode("\n", $output) : null];
}
function fail2banRestart(array $params): array
{
exec('systemctl restart fail2ban 2>&1', $output, $code);
return ['success' => $code === 0, 'error' => $code !== 0 ? implode("\n", $output) : null];
}
function fail2banSaveSettings(array $params): array
{
$maxRetry = $params['max_retry'] ?? 5;
$banTime = $params['ban_time'] ?? 600;
$findTime = $params['find_time'] ?? 600;
$config = << .* \"POST /wp-login.php\n ^ .* \"POST /xmlrpc.php\nignoreregex =";
file_put_contents('/etc/fail2ban/filter.d/wordpress-login.conf', $filter);
exec('fail2ban-client reload 2>&1', $output, $code);
return ['success' => true, 'message' => 'Settings saved'];
}
function fail2banUnbanIp(array $params): array
{
$jail = $params['jail'] ?? '';
$ip = $params['ip'] ?? '';
if (empty($jail) || empty($ip)) {
return ['success' => false, 'error' => 'Jail and IP required'];
}
exec("fail2ban-client set " . escapeshellarg($jail) . " unbanip " . escapeshellarg($ip) . " 2>&1", $output, $code);
return ['success' => $code === 0, 'error' => $code !== 0 ? implode("\n", $output) : null];
}
function fail2banBanIp(array $params): array
{
$ip = $params['ip'] ?? '';
$jail = $params['jail'] ?? 'sshd';
if (empty($ip)) {
return ['success' => false, 'error' => 'IP required'];
}
exec("fail2ban-client set " . escapeshellarg($jail) . " banip " . escapeshellarg($ip) . " 2>&1", $output, $code);
return ['success' => $code === 0, 'error' => $code !== 0 ? implode("\n", $output) : null];
}
function fail2banListJails(array $params): array
{
// Fallback descriptions for jails that use shared filters
$jailDescriptions = [
'postfix' => 'Postfix SMTP server protection',
'postfix-sasl' => 'Postfix SASL authentication protection',
'dovecot' => 'Dovecot IMAP/POP3 server protection',
'nginx-http-auth' => 'Nginx HTTP authentication protection',
'nginx-botsearch' => 'Nginx bot/scanner protection',
'nginx-bad-request' => 'Nginx bad request protection',
'nginx-limit-req' => 'Nginx rate limit protection',
'apache-auth' => 'Apache HTTP authentication protection',
'apache-badbots' => 'Apache bot/scanner protection',
'phpmyadmin' => 'phpMyAdmin login protection',
'mysql-auth' => 'MySQL authentication protection',
'proftpd' => 'ProFTPD FTP server protection',
'vsftpd' => 'vsftpd FTP server protection',
'pure-ftpd' => 'Pure-FTPd server protection',
'exim' => 'Exim mail server protection',
'named-refused' => 'BIND DNS refused queries protection',
'recidive' => 'Recidive ban for repeat offenders',
];
// Get list of available jails from jail.d directory
$jailsDir = '/etc/fail2ban/jail.d';
$availableJails = [];
// Get active jails from fail2ban-client
$activeJails = [];
exec('fail2ban-client status 2>/dev/null', $output);
foreach ($output as $line) {
if (str_contains($line, 'Jail list:')) {
// Extract jails after "Jail list:" - handle special chars like `- and tabs
if (preg_match('/Jail list:\s*[`|-]*\s*(.+)/i', $line, $m)) {
$jails_str = trim($m[1]);
$activeJails = array_filter(array_map('trim', explode(',', $jails_str)));
}
}
}
// Scan jail.d for configured jails
if (is_dir($jailsDir)) {
foreach (glob($jailsDir . '/*.conf') as $file) {
$content = file_get_contents($file);
// Extract jail sections - only match simple names (letters, numbers, dashes, underscores)
if (preg_match_all('/^\[([a-zA-Z][a-zA-Z0-9_-]*)\]\s*$/m', $content, $matches)) {
foreach ($matches[1] as $jailName) {
if ($jailName === 'DEFAULT') continue;
// Check if enabled in config
$enabledInConfig = false;
if (preg_match('/\[' . preg_quote($jailName, '/') . '\][^\[]*enabled\s*=\s*(true|yes|1)/is', $content)) {
$enabledInConfig = true;
}
// Get jail description from filter or fallback
$description = '';
$filterFile = '/etc/fail2ban/filter.d/' . $jailName . '.conf';
if (file_exists($filterFile)) {
$filterContent = file_get_contents($filterFile);
if (preg_match('/^#\s*(.+)/m', $filterContent, $descMatch)) {
$description = trim($descMatch[1]);
}
}
// Use fallback description if none found
if (empty($description) && isset($jailDescriptions[$jailName])) {
$description = $jailDescriptions[$jailName];
}
// Check if actually running
$isActive = in_array($jailName, $activeJails);
$availableJails[$jailName] = [
'name' => $jailName,
'enabled' => $enabledInConfig,
'active' => $isActive,
'description' => $description,
'config_file' => basename($file)
];
}
}
}
}
// Also include sshd if it's active (from defaults-debian.conf)
if (in_array('sshd', $activeJails)) {
$availableJails['sshd'] = [
'name' => 'sshd',
'enabled' => true,
'active' => true,
'description' => 'SSH brute force protection',
'config_file' => 'defaults-debian.conf'
];
}
return ['success' => true, 'jails' => array_values($availableJails)];
}
function fail2banEnableJail(array $params): array
{
$jail = $params['jail'] ?? '';
if (empty($jail)) {
return ['success' => false, 'error' => 'Jail name required'];
}
// Find the config file for this jail
$jailsDir = '/etc/fail2ban/jail.d';
$configFile = null;
foreach (glob($jailsDir . '/*.conf') as $file) {
$content = file_get_contents($file);
if (preg_match('/\[' . preg_quote($jail, '/') . '\]/i', $content)) {
$configFile = $file;
break;
}
}
if (!$configFile) {
return ['success' => false, 'error' => 'Jail configuration not found'];
}
// Update the config file to enable the jail
$content = file_get_contents($configFile);
// Replace enabled = false with enabled = true in the jail section
$pattern = '/(\[' . preg_quote($jail, '/') . '\][^\[]*?)enabled\s*=\s*(false|no|0)/is';
if (preg_match($pattern, $content)) {
$content = preg_replace($pattern, '$1enabled = true', $content);
} else {
// If no enabled line exists, add it after the jail header
$content = preg_replace('/(\[' . preg_quote($jail, '/') . '\])/i', "$1\nenabled = true", $content);
}
file_put_contents($configFile, $content);
// Restart fail2ban to apply changes
exec('systemctl restart fail2ban 2>&1', $output, $code);
// Wait for fail2ban to be fully ready
if ($code === 0) {
sleep(2);
// Verify jail is now active
exec('fail2ban-client status 2>/dev/null', $statusOutput);
}
return ['success' => $code === 0, 'error' => $code !== 0 ? implode("\n", $output) : null];
}
function fail2banDisableJail(array $params): array
{
$jail = $params['jail'] ?? '';
if (empty($jail)) {
return ['success' => false, 'error' => 'Jail name required'];
}
// Prevent disabling sshd jail
if ($jail === 'sshd') {
return ['success' => false, 'error' => 'Cannot disable SSH jail for security reasons'];
}
// Find the config file for this jail
$jailsDir = '/etc/fail2ban/jail.d';
$configFile = null;
foreach (glob($jailsDir . '/*.conf') as $file) {
$content = file_get_contents($file);
if (preg_match('/\[' . preg_quote($jail, '/') . '\]/i', $content)) {
$configFile = $file;
break;
}
}
if (!$configFile) {
return ['success' => false, 'error' => 'Jail configuration not found'];
}
// Update the config file to disable the jail
$content = file_get_contents($configFile);
// Replace enabled = true with enabled = false in the jail section
$pattern = '/(\[' . preg_quote($jail, '/') . '\][^\[]*?)enabled\s*=\s*(true|yes|1)/is';
if (preg_match($pattern, $content)) {
$content = preg_replace($pattern, '$1enabled = false', $content);
} else {
// If no enabled line exists, add it as disabled
$content = preg_replace('/(\[' . preg_quote($jail, '/') . '\])/i', "$1\nenabled = false", $content);
}
file_put_contents($configFile, $content);
// Restart fail2ban to apply changes
exec('systemctl restart fail2ban 2>&1', $output, $code);
// Wait for fail2ban to be fully ready
if ($code === 0) {
sleep(2);
// Verify fail2ban status
exec('fail2ban-client status 2>/dev/null', $statusOutput);
}
return ['success' => $code === 0, 'error' => $code !== 0 ? implode("\n", $output) : null];
}
// ============ CLAMAV MANAGEMENT ============
function countTextSignatures(string $path): int
{
if (!is_readable($path)) {
return 0;
}
$handle = fopen($path, 'rb');
if ($handle === false) {
return 0;
}
$count = 0;
while (($line = fgets($handle)) !== false) {
$line = trim($line);
if ($line === '' || $line[0] === '#') {
continue;
}
$count++;
}
fclose($handle);
return $count;
}
function collectCustomSignatureFiles(string $dbDir): array
{
$patterns = [
'*.ndb',
'*.hdb',
'*.ldb',
'*.cdb',
'*.hsb',
'*.ftm',
'*.ign2',
];
$files = [];
foreach ($patterns as $pattern) {
$matches = glob($dbDir.'/'.$pattern);
if (is_array($matches)) {
$files = array_merge($files, $matches);
}
}
$files = array_values(array_unique($files));
sort($files);
return $files;
}
function compileWebHostingSignatures(string $rawSignatures): string
{
$compiled = [];
foreach (explode("\n", $rawSignatures) as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') {
continue;
}
$parts = explode(':', $line, 4);
if (count($parts) < 4) {
continue;
}
[$name, $target, $offset, $pattern] = $parts;
if (str_starts_with($pattern, 'HEX:')) {
$pattern = substr($pattern, 4);
} else {
$parts = preg_split('/(\{[-0-9]+(?:-[0-9]+)?\})/', $pattern, -1, PREG_SPLIT_DELIM_CAPTURE);
$compiledPattern = '';
foreach ($parts as $part) {
if ($part === '') {
continue;
}
if (preg_match('/^\{(-?\d+)(?:-(\d+))?\}$/', $part, $matches) === 1) {
$min = (int) $matches[1];
$max = $matches[2] ?? null;
if ($min < 0 && $max === null) {
$compiledPattern .= '{0-'.abs($min).'}';
continue;
}
if ($min < 0) {
$min = 0;
}
if ($max !== null) {
$maxValue = (int) $max;
if ($maxValue < $min) {
$maxValue = $min;
}
$compiledPattern .= '{'.$min.'-'.$maxValue.'}';
continue;
}
$compiledPattern .= '{'.$min.'}';
continue;
}
$compiledPattern .= strtoupper(bin2hex($part));
}
$pattern = $compiledPattern;
}
if (preg_match('/\{[-0-9]+(?:-[0-9]+)?\}$/', $pattern) === 1) {
continue;
}
$patternWithoutSkips = preg_replace('/\{[-0-9]+(?:-[0-9]+)?\}/', '', $pattern);
if ($patternWithoutSkips === null || strlen($patternWithoutSkips) < 8 || strlen($patternWithoutSkips) % 2 !== 0) {
continue;
}
$compiled[] = $name.':'.$target.':'.$offset.':'.$pattern;
}
return implode("\n", $compiled)."\n";
}
function clamavStatusLight(array $params): array
{
exec('which clamscan 2>/dev/null', $output, $code);
$installed = $code === 0;
if (!$installed) {
return ['success' => true, 'installed' => false];
}
exec('clamscan --version 2>/dev/null', $versionOutput);
$version = $versionOutput[0] ?? 'Unknown';
exec('systemctl is-active clamav-daemon 2>/dev/null', $daemonOutput, $daemonCode);
$running = $daemonCode === 0;
exec('systemctl is-enabled jabali-realtime-scan 2>/dev/null', $rtEnabled, $rtEnabledCode);
$realtimeEnabled = $rtEnabledCode === 0;
exec('systemctl is-active jabali-realtime-scan 2>/dev/null', $rtActive, $rtActiveCode);
$realtimeRunning = $rtActiveCode === 0;
$lightMode = file_exists('/var/lib/clamav/.light_mode');
return [
'success' => true,
'installed' => true,
'running' => $running,
'version' => $version,
'realtime_enabled' => $realtimeEnabled,
'realtime_running' => $realtimeRunning,
'light_mode' => $lightMode,
];
}
function clamavStatus(array $params): array
{
exec('which clamscan 2>/dev/null', $output, $code);
$installed = $code === 0;
if (!$installed) {
return ['success' => true, 'installed' => false];
}
exec('clamscan --version 2>/dev/null', $versionOutput);
$version = $versionOutput[0] ?? 'Unknown';
exec('systemctl is-active clamav-daemon 2>/dev/null', $daemonOutput, $daemonCode);
$running = $daemonCode === 0;
// Signature info
$signatureCount = 0;
$lastUpdate = '';
exec('sigtool --info /var/lib/clamav/daily.cvd 2>/dev/null || sigtool --info /var/lib/clamav/daily.cld 2>/dev/null', $sigOutput);
foreach ($sigOutput as $line) {
if (str_contains($line, 'Build time:')) {
$lastUpdate = trim(str_replace('Build time:', '', $line));
}
}
// Recent threats
$recentThreats = [];
$logFile = file_exists('/var/log/clamav/scan.log') ? '/var/log/clamav/scan.log' : '/var/log/clamav/clamav.log';
if (file_exists($logFile) && is_readable($logFile)) {
$content = @file_get_contents($logFile);
if ($content) {
preg_match_all('/(.+): (.+) FOUND$/m', $content, $matches, PREG_SET_ORDER);
$recentThreats = array_slice(array_map(fn($m) => ['file' => $m[1], 'threat' => $m[2]], $matches), -10);
}
}
// Quarantined files
$quarantinedFiles = [];
$quarantineDir = '/var/lib/clamav/quarantine';
if (is_dir($quarantineDir) && is_readable($quarantineDir)) {
$files = @scandir($quarantineDir);
if ($files) {
$files = array_filter($files, fn($f) => $f !== '.' && $f !== '..');
$quarantinedFiles = array_map(fn($f) => [
'name' => $f,
'size' => @filesize("$quarantineDir/$f") ?: 0,
'date' => date('Y-m-d H:i', @filemtime("$quarantineDir/$f") ?: 0),
], $files);
}
}
// Real-time scanner status
exec('systemctl is-enabled jabali-realtime-scan 2>/dev/null', $rtEnabled, $rtEnabledCode);
$realtimeEnabled = $rtEnabledCode === 0;
exec('systemctl is-active jabali-realtime-scan 2>/dev/null', $rtActive, $rtActiveCode);
$realtimeRunning = $rtActiveCode === 0;
// Check if light mode is enabled
$lightMode = file_exists('/var/lib/clamav/.light_mode');
// Get signature database details
$signatureDatabases = [];
$dbDir = '/var/lib/clamav';
$dbFiles = ['main.cvd', 'main.cld', 'daily.cvd', 'daily.cld', 'bytecode.cvd', 'bytecode.cld'];
foreach ($dbFiles as $dbFile) {
$path = "$dbDir/$dbFile";
if (file_exists($path)) {
$size = filesize($path);
$sigs = 0;
$buildTime = '';
exec("sigtool --info '$path' 2>/dev/null", $infoOutput);
foreach ($infoOutput as $line) {
if (str_contains($line, 'Signatures:')) {
$sigs = (int) trim(str_replace('Signatures:', '', $line));
}
if (str_contains($line, 'Build time:')) {
$buildTime = trim(str_replace('Build time:', '', $line));
}
}
$signatureDatabases[] = [
'name' => $dbFile,
'size' => $size,
'signatures' => $sigs,
'build_time' => $buildTime,
];
$signatureCount += $sigs;
unset($infoOutput);
}
}
// Check for custom signature files
foreach (collectCustomSignatureFiles($dbDir) as $customFile) {
$name = basename($customFile);
$sigs = countTextSignatures($customFile);
$signatureDatabases[] = [
'name' => $name,
'size' => filesize($customFile),
'signatures' => $sigs,
'build_time' => date('Y-m-d H:i', filemtime($customFile)),
'custom' => true,
];
$signatureCount += $sigs;
}
return [
'success' => true,
'installed' => true,
'running' => $running,
'version' => $version,
'signature_count' => $signatureCount,
'last_update' => $lastUpdate,
'recent_threats' => $recentThreats,
'quarantined_files' => $quarantinedFiles,
'realtime_enabled' => $realtimeEnabled,
'realtime_running' => $realtimeRunning,
'light_mode' => $lightMode,
'signature_databases' => $signatureDatabases,
];
}
function clamavInstall(array $params): array
{
exec('apt-get update && apt-get install -y clamav clamav-daemon clamav-freshclam inotify-tools 2>&1', $output, $code);
if ($code !== 0) {
return ['success' => false, 'error' => 'Installation failed: ' . implode("\n", array_slice($output, -10))];
}
exec('systemctl stop clamav-freshclam clamav-daemon 2>/dev/null');
// Write optimized config
$config = <<<'CONFIG'
# Jabali ClamAV Configuration - Optimized for low resource usage
LocalSocket /var/run/clamav/clamd.ctl
FixStaleSocket true
LocalSocketGroup clamav
LocalSocketMode 666
User clamav
LogFile /var/log/clamav/clamav.log
LogRotate true
LogTime true
DatabaseDirectory /var/lib/clamav
MaxThreads 2
MaxQueue 50
MaxScanTime 60000
MaxScanSize 25M
MaxFileSize 5M
MaxRecursion 10
MaxFiles 1000
ScanArchive false
ScanPE true
ScanELF true
ScanHTML true
AlgorithmicDetection true
PhishingSignatures true
ExitOnOOM true
CONFIG;
file_put_contents('/etc/clamav/clamd.conf', $config);
// Create quarantine directory
@mkdir('/var/lib/clamav/quarantine', 0755, true);
// Write real-time scanner script
clamavWriteRealtimeScanner();
// Update signatures
exec('freshclam 2>&1');
// Start freshclam only (daemon disabled by default)
exec('systemctl enable clamav-freshclam');
exec('systemctl start clamav-freshclam');
return ['success' => true, 'message' => 'ClamAV installed (daemon disabled by default)'];
}
function clamavWriteRealtimeScanner(): void
{
$script = <<<'SCRIPT'
#!/bin/bash
WATCH_DIRS="/home"
QUARANTINE_DIR="/var/lib/clamav/quarantine"
LOG_FILE="/var/log/clamav/realtime-scan.log"
PID_FILE="/var/run/jabali-realtime-scan.pid"
SCAN_EXTENSIONS="php|phtml|php3|php4|php5|php7|phar|html|htm|js|cgi|pl|py|sh|asp|aspx|jsp|exe|dll|bat|cmd|vbs|scr|com"
SKIP_EXTENSIONS="zip|tar|gz|tgz|bz2|xz|rar|7z|iso|img|dmg|pkg|deb|rpm|msi|cab|mp4|mkv|avi|mov|wmv|mp3|wav|flac|ogg|pdf|doc|docx|xls|xlsx|ppt|pptx|sql|bak"
MAX_FILE_SIZE=5242880
mkdir -p "$QUARANTINE_DIR" && chmod 755 "$QUARANTINE_DIR"
mkdir -p "$(dirname "$LOG_FILE")" && touch "$LOG_FILE"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE"; }
scan_file() {
local file="$1"
[ ! -f "$file" ] && return 0
local ext="${file##*.}"; ext="${ext,,}"
echo "$ext" | grep -qE "^($SKIP_EXTENSIONS)$" && return 0
echo "$ext" | grep -qE "^($SCAN_EXTENSIONS)$" || return 0
local filesize=$(stat -c%s "$file" 2>/dev/null || echo 0)
[ "$filesize" -gt "$MAX_FILE_SIZE" ] && return 0
case "$file" in */vendor/*|*/node_modules/*|*/.cache/*|*/wp-includes/*|*/wp-admin/*) return 0 ;; esac
log "Scanning: $file"
local result
if pgrep -x clamd >/dev/null 2>&1; then
result=$(timeout 30 clamdscan --no-summary --move="$QUARANTINE_DIR" "$file" 2>&1)
else
result=$(timeout 60 clamscan --no-summary --move="$QUARANTINE_DIR" "$file" 2>&1)
fi
echo "$result" | grep -q "FOUND" && log "THREAT DETECTED: $file - $result"
}
cleanup() { log "Stopping..."; rm -f "$PID_FILE" "$FIFO"; pkill -P $$ 2>/dev/null; exit 0; }
trap cleanup SIGTERM SIGINT SIGHUP
echo $$ > "$PID_FILE"
log "Real-time scanner starting..."
FIFO="/tmp/jabali-realtime-scan-fifo"
rm -f "$FIFO" && mkfifo "$FIFO"
inotifywait -m -r -e create -e modify -e moved_to --format '%w%f' $WATCH_DIRS > "$FIFO" 2>/dev/null &
while read file; do
scan_file "$file" &
while [ $(jobs -r | wc -l) -ge 3 ]; do sleep 0.5; done
done < "$FIFO"
SCRIPT;
file_put_contents('/usr/local/bin/jabali-realtime-scan', $script);
chmod('/usr/local/bin/jabali-realtime-scan', 0755);
$service = <<<'SERVICE'
[Unit]
Description=Jabali Real-time File Scanner
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/jabali-realtime-scan
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
SERVICE;
file_put_contents('/etc/systemd/system/jabali-realtime-scan.service', $service);
exec('systemctl daemon-reload');
}
function clamavStart(array $params): array
{
exec('systemctl start clamav-daemon 2>&1', $output, $code);
return ['success' => $code === 0, 'error' => $code !== 0 ? implode("\n", $output) : null];
}
function clamavStop(array $params): array
{
exec('systemctl stop clamav-daemon 2>&1', $output, $code);
return ['success' => $code === 0, 'error' => $code !== 0 ? implode("\n", $output) : null];
}
function clamavUpdateSignatures(array $params): array
{
exec('freshclam 2>&1', $output, $code);
return ['success' => $code === 0, 'output' => implode("\n", $output)];
}
function clamavScan(array $params): array
{
$path = $params['path'] ?? '/home';
if (!is_dir($path)) {
return ['success' => false, 'error' => 'Invalid path'];
}
$quarantineDir = '/var/lib/clamav/quarantine';
@mkdir($quarantineDir, 0755, true);
exec("clamscan -r --infected --move=" . escapeshellarg($quarantineDir) . " " . escapeshellarg($path) . " 2>&1", $output, $code);
return [
'success' => true,
'infected' => $code === 1,
'output' => implode("\n", $output),
];
}
function clamavRealtimeStart(array $params): array
{
exec('systemctl start jabali-realtime-scan 2>&1', $output, $code);
return ['success' => $code === 0, 'error' => $code !== 0 ? implode("\n", $output) : null];
}
function clamavRealtimeStop(array $params): array
{
exec('systemctl stop jabali-realtime-scan 2>&1', $output, $code);
return ['success' => $code === 0, 'error' => $code !== 0 ? implode("\n", $output) : null];
}
function clamavRealtimeEnable(array $params): array
{
exec('systemctl enable jabali-realtime-scan && systemctl start jabali-realtime-scan 2>&1', $output, $code);
return ['success' => $code === 0, 'error' => $code !== 0 ? implode("\n", $output) : null];
}
function clamavRealtimeDisable(array $params): array
{
exec('systemctl stop jabali-realtime-scan && systemctl disable jabali-realtime-scan 2>&1', $output, $code);
return ['success' => $code === 0, 'error' => $code !== 0 ? implode("\n", $output) : null];
}
function clamavDeleteQuarantined(array $params): array
{
$filename = $params['filename'] ?? '';
$quarantineDir = '/var/lib/clamav/quarantine';
if (empty($filename)) {
// Delete all
array_map('unlink', glob("$quarantineDir/*"));
return ['success' => true, 'message' => 'All quarantined files deleted'];
}
$path = "$quarantineDir/$filename";
if (file_exists($path) && str_starts_with(realpath($path), realpath($quarantineDir))) {
unlink($path);
return ['success' => true, 'message' => 'File deleted'];
}
return ['success' => false, 'error' => 'File not found'];
}
function clamavClearThreats(array $params): array
{
// Clear the scan log file that stores threat detections
$logFiles = ['/var/log/clamav/scan.log', '/var/log/clamav/clamav.log'];
$cleared = false;
foreach ($logFiles as $logFile) {
if (file_exists($logFile)) {
file_put_contents($logFile, '');
$cleared = true;
}
}
return ['success' => true, 'message' => $cleared ? 'Threat log cleared' : 'No threat logs found'];
}
function clamavSetLightMode(array $params): array
{
// Stop services first
exec('systemctl stop clamav-daemon clamav-freshclam 2>/dev/null');
sleep(2);
$dbDir = '/var/lib/clamav';
// Backup original freshclam.conf if not already done
if (!file_exists('/etc/clamav/freshclam.conf.original')) {
@copy('/etc/clamav/freshclam.conf', '/etc/clamav/freshclam.conf.original');
}
// Create web hosting focused signature database
// Comprehensive signatures for PHP shells, backdoors, CMS malware, cryptominers, phishing, spam
$lightSignatures = <<<'SIGNATURES'
# ============================================================================
# Jabali Web Hosting ClamAV Signatures Database
# Version: 2.0 - Comprehensive Web Hosting Protection
# Categories: PHP Shells, Backdoors, CMS Malware, Cryptominers, Phishing, Spam
# ============================================================================
# ==== PHP WEB SHELLS - Popular Variants ====
Jabali.Shell.C99:0:*:c99shell
Jabali.Shell.C99.v2:0:*:c99madshell
Jabali.Shell.C99.v3:0:*:c99_buff_prepare
Jabali.Shell.R57:0:*:r57shell
Jabali.Shell.R57.v2:0:*:r57_cmd
Jabali.Shell.WSO:0:*:Web Shell by oRb
Jabali.Shell.WSO.v2:0:*:WSO Web Shell
Jabali.Shell.WSO.v3:0:*:wso_version
Jabali.Shell.B374K:0:*:b374k shell
Jabali.Shell.B374K.v2:0:*:b374k 2.8
Jabali.Shell.B374K.v3:0:*:b374k-mini
Jabali.Shell.Alfa:0:*:Alfa Shell
Jabali.Shell.Alfa.v2:0:*:AlfaTeam Shell
Jabali.Shell.Alfa.v3:0:*:STARTER_ALFA
Jabali.Shell.FilesMan:0:*:FilesMan
Jabali.Shell.FilesMan.v2:0:*:Filesmanager
Jabali.Shell.P0wny:0:*:p0wny-shell
Jabali.Shell.P0wny.v2:0:*:p0wny@shell
Jabali.Shell.Weevely:0:*:weevely3
Jabali.Shell.Weevely.v2:0:*:$k="weevely
Jabali.Shell.AnonymousFox:0:*:Anonymous Fox
Jabali.Shell.AnonymousFox.v2:0:*:Starter_fox
Jabali.Shell.Chaos:0:*:ChaosShell
Jabali.Shell.ASPXSpy:0:*:ASPXSpy
Jabali.Shell.China:0:*:China Chopper
Jabali.Shell.IndoXploit:0:*:IndoXploit Shell
Jabali.Shell.Mini:0:*:Mini Shell
Jabali.Shell.Sadrazam:0:*:Sadrazam
Jabali.Shell.Marijuana:0:*:Marijuana Shell
Jabali.Shell.Locus:0:*:Locus7Shell
Jabali.Shell.PHPSPY:0:*:PHPSPY
Jabali.Shell.Dx:0:*:DxShell
Jabali.Shell.Antichat:0:*:AntiChat Shell
Jabali.Shell.JspSpy:0:*:JspSpy
Jabali.Shell.Laudanum:0:*:Laudanum Shell
Jabali.Shell.Zehir:0:*:Zehir4 Shell
Jabali.Shell.GIF89a:0:*:GIF89a'admin
Jabali.WP.Backdoor.Posts:0:*:$wpdb->query("INSERT INTO wp_posts
Jabali.WP.Backdoor.Users:0:*:$wpdb->query("INSERT INTO wp_users
Jabali.WP.Backdoor.Options:0:*:$wpdb->query("INSERT INTO wp_options
Jabali.WP.CoreReplace:0:*://wp-admin/core-replace
Jabali.WP.Dropper.Functions:0:*:add_action('init','wp_cd
Jabali.WP.Dropper.Theme:0:*:@include(TEMPLATEPATH.'/footer2.php
Jabali.WP.Malware.Starter:0:*:starter_version
Jabali.WP.Malware.Footer:0:*:@include(WP_CONTENT_DIR.'/starter.php
Jabali.WP.TimThumb:0:*:timthumb.php?src=http
Jabali.WP.RevSlider:0:*:revslider_ajax_action
Jabali.WP.Inject.Header:0:*:add_action('wp_head', 'wp_malware_
Jabali.WP.Inject.Footer:0:*:add_action('wp_footer', 'wp_spam_
Jabali.WP.Favicon.ICO:0:*:@include(ABSPATH.'favicon_
Jabali.WP.Class.Plugin:0:*:class-plugin-anti-spam.php
# ==== JOOMLA MALWARE ====
Jabali.Joomla.Backdoor:0:*:JConfig base64
Jabali.Joomla.Malware.Gifela:0:*:gifela
Jabali.Joomla.Exploit.JCE:0:*:com_jce exploit
Jabali.Joomla.Inject:0:*:defined('_JEXEC'){-50}eval(
Jabali.Joomla.FakeExt:0:*:com_installer_backdoor
# ==== DRUPAL MALWARE ====
Jabali.Drupal.Backdoor:0:*:drupal_add_js(base64_decode
Jabali.Drupal.SA2018:0:*:Drupalgeddon
Jabali.Drupal.Inject:0:*:hook_init(){-50}eval(
# ==== GENERIC CMS MALWARE ====
Jabali.CMS.ConfigBackdoor:0:*:config.php.bak
Jabali.CMS.IndexBackdoor:0:*:index.php.bak{-100}eval
Jabali.CMS.Installer:0:*:install.php{-100}shell_exec
# ==== CRYPTOMINERS ====
Jabali.Miner.Coinhive:0:*:coinhive.min.js
Jabali.Miner.Coinhive.v2:0:*:CoinHive.Anonymous
Jabali.Miner.CryptoLoot:0:*:cryptoloot.pro
Jabali.Miner.JSEcoin:0:*:jsecoin.com/server
Jabali.Miner.WebMiner:0:*:webminer.co/worker
Jabali.Miner.DeepMiner:0:*:deepminer.js
Jabali.Miner.CoinImp:0:*:coinimp.com/scripts
Jabali.Miner.Crypto.Webassembly:0:*:importScripts('cryptonight
Jabali.Miner.Monero:0:*:stratum+tcp://
Jabali.Miner.PHP.Pool:0:*:$pool = "pool.minexmr
Jabali.Miner.XMR:0:*:xmr-stak
Jabali.Miner.Wasm:0:*:cryptonight.wasm
# ==== EMAIL SPAM/MAILERS ====
Jabali.Mailer.POST:0:*:@mail($_POST['to']
Jabali.Mailer.Mass:0:*:foreach($emails as $email){-50}mail(
Jabali.Mailer.PHPMailer.Backdoor:0:*:$mail->AddAddress($_POST
Jabali.Mailer.SwiftMailer:0:*:Swift_SmtpTransport{-100}$_POST
Jabali.Mailer.SMTP.Backdoor:0:*:fsockopen("smtp
Jabali.Mailer.Leafmailer:0:*:Leafmailer
Jabali.Mailer.Darkmailer:0:*:DarkMailer
Jabali.Mailer.AnonMailer:0:*:Anon-Mailer
Jabali.Mailer.PhpMail:0:*:php_mailer v
Jabali.Mailer.Inbox:0:*:InboxMass Mailer
Jabali.Mailer.AliBaba:0:*:AliBaba Mailer
Jabali.Mailer.Gamma:0:*:Gamma Mailer
Jabali.Mailer.WHMCS:0:*:WHMCS Mailer
Jabali.Mailer.XMailer:0:*:X-Mailer: PHP
Jabali.Mailer.Header.Inject:0:*:$headers.=$_POST['bcc']
Jabali.Spam.Script:0:*:mail($to,$subject,$body,$headers){-100}$_POST
# ==== PHISHING KITS ====
Jabali.Phish.PayPal:0:*:paypal-resolution
Jabali.Phish.PayPal.v2:0:*:paypal-login-form
Jabali.Phish.Apple:0:*:appleid.apple.com.verify
Jabali.Phish.Apple.v2:0:*:apple-id-verification
Jabali.Phish.Microsoft:0:*:microsoft-login-verify
Jabali.Phish.Office365:0:*:office365-signin
Jabali.Phish.Google:0:*:google-account-verify
Jabali.Phish.Google.v2:0:*:accounts.google.com.verify
Jabali.Phish.Amazon:0:*:amazon-signin-verify
Jabali.Phish.Netflix:0:*:netflix-account-verify
Jabali.Phish.Facebook:0:*:facebook-login-verify
Jabali.Phish.Instagram:0:*:instagram-login-verify
Jabali.Phish.Bank:0:*:secure-bank-login
Jabali.Phish.Chase:0:*:chase.com.verify-account
Jabali.Phish.BofA:0:*:bankofamerica.verify
Jabali.Phish.Wells:0:*:wellsfargo.verify
Jabali.Phish.Generic:0:*:Your account has been limited
Jabali.Phish.Result:0:*:$result_file = "results.txt"
Jabali.Phish.Telegram:0:*:api.telegram.org/bot{-100}sendDocument
# ==== JAVASCRIPT MALWARE ====
Jabali.JS.Eval.Atob:0:*:eval(atob(
Jabali.JS.Eval.FromCharCode:0:*:eval(String.fromCharCode(
Jabali.JS.Eval.Unescape:0:*:eval(unescape(
Jabali.JS.Document.Write:0:*:document.write(unescape(
Jabali.JS.Redirect:0:*:window.location.href=
Jabali.JS.Redirect.v2:0:*:document.location.href=
Jabali.JS.Redirect.v3:0:*:top.location.href=
Jabali.JS.Iframe.Hidden:0:*: