Files
jabali-panel/bin/jabali-agent

25012 lines
879 KiB
PHP
Executable File

#!/usr/bin/env php
<?php
declare(strict_types=1);
mysqli_report(MYSQLI_REPORT_OFF); // Disable mysqli exceptions
define('SOCKET_PATH', '/var/run/jabali/agent.sock');
define('PID_FILE', '/var/run/jabali/agent.pid');
define('LOG_FILE', '/var/log/jabali/agent.log');
// Email/Mail constants
define('VMAIL_UID', 5000);
define('VMAIL_GID', 5000);
define('POSTFIX_VIRTUAL_DOMAINS', '/etc/postfix/virtual_mailbox_domains');
define('POSTFIX_VIRTUAL_MAILBOXES', '/etc/postfix/virtual_mailbox_maps');
define('POSTFIX_VIRTUAL_ALIASES', '/etc/postfix/virtual_alias_maps');
define('DKIM_KEYS_DIR', '/etc/opendkim/keys');
// DNSSEC constants
define('DNSSEC_KEYS_DIR', '/etc/bind/keys');
define('DNSSEC_ALGORITHM', 'ECDSAP256SHA256'); // Algorithm 13 - recommended for modern DNSSEC
// Jabali nginx include locations
define('JABALI_NGINX_DIR', '/etc/nginx/jabali');
define('JABALI_NGINX_INCLUDES', '/etc/nginx/jabali/includes');
define('JABALI_WAF_INCLUDE', '/etc/nginx/jabali/includes/waf.conf');
define('JABALI_GEO_INCLUDE', '/etc/nginx/jabali/includes/geo.conf');
define('JABALI_WAF_RULES', '/etc/nginx/jabali/modsecurity.conf');
define('JABALI_GEO_HTTP_CONF', '/etc/nginx/conf.d/jabali-geoip.conf');
$protectedUsers = ['root', 'www-data', 'mysql', 'postgres', 'nobody', 'ubuntu', 'debian'];
$allowedServices = ['nginx', 'apache2', 'php-fpm', 'php8.4-fpm', 'mysql', 'mariadb', 'postfix', 'dovecot', 'bind9', 'named'];
function logger(string $message, string $level = 'INFO'): void
{
$timestamp = date('Y-m-d H:i:s');
$logMessage = "[$timestamp] [$level] $message\n";
file_put_contents(LOG_FILE, $logMessage, FILE_APPEND);
}
function validateUsername(string $username): bool
{
return preg_match('/^[a-z][a-z0-9_]{0,31}$/', $username) === 1;
}
function validateDomain(string $domain): bool
{
return preg_match('/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i', $domain) === 1;
}
function isProtectedUser(string $username): bool
{
global $protectedUsers;
return in_array($username, $protectedUsers, true);
}
function sanitizePath(string $path): string
{
// Remove null bytes and normalize
$path = str_replace("\0", '', $path);
// Remove .. to prevent directory traversal
$parts = explode('/', $path);
$safe = [];
foreach ($parts as $part) {
if ($part === '..') continue;
if ($part === '.' || $part === '') continue;
$safe[] = $part;
}
return implode('/', $safe);
}
function validateUserPath(string $username, string $path): ?string
{
$homeDir = rtrim("/home/$username", '/');
$homePrefix = $homeDir . '/';
$sanitized = sanitizePath($path);
$fullPath = $homeDir . '/' . $sanitized;
// Resolve to real path and verify it's within home directory
$realPath = realpath($fullPath);
if ($realPath === false) {
// Path doesn't exist yet, check parent
$parentPath = dirname($fullPath);
$realParent = realpath($parentPath);
if ($realParent === false || ($realParent !== $homeDir && !str_starts_with($realParent, $homePrefix))) {
return null;
}
return $fullPath;
}
if ($realPath !== $homeDir && !str_starts_with($realPath, $homePrefix)) {
return null; // Path escapes home directory
}
return $realPath;
}
/**
* Get list of domains owned by a user.
*/
function getUserDomains(string $username): array
{
$homeDir = "/home/$username";
$domains = [];
// Check .domains file first
$domainsFile = "$homeDir/.domains";
if (file_exists($domainsFile)) {
$domainsData = json_decode(file_get_contents($domainsFile), true) ?: [];
$domains = array_keys($domainsData);
}
// Also check domains directory
$domainsDir = "$homeDir/domains";
if (is_dir($domainsDir)) {
foreach (scandir($domainsDir) as $d) {
if ($d !== '.' && $d !== '..' && is_dir("$domainsDir/$d")) {
if (!in_array($d, $domains)) {
$domains[] = $d;
}
}
}
}
return $domains;
}
/**
* Check if a domain belongs to a user.
*/
function validateUserDomain(string $username, string $domain): bool
{
// Sanitize domain name - no path traversal
if (!validateDomain($domain) || strpos($domain, '..') !== false || strpos($domain, '/') !== false) {
return false;
}
$userDomains = getUserDomains($username);
return in_array($domain, $userDomains);
}
/**
* Validate and sanitize MySQL users.sql file.
* Only allows CREATE USER and GRANT statements for users matching username_ prefix.
* Returns sanitized SQL or null if invalid.
*/
function validateMysqlUsersFile(string $username, string $filePath): ?string
{
if (!file_exists($filePath)) {
return null;
}
$content = file_get_contents($filePath);
$lines = explode("\n", $content);
$sanitized = [];
$prefix = $username . '_';
$prefixPattern = preg_quote($prefix, '/');
// System databases that should never be granted access to
$systemDatabases = ['mysql', 'information_schema', 'performance_schema', 'sys'];
foreach ($lines as $line) {
$trimmed = trim($line);
// Allow comments and empty lines
if (empty($trimmed) || strpos($trimmed, '--') === 0 || strpos($trimmed, '#') === 0) {
$sanitized[] = $line;
continue;
}
// Allow FLUSH PRIVILEGES
if (preg_match('/^FLUSH\s+PRIVILEGES/i', $trimmed)) {
$sanitized[] = $line;
continue;
}
// Validate DROP USER IF EXISTS - must match prefix
if (preg_match('/^DROP\s+USER\s+IF\s+EXISTS\s+[\'"`]?(' . $prefixPattern . '[a-zA-Z0-9_]+)[\'"`]?@/i', $trimmed, $matches)) {
$sanitized[] = $line;
continue;
}
// Validate CREATE USER - must match prefix
if (preg_match('/^CREATE\s+USER\s+[`\'"]?(' . $prefixPattern . '[a-zA-Z0-9_]+)[`\'"]?@/i', $trimmed, $matches)) {
$sanitized[] = $line;
continue;
}
// Validate GRANT statements
if (preg_match('/^GRANT\s+/i', $trimmed)) {
// Check for global grants (ON *.*)
if (preg_match('/\bON\s+\*\.\*/i', $trimmed)) {
// Only allow USAGE on *.* (which grants no privileges)
if (!preg_match('/^GRANT\s+USAGE\s+ON\s+\*\.\*/i', $trimmed)) {
logger( "Blocked global grant in users.sql: $trimmed");
continue; // Skip dangerous global grants
}
}
// Check for grants on system databases
$blocked = false;
foreach ($systemDatabases as $sysDb) {
if (preg_match('/\bON\s+[`\'"]?' . preg_quote($sysDb, '/') . '[`\'"]?\./i', $trimmed)) {
logger( "Blocked system database grant in users.sql: $trimmed");
$blocked = true;
break;
}
}
if ($blocked) continue;
// SECURITY: Grant must satisfy BOTH conditions:
// 1. The database must match username_% pattern (user's own databases only)
// 2. The TO user must match username_ prefix
// Check if grant is on user's own databases (username_% or username\_%)
$dbPattern = preg_quote($username, '/') . '[_\\\\]+[%a-zA-Z0-9_]*';
$isOwnDatabase = preg_match('/\bON\s+[`\'"]?' . $dbPattern . '/i', $trimmed);
// Check if the TO user matches the prefix
$isOwnUser = preg_match('/\bTO\s+[`\'"]?' . $prefixPattern . '/i', $trimmed);
// Allow GRANT USAGE ON *.* (no actual privileges) if TO user matches prefix
if (preg_match('/^GRANT\s+USAGE\s+ON\s+\*\.\*/i', $trimmed) && $isOwnUser) {
$sanitized[] = $line;
continue;
}
// For all other grants, database MUST be user's own
if ($isOwnDatabase && $isOwnUser) {
$sanitized[] = $line;
continue;
}
// Block any other grants
logger("Blocked unauthorized grant in users.sql: $trimmed");
continue;
}
// Block any other statements (potential injection)
logger( "Blocked unknown statement in users.sql: $trimmed");
}
return implode("\n", $sanitized);
}
/**
* Sanitize a database dump file by removing dangerous constructs.
* Modifies the file in place and returns true if successful.
*/
function sanitizeDatabaseDump(string $username, string $filePath): bool
{
$isGzipped = preg_match('/\.gz$/i', $filePath);
$prefix = $username . '_';
// Dangerous patterns to remove/block
$dangerousPatterns = [
// DEFINER clauses (stored procedures, triggers, views run as definer)
'/DEFINER\s*=\s*[`\'"][^`\'"]+[`\'"]\s*@\s*[`\'"][^`\'"]+[`\'"]/i' => '',
// SET GLOBAL statements
'/^\s*SET\s+GLOBAL\s+/im' => '-- BLOCKED: SET GLOBAL ',
// GRANT statements embedded in dumps
'/^\s*GRANT\s+/im' => '-- BLOCKED: GRANT ',
// REVOKE statements
'/^\s*REVOKE\s+/im' => '-- BLOCKED: REVOKE ',
// CREATE USER statements
'/^\s*CREATE\s+USER\s+/im' => '-- BLOCKED: CREATE USER ',
// DROP USER statements
'/^\s*DROP\s+USER\s+/im' => '-- BLOCKED: DROP USER ',
// LOAD DATA INFILE (file reading)
'/LOAD\s+DATA\s+(LOCAL\s+)?INFILE/i' => '-- BLOCKED: LOAD DATA INFILE',
// SELECT INTO OUTFILE/DUMPFILE (file writing)
'/SELECT\s+.*\s+INTO\s+(OUTFILE|DUMPFILE)/i' => '-- BLOCKED: SELECT INTO OUTFILE',
// INSTALL/UNINSTALL PLUGIN
'/^\s*(INSTALL|UNINSTALL)\s+PLUGIN/im' => '-- BLOCKED: PLUGIN ',
];
try {
if ($isGzipped) {
$content = gzdecode(file_get_contents($filePath));
if ($content === false) {
return false;
}
} else {
$content = file_get_contents($filePath);
}
// Apply sanitization
$modified = false;
foreach ($dangerousPatterns as $pattern => $replacement) {
$newContent = preg_replace($pattern, $replacement, $content);
if ($newContent !== $content) {
$modified = true;
$content = $newContent;
logger( "Sanitized dangerous pattern in database dump: $filePath");
}
}
// Check for USE statements pointing to other users' databases
if (preg_match_all('/^\s*USE\s+[`\'"]?([a-zA-Z0-9_]+)[`\'"]?\s*;/im', $content, $matches)) {
foreach ($matches[1] as $dbName) {
if (strpos($dbName, $prefix) !== 0) {
// USE statement for a database not owned by this user
$content = preg_replace(
'/^\s*USE\s+[`\'"]?' . preg_quote($dbName, '/') . '[`\'"]?\s*;/im',
"-- BLOCKED: USE $dbName (not owned by user)",
$content
);
$modified = true;
logger( "Blocked USE statement for foreign database: $dbName");
}
}
}
if ($modified) {
if ($isGzipped) {
file_put_contents($filePath, gzencode($content));
} else {
file_put_contents($filePath, $content);
}
}
return true;
} catch (Exception $e) {
logger( "Failed to sanitize database dump: " . $e->getMessage());
return false;
}
}
/**
* Check a directory for dangerous symlinks that point outside allowed paths.
* Returns array of dangerous symlink paths found.
*/
function findDangerousSymlinks(string $directory, string $allowedBase): array
{
$dangerous = [];
if (!is_dir($directory)) {
return $dangerous;
}
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $file) {
if (is_link($file->getPathname())) {
$target = readlink($file->getPathname());
if ($target === false) {
continue;
}
// Resolve absolute path of symlink target
if ($target[0] !== '/') {
$target = dirname($file->getPathname()) . '/' . $target;
}
$realTarget = realpath($target);
// If target doesn't exist or is outside allowed base, it's dangerous
if ($realTarget === false || strpos($realTarget, $allowedBase) !== 0) {
$dangerous[] = $file->getPathname();
logger( "Found dangerous symlink: {$file->getPathname()} -> $target");
}
}
}
return $dangerous;
}
/**
* Remove dangerous symlinks from a directory.
*/
function removeDangerousSymlinks(string $directory, string $allowedBase): int
{
$dangerous = findDangerousSymlinks($directory, $allowedBase);
$removed = 0;
foreach ($dangerous as $symlink) {
if (unlink($symlink)) {
$removed++;
logger( "Removed dangerous symlink: $symlink");
}
}
return $removed;
}
function handleAction(array $request): array
{
$action = $request['action'] ?? '';
$params = $request['params'] ?? $request;
return match ($action) {
'ping' => ['success' => true, 'message' => 'pong', 'version' => '1.0.0'],
'user.create' => createUser($params),
'user.delete' => deleteUser($params),
'user.exists' => userExists($params),
'ufw.status' => ufwStatus($params),
'ufw.list_rules' => ufwListRules($params),
'ufw.enable' => ufwEnable($params),
'ufw.disable' => ufwDisable($params),
'ufw.allow_port' => ufwAllowPort($params),
'ufw.deny_port' => ufwDenyPort($params),
'ufw.allow_ip' => ufwAllowIp($params),
'ufw.deny_ip' => ufwDenyIp($params),
'ufw.delete_rule' => ufwDeleteRule($params),
'ufw.set_default' => ufwSetDefault($params),
'ufw.limit_port' => ufwLimitPort($params),
'ufw.reset' => ufwReset($params),
'ufw.reload' => ufwReload($params),
'ufw.allow_service' => ufwAllowService($params),
'user.password' => setUserPassword($params),
'user.exists' => userExists($params),
'ufw.status' => ufwStatus($params),
'ufw.list_rules' => ufwListRules($params),
'ufw.enable' => ufwEnable($params),
'ufw.disable' => ufwDisable($params),
'ufw.allow_port' => ufwAllowPort($params),
'ufw.deny_port' => ufwDenyPort($params),
'ufw.allow_ip' => ufwAllowIp($params),
'ufw.deny_ip' => ufwDenyIp($params),
'ufw.delete_rule' => ufwDeleteRule($params),
'ufw.set_default' => ufwSetDefault($params),
'ufw.limit_port' => ufwLimitPort($params),
'ufw.reset' => ufwReset($params),
'ufw.reload' => ufwReload($params),
'ufw.allow_service' => ufwAllowService($params),
'domain.create' => domainCreate($params),
'domain.alias_add' => domainAliasAdd($params),
'domain.alias_remove' => domainAliasRemove($params),
'domain.ensure_error_pages' => domainEnsureErrorPages($params),
'domain.delete' => domainDelete($params),
'domain.list' => domainList($params),
'domain.toggle' => domainToggle($params),
'domain.set_redirects' => domainSetRedirects($params),
'domain.set_hotlink_protection' => domainSetHotlinkProtection($params),
'domain.set_directory_index' => domainSetDirectoryIndex($params),
'domain.list_protected_dirs' => domainListProtectedDirs($params),
'domain.add_protected_dir' => domainAddProtectedDir($params),
'domain.remove_protected_dir' => domainRemoveProtectedDir($params),
'domain.add_protected_dir_user' => domainAddProtectedDirUser($params),
'domain.remove_protected_dir_user' => domainRemoveProtectedDirUser($params),
'php.getSettings' => phpGetSettings($params),
'php.setSettings' => phpSetSettings($params),
'php.update_pool_limits' => phpUpdatePoolLimits($params),
'php.update_all_pool_limits' => phpUpdateAllPoolLimits($params),
'wp.install' => wpInstall($params),
'wp.list' => wpList($params),
'wp.delete' => wpDelete($params),
'wp.auto_login' => wpAutoLogin($params),
'wp.update' => wpUpdate($params),
'wp.scan' => wpScan($params),
'wp.import' => wpImport($params),
'wp.cache_enable' => wpCacheEnable($params),
'wp.cache_disable' => wpCacheDisable($params),
'wp.cache_flush' => wpCacheFlush($params),
'wp.cache_status' => wpCacheStatus($params),
'wp.toggle_debug' => wpToggleDebug($params),
'wp.toggle_auto_update' => wpToggleAutoUpdate($params),
'wp.create_staging' => wpCreateStaging($params),
'wp.push_staging' => wpPushStaging($params),
'wp.page_cache_enable' => wpPageCacheEnable($params),
'wp.page_cache_disable' => wpPageCacheDisable($params),
'wp.page_cache_purge' => wpPageCachePurge($params),
'wp.page_cache_status' => wpPageCacheStatus($params),
'ssh.list_keys' => sshListKeys($params),
'ssh.add_key' => sshAddKey($params),
'ssh.delete_key' => sshDeleteKey($params),
'ssh.enable_shell' => sshEnableShell($params),
'ssh.disable_shell' => sshDisableShell($params),
'ssh.shell_status' => sshGetShellStatus($params),
'file.list' => fileList($params),
'file.read' => fileRead($params),
'file.write' => fileWrite($params),
'file.delete' => fileDelete($params),
'file.mkdir' => fileMkdir($params),
'file.rename' => fileRename($params),
'file.move' => fileMove($params),
'file.copy' => fileCopy($params),
'file.upload' => fileUpload($params),
'file.upload_temp' => fileUploadTemp($params),
'file.download' => fileDownload($params),
'file.exists' => fileExists($params),
'file.info' => fileInfo($params),
'file.extract' => fileExtract($params),
'file.chmod' => fileChmod($params),
'file.chown' => fileChown($params),
'file.trash' => fileTrash($params),
'file.restore' => fileRestore($params),
'file.empty_trash' => fileEmptyTrash($params),
'file.list_trash' => fileListTrash($params),
'image.optimize' => imageOptimize($params),
'mysql.list_databases' => mysqlListDatabases($params),
'mysql.create_database' => mysqlCreateDatabase($params),
'mysql.delete_database' => mysqlDeleteDatabase($params),
'mysql.list_users' => mysqlListUsers($params),
'mysql.create_user' => mysqlCreateUser($params),
'mysql.delete_user' => mysqlDeleteUser($params),
'mysql.change_password' => mysqlChangePassword($params),
'mysql.grant_privileges' => mysqlGrantPrivileges($params),
'mysql.revoke_privileges' => mysqlRevokePrivileges($params),
'mysql.get_privileges' => mysqlGetPrivileges($params),
'mysql.create_master_user' => mysqlCreateMasterUser($params),
'mysql.import_database' => mysqlImportDatabase($params),
'mysql.export_database' => mysqlExportDatabase($params),
'postgres.list_databases' => postgresListDatabases($params),
'postgres.list_users' => postgresListUsers($params),
'postgres.create_database' => postgresCreateDatabase($params),
'postgres.delete_database' => postgresDeleteDatabase($params),
'postgres.create_user' => postgresCreateUser($params),
'postgres.delete_user' => postgresDeleteUser($params),
'postgres.change_password' => postgresChangePassword($params),
'postgres.grant_privileges' => postgresGrantPrivileges($params),
'service.restart' => restartService($params),
'service.reload' => reloadService($params),
'service.status' => getServiceStatus($params),
'dns.create_zone' => dnsCreateZone($params),
'dns.sync_zone' => dnsSyncZone($params),
'dns.delete_zone' => dnsDeleteZone($params),
'dns.reload' => dnsReload($params),
'dns.enable_dnssec' => dnsEnableDnssec($params),
'dns.disable_dnssec' => dnsDisableDnssec($params),
'dns.get_dnssec_status' => dnsGetDnssecStatus($params),
'dns.get_ds_records' => dnsGetDsRecords($params),
'php.install' => phpInstall($params),
'php.uninstall' => phpUninstall($params),
'php.set_default' => phpSetDefaultVersion($params),
'php.restart_fpm' => phpRestartFpm($params),
'php.reload_fpm' => phpReloadFpm($params),
'php.restart_all_fpm' => phpRestartAllFpm($params),
'php.reload_all_fpm' => phpReloadAllFpm($params),
'php.list_versions' => phpListVersions($params),
'php.install_wp_modules' => phpInstallWordPressModules($params),
'ssh.generate_key' => sshGenerateKey($params),
'git.generate_key' => gitGenerateKey($params),
'git.deploy' => gitDeploy($params),
'rspamd.user_settings' => rspamdUserSettings($params),
'usage.bandwidth_total' => usageBandwidthTotal($params),
'usage.user_resources' => usageUserResources($params),
'server.set_hostname' => setHostname($params),
'server.set_upload_limits' => setUploadLimits($params),
'server.update_bind' => updateBindConfig($params),
'server.info' => getServerInfo($params),
'server.create_zone' => createServerZone($params),
'updates.list' => updatesList($params),
'updates.run' => updatesRun($params),
'waf.apply' => wafApplySettings($params),
'waf.audit_log' => wafAuditLogList($params),
'geo.apply_rules' => geoApplyRules($params),
'geo.update_database' => geoUpdateDatabase($params),
'geo.upload_database' => geoUploadDatabase($params),
'database.persist_tuning' => databasePersistTuning($params),
'database.get_variables' => databaseGetVariables($params),
'database.set_global' => databaseSetGlobal($params),
'server.export_config' => serverExportConfig($params),
'server.import_config' => serverImportConfig($params),
'server.get_resolvers' => serverGetResolvers($params),
'server.set_resolvers' => serverSetResolvers($params),
'php.install' => phpInstall($params),
'php.uninstall' => phpUninstall($params),
'php.set_default' => phpSetDefault($params),
'php.restart_fpm' => phpRestartFpm($params),
'php.reload_fpm' => phpReloadFpm($params),
'php.restart_all_fpm' => phpRestartAllFpm($params),
'php.reload_all_fpm' => phpReloadAllFpm($params),
'php.list_versions' => phpListVersions($params),
'php.install_wp_modules' => phpInstallWordPressModules($params),
'ssh.generate_key' => sshGenerateKey($params),
'server.set_hostname' => setHostname($params),
'server.set_upload_limits' => setUploadLimits($params),
'server.update_bind' => updateBindConfig($params),
'server.info' => getServerInfo($params),
'server.create_zone' => createServerZone($params),
'nginx.enable_compression' => nginxEnableCompression($params),
'nginx.get_compression_status' => nginxGetCompressionStatus($params),
// Email operations
'email.enable_domain' => emailEnableDomain($params),
'email.disable_domain' => emailDisableDomain($params),
'email.generate_dkim' => emailGenerateDkim($params),
'email.domain_info' => emailGetDomainInfo($params),
'email.mailbox_create' => emailMailboxCreate($params),
'email.mailbox_delete' => emailMailboxDelete($params),
'email.mailbox_change_password' => emailMailboxChangePassword($params),
'email.mailbox_set_quota' => emailMailboxSetQuota($params),
'email.mailbox_quota_usage' => emailMailboxGetQuotaUsage($params),
'email.mailbox_toggle' => emailMailboxToggle($params),
'email.sync_virtual_users' => emailSyncVirtualUsers($params),
'email.reload_services' => emailReloadServices($params),
'email.forwarder_create' => emailForwarderCreate($params),
'email.forwarder_delete' => emailForwarderDelete($params),
'email.forwarder_update' => emailForwarderUpdate($params),
'email.forwarder_toggle' => emailForwarderToggle($params),
'email.catchall_update' => emailCatchallUpdate($params),
'email.sync_maps' => emailSyncMaps($params),
'email.get_logs' => emailGetLogs($params),
'email.autoresponder_set' => emailAutoresponderSet($params),
'email.autoresponder_toggle' => emailAutoresponderToggle($params),
'email.autoresponder_delete' => emailAutoresponderDelete($params),
'email.hash_password' => emailHashPassword($params),
// Mail queue operations
'mail.queue_list' => mailQueueList($params),
'mail.queue_retry' => mailQueueRetry($params),
'mail.queue_delete' => mailQueueDelete($params),
'service.list' => serviceList($params),
'service.start' => serviceStart($params),
'service.stop' => serviceStop($params),
'service.restart' => serviceRestart($params),
'service.enable' => serviceEnable($params),
'service.disable' => serviceDisable($params),
// Server Import operations
'import.discover' => importDiscover($params),
'import.start' => importStart($params),
// SSL Certificate operations
'ssl.check' => sslCheck($params),
'ssl.issue' => sslIssue($params),
'ssl.install' => sslInstall($params),
'ssl.renew' => sslRenew($params),
'ssl.generate_self_signed' => sslGenerateSelfSigned($params),
'ssl.delete' => sslDelete($params),
// Backup operations
'backup.create' => backupCreate($params),
'backup.create_server' => backupCreateServer($params),
'backup.incremental_direct' => backupServerIncrementalDirect($params),
'backup.restore' => backupRestore($params),
'backup.list' => backupList($params),
'backup.delete' => backupDelete($params),
'backup.delete_server' => backupDeleteServer($params),
'backup.verify' => backupVerify($params),
'backup.get_info' => backupGetInfo($params),
'backup.upload_remote' => backupUploadRemote($params),
'backup.download_remote' => backupDownloadRemote($params),
'backup.list_remote' => backupListRemote($params),
'backup.delete_remote' => backupDeleteRemote($params),
'backup.test_destination' => backupTestDestination($params),
'backup.download_user_archive' => backupDownloadUserArchive($params),
// cPanel migration operations
'cpanel.analyze_backup' => cpanelAnalyzeBackup($params),
'cpanel.restore_backup' => cpanelRestoreBackup($params),
'cpanel.fix_backup_permissions' => cpanelFixBackupPermissions($params),
// WHM migration operations
'whm.download_backup_scp' => whmDownloadBackupScp($params),
// Jabali system SSH key operations
'jabali_ssh.get_public_key' => jabaliSshGetPublicKey($params),
'jabali_ssh.get_private_key' => jabaliSshGetPrivateKey($params),
'jabali_ssh.ensure_exists' => jabaliSshEnsureExists($params),
'jabali_ssh.add_to_authorized_keys' => jabaliSshAddToAuthorizedKeys($params),
// Fail2ban operations
'fail2ban.status' => fail2banStatus($params),
'fail2ban.status_light' => fail2banStatusLight($params),
'fail2ban.install' => fail2banInstall($params),
'fail2ban.start' => fail2banStart($params),
'fail2ban.stop' => fail2banStop($params),
'fail2ban.restart' => fail2banRestart($params),
'fail2ban.save_settings' => fail2banSaveSettings($params),
'fail2ban.unban_ip' => fail2banUnbanIp($params),
'fail2ban.ban_ip' => fail2banBanIp($params),
'fail2ban.list_jails' => fail2banListJails($params),
'fail2ban.enable_jail' => fail2banEnableJail($params),
'fail2ban.disable_jail' => fail2banDisableJail($params),
'fail2ban.logs' => fail2banLogs($params),
// ClamAV operations
'clamav.status' => clamavStatus($params),
'clamav.status_light' => clamavStatusLight($params),
'clamav.install' => clamavInstall($params),
'clamav.start' => clamavStart($params),
'clamav.stop' => clamavStop($params),
'clamav.update_signatures' => clamavUpdateSignatures($params),
'clamav.scan' => clamavScan($params),
'clamav.realtime_start' => clamavRealtimeStart($params),
'clamav.realtime_stop' => clamavRealtimeStop($params),
'clamav.realtime_enable' => clamavRealtimeEnable($params),
'clamav.realtime_disable' => clamavRealtimeDisable($params),
'clamav.delete_quarantined' => clamavDeleteQuarantined($params),
'clamav.clear_threats' => clamavClearThreats($params),
'clamav.set_light_mode' => clamavSetLightMode($params),
'clamav.set_full_mode' => clamavSetFullMode($params),
'clamav.force_update_signatures' => clamavForceUpdateSignatures($params),
'ssh.get_settings' => sshGetSettings($params),
'ssh.save_settings' => sshSaveSettings($params),
// Cron job operations
'cron.list' => cronList($params),
'cron.create' => cronCreate($params),
'cron.delete' => cronDelete($params),
'cron.toggle' => cronToggle($params),
'cron.run' => cronRun($params),
'cron.wp_setup' => cronWordPressSetup($params),
// Server metrics operations
'metrics.overview' => metricsOverview($params),
'metrics.cpu' => metricsCpu($params),
'metrics.memory' => metricsMemory($params),
'metrics.disk' => metricsDisk($params),
'metrics.network' => metricsNetwork($params),
'metrics.processes' => metricsProcesses($params),
'system.kill_process' => systemKillProcess($params),
// Disk quota operations
'quota.status' => quotaStatus($params),
'quota.enable' => quotaEnable($params),
'quota.set' => quotaSet($params),
'quota.get' => quotaGet($params),
'quota.report' => quotaReport($params),
// IP address management
'ip.list' => ipList($params),
'ip.add' => ipAdd($params),
'ip.remove' => ipRemove($params),
'ip.info' => ipInfo($params),
// Security scanner tools
'scanner.install' => scannerInstall($params),
'scanner.uninstall' => scannerUninstall($params),
'scanner.status' => scannerStatus($params),
'scanner.run_lynis' => scannerRunLynis($params),
'scanner.run_nikto' => scannerRunNikto($params),
'scanner.start_lynis' => scannerStartLynis($params),
'scanner.get_scan_status' => scannerGetScanStatus($params),
// Log analysis
'logs.tail' => logsTail($params),
'logs.goaccess' => logsGoaccess($params),
// Redis ACL management
'redis.create_user' => redisCreateUser($params),
'redis.delete_user' => redisDeleteUser($params),
'redis.user_exists' => redisUserExists($params),
'redis.change_password' => redisChangePassword($params),
'redis.migrate_users' => redisMigrateUsers($params),
default => ['success' => false, 'error' => "Unknown action: $action"],
};
}
function wafAuditLogList(array $params): array
{
$limit = (int) ($params['limit'] ?? 200);
if ($limit <= 0) {
$limit = 200;
}
$logPath = '/var/log/nginx/modsec_audit.log';
if (!file_exists($logPath)) {
return ['success' => true, 'entries' => []];
}
$lines = [];
exec('tail -n 5000 ' . escapeshellarg($logPath) . ' 2>/dev/null', $lines);
$entries = [];
$current = [
'timestamp' => null,
'remote_ip' => null,
'host' => null,
'uri' => null,
];
foreach ($lines as $line) {
if (preg_match('/^---[A-Za-z0-9]+---A--$/', $line)) {
$current = [
'timestamp' => null,
'remote_ip' => null,
'host' => null,
'uri' => null,
];
continue;
}
if (preg_match('/^\[(\d{2}\/[A-Za-z]{3}\/\d{4}:\d{2}:\d{2}:\d{2}) ([+-]\d{4})\]\s+\d+\.\d+\s+([0-9a-fA-F:.]+)/', $line, $matches)) {
$date = DateTime::createFromFormat('d/M/Y:H:i:s O', $matches[1] . ' ' . $matches[2]);
if ($date instanceof DateTime) {
$current['timestamp'] = $date->getTimestamp();
}
$current['remote_ip'] = $matches[3];
continue;
}
if (preg_match('/^(GET|POST|PUT|DELETE|HEAD|OPTIONS|PATCH)\s+([^ ]+)\s+HTTP/i', $line, $matches)) {
$current['uri'] = $matches[2];
continue;
}
if (preg_match('/^host:\s*(.+)$/i', $line, $matches)) {
$current['host'] = trim($matches[1]);
continue;
}
if (str_contains($line, 'ModSecurity:') && str_contains($line, 'Access denied')) {
$entry = [
'timestamp' => $current['timestamp'],
'remote_ip' => $current['remote_ip'],
'host' => $current['host'],
'uri' => $current['uri'],
'rule_id' => null,
'message' => null,
'severity' => null,
];
if (preg_match('/\\[id "([0-9]+)"\\]/', $line, $matches)) {
$entry['rule_id'] = $matches[1];
}
if (preg_match('/\\[msg "([^"]+)"\\]/', $line, $matches)) {
$entry['message'] = $matches[1];
}
if (preg_match('/\\[severity "([^"]+)"\\]/', $line, $matches)) {
$entry['severity'] = $matches[1];
}
if (preg_match('/\\[uri "([^"]+)"\\]/', $line, $matches)) {
$loggedUri = $matches[1];
$currentUri = (string) ($entry['uri'] ?? '');
if ($currentUri === '' || (!str_contains($currentUri, '?') && $loggedUri !== '')) {
$entry['uri'] = $loggedUri;
}
}
if (preg_match('/\\[hostname "([^"]+)"\\]/', $line, $matches)) {
$entry['host'] = $matches[1];
}
$entries[] = $entry;
}
}
$entries = array_reverse($entries);
if (count($entries) > $limit) {
$entries = array_slice($entries, 0, $limit);
}
return ['success' => true, 'entries' => $entries];
}
// ============ USER MANAGEMENT ============
function createUser(array $params): array
{
$username = $params['username'] ?? '';
$password = $params['password'] ?? null;
logger("Creating user: $username");
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username format'];
}
if (isProtectedUser($username)) {
return ['success' => false, 'error' => 'Cannot create protected system user'];
}
exec("id " . escapeshellarg($username) . " 2>/dev/null", $output, $exitCode);
if ($exitCode === 0) {
return ['success' => false, 'error' => 'User already exists'];
}
$homeDir = "/home/$username";
// Create user with nologin shell (SFTP-only by default)
$cmd = sprintf('useradd -m -d %s -s /usr/sbin/nologin %s 2>&1',
escapeshellarg($homeDir),
escapeshellarg($username)
);
exec($cmd, $output, $exitCode);
if ($exitCode !== 0) {
return ['success' => false, 'error' => 'Failed to create user: ' . implode("\n", $output)];
}
if ($password) {
$cmd = sprintf('echo %s:%s | chpasswd 2>&1',
escapeshellarg($username),
escapeshellarg($password)
);
exec($cmd);
}
// Remove symlinks that cause issues
@unlink("$homeDir/.face.icon");
@unlink("$homeDir/.face");
// Create standard directories (NO ACLs for www-data!)
$dirs = ['domains', 'logs', 'tmp', 'ssl', 'backups'];
foreach ($dirs as $dir) {
$path = "$homeDir/$dir";
if (!is_dir($path)) {
mkdir($path, 0755, true);
}
chown($path, $username);
chgrp($path, $username);
}
// Set up for secure SFTP chroot
exec("usermod -aG sftpusers " . escapeshellarg($username));
// Chroot requires root ownership of home directory
// Use user's group with 750 for complete isolation between users
chown($homeDir, "root");
chgrp($homeDir, $username); // User's own group - only this user can access
chmod($homeDir, 0750); // root=rwx, user's group=r-x, others=none
// Create PHP-FPM pool for the user (so it's ready when they create domains)
// Don't reload FPM here - caller is responsible for reloading after all operations complete
$fpmResult = createFpmPool($username, false);
$fpmPoolCreated = (bool) ($fpmResult['pool_created'] ?? false);
$fpmReloadRequired = $fpmPoolCreated && (bool) ($fpmResult['needs_reload'] ?? false);
// Create Redis ACL user for isolated caching
$redisPassword = bin2hex(random_bytes(16)); // 32 char password
$redisResult = redisCreateUser(['username' => $username, 'password' => $redisPassword]);
if ($redisResult['success']) {
// Store Redis credentials in user's home directory
$redisCredFile = "{$homeDir}/.redis_credentials";
$credContent = "REDIS_USER=jabali_{$username}\n" .
"REDIS_PASS={$redisPassword}\n" .
"REDIS_PREFIX={$username}:\n";
file_put_contents($redisCredFile, $credContent);
chmod($redisCredFile, 0600);
chown($redisCredFile, $username);
chgrp($redisCredFile, $username);
logger("Created Redis ACL user for $username");
} else {
logger("Warning: Failed to create Redis user for $username: " . ($redisResult['error'] ?? 'Unknown error'));
}
logger("Created user $username with home directory $homeDir");
return [
'success' => true,
'message' => "User $username created successfully",
'home_directory' => $homeDir,
'redis_user' => $redisResult['success'] ? "jabali_{$username}" : null,
'fpm_pool_created' => $fpmPoolCreated,
'fpm_reload_required' => $fpmReloadRequired,
];
}
function deleteUser(array $params): array
{
$username = $params['username'] ?? '';
$removeHome = $params['remove_home'] ?? false;
$domains = $params['domains'] ?? []; // List of user's domains to clean up
$steps = [];
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username format'];
}
if (isProtectedUser($username)) {
return ['success' => false, 'error' => 'Cannot delete protected system user'];
}
// Check if user exists
exec("id " . escapeshellarg($username) . " 2>/dev/null", $idOutput, $idExit);
if ($idExit !== 0) {
return ['success' => false, 'error' => 'User does not exist'];
}
$homeDir = "/home/$username";
// Get domains from .domains file if not provided
if (empty($domains)) {
$domainsFile = "$homeDir/.domains";
if (file_exists($domainsFile)) {
$domainsData = json_decode(file_get_contents($domainsFile), true) ?: [];
$domains = array_keys($domainsData);
}
}
$domainConfigRemoved = false;
$domainsDirExists = is_dir("$homeDir/domains");
// Clean up domain-related files for each domain
foreach ($domains as $domain) {
if (!validateDomain($domain)) {
continue;
}
$domainTouched = false;
// Remove nginx vhost configs (with .conf extension)
$nginxAvailable = "/etc/nginx/sites-available/{$domain}.conf";
$nginxEnabled = "/etc/nginx/sites-enabled/{$domain}.conf";
// Also try without .conf for backwards compatibility
$nginxAvailableOld = "/etc/nginx/sites-available/$domain";
$nginxEnabledOld = "/etc/nginx/sites-enabled/$domain";
foreach ([$nginxEnabled, $nginxEnabledOld] as $file) {
if (file_exists($file) || is_link($file)) {
@unlink($file);
logger("Removed nginx symlink: $file");
$domainTouched = true;
}
}
foreach ([$nginxAvailable, $nginxAvailableOld] as $file) {
if (file_exists($file)) {
@unlink($file);
logger("Removed nginx config: $file");
$domainTouched = true;
}
}
// Remove DNS zone file
$zoneFile = "/etc/bind/zones/db.$domain";
if (file_exists($zoneFile)) {
@unlink($zoneFile);
logger("Removed DNS zone: $zoneFile");
$domainTouched = true;
// Remove from named.conf.local
$namedConf = '/etc/bind/named.conf.local';
if (file_exists($namedConf)) {
$content = file_get_contents($namedConf);
// Remove zone block for this domain (use [\s\S]*?\n\} to match nested braces)
$pattern = '/\n?zone\s+"' . preg_quote($domain, '/') . '"\s*\{[\s\S]*?\n\};\n?/';
$newContent = preg_replace($pattern, "\n", $content);
if ($newContent !== $content) {
file_put_contents($namedConf, $newContent);
logger("Removed zone from named.conf.local: $domain");
}
}
}
// Remove mail directories (both in home and /var/vmail)
$mailDir = "$homeDir/mail/$domain";
if (is_dir($mailDir)) {
exec("rm -rf " . escapeshellarg($mailDir));
logger("Removed mail directory: $mailDir");
$domainTouched = true;
}
$vmailDir = "/var/vmail/$domain";
if (is_dir($vmailDir)) {
exec("rm -rf " . escapeshellarg($vmailDir));
logger("Removed vmail directory: $vmailDir");
$domainTouched = true;
}
// Remove from Postfix virtual_mailbox_domains
$vdomainsFile = POSTFIX_VIRTUAL_DOMAINS;
if (file_exists($vdomainsFile)) {
$content = file_get_contents($vdomainsFile);
$lines = explode("\n", $content);
$lines = array_filter($lines, fn($line) => trim($line) !== $domain);
file_put_contents($vdomainsFile, implode("\n", $lines));
}
// Remove mailboxes from Postfix virtual_mailbox_maps
$vmailboxFile = POSTFIX_VIRTUAL_MAILBOXES;
if (file_exists($vmailboxFile)) {
$content = file_get_contents($vmailboxFile);
$lines = explode("\n", $content);
$lines = array_filter($lines, fn($line) => !str_contains($line, "@$domain"));
file_put_contents($vmailboxFile, implode("\n", $lines));
}
// Remove from Postfix virtual_alias_maps
$valiasFile = POSTFIX_VIRTUAL_ALIASES;
if (file_exists($valiasFile)) {
$content = file_get_contents($valiasFile);
$lines = explode("\n", $content);
$lines = array_filter($lines, fn($line) => !str_contains($line, "@$domain"));
file_put_contents($valiasFile, implode("\n", $lines));
}
// Remove SSL certificates (live, archive, and renewal)
$certPath = "/etc/letsencrypt/live/$domain";
$certArchive = "/etc/letsencrypt/archive/$domain";
$certRenewal = "/etc/letsencrypt/renewal/$domain.conf";
if (is_dir($certPath)) {
exec("rm -rf " . escapeshellarg($certPath));
logger("Removed SSL certificate: $certPath");
$domainTouched = true;
}
if (is_dir($certArchive)) {
exec("rm -rf " . escapeshellarg($certArchive));
logger("Removed SSL archive: $certArchive");
$domainTouched = true;
}
if (file_exists($certRenewal)) {
@unlink($certRenewal);
logger("Removed SSL renewal config: $certRenewal");
$domainTouched = true;
}
if ($domainTouched) {
$domainConfigRemoved = true;
$steps[] = "$domain config files removed";
}
}
// Delete MySQL databases and users belonging to this user
$dbPrefix = $username . '_';
$mysqli = getMysqlConnection();
if ($mysqli) {
$dbDeletedCount = 0;
$dbUserDeletedCount = 0;
// Get all databases belonging to this user
$result = $mysqli->query("SHOW DATABASES LIKE '{$mysqli->real_escape_string($dbPrefix)}%'");
if ($result) {
while ($row = $result->fetch_row()) {
$dbName = $row[0];
// Double-check it starts with username_
if (strpos($dbName, $dbPrefix) === 0) {
$mysqli->query("DROP DATABASE IF EXISTS `{$mysqli->real_escape_string($dbName)}`");
logger("Deleted MySQL database: $dbName");
$dbDeletedCount++;
}
}
$result->free();
}
// Get all MySQL users belonging to this user
$result = $mysqli->query("SELECT User, Host FROM mysql.user WHERE User LIKE '{$mysqli->real_escape_string($dbPrefix)}%'");
if ($result) {
while ($row = $result->fetch_assoc()) {
$dbUser = $row['User'];
$dbHost = $row['Host'];
// Double-check it starts with username_
if (strpos($dbUser, $dbPrefix) === 0) {
$mysqli->query("DROP USER IF EXISTS '{$mysqli->real_escape_string($dbUser)}'@'{$mysqli->real_escape_string($dbHost)}'");
logger("Deleted MySQL user: $dbUser@$dbHost");
$dbUserDeletedCount++;
}
}
$result->free();
}
$mysqli->query("FLUSH PRIVILEGES");
$mysqli->close();
if ($dbDeletedCount > 0) {
$steps[] = "MySQL databases removed ({$dbDeletedCount})";
}
if ($dbUserDeletedCount > 0) {
$steps[] = "MySQL users removed ({$dbUserDeletedCount})";
}
}
// Remove PHP-FPM pool config
$fpmRemovedCount = 0;
foreach (glob("/etc/php/*/fpm/pool.d/$username.conf") as $poolConf) {
if (@unlink($poolConf)) {
logger("Removed PHP-FPM pool: $poolConf");
$fpmRemovedCount++;
}
}
if ($fpmRemovedCount > 0) {
$steps[] = 'PHP-FPM pool removed';
$domainConfigRemoved = true;
}
// Delete user with --force to ignore warnings about mail spool
// Don't use -r since home directory is owned by root for chroot
$cmd = sprintf('userdel --force %s 2>&1', escapeshellarg($username));
$userdelOutput = [];
exec($cmd, $userdelOutput, $userdelExit);
// Verify user was actually deleted (userdel may return non-zero for warnings)
exec("id " . escapeshellarg($username) . " 2>/dev/null", $checkOutput, $checkExit);
if ($checkExit === 0) {
// User still exists - deletion actually failed
return ['success' => false, 'error' => 'Failed to delete user: ' . implode("\n", $userdelOutput)];
}
$steps[] = 'User removed from SSH';
$steps[] = 'Unix user removed from the server';
// Delete Redis ACL user (and all their cached keys)
$redisResult = redisDeleteUser(['username' => $username]);
if (!$redisResult['success']) {
logger("Warning: Failed to delete Redis user for $username: " . ($redisResult['error'] ?? 'Unknown error'));
} else {
logger("Deleted Redis ACL user for $username");
$steps[] = 'Redis ACL user removed';
}
// Manually remove home directory if requested (since it's owned by root)
if ($removeHome && is_dir($homeDir)) {
exec(sprintf('rm -rf %s 2>&1', escapeshellarg($homeDir)), $rmOutput, $rmExit);
if ($rmExit !== 0) {
logger("Warning: Failed to remove home directory for $username");
} else {
$steps[] = "User's data directory removed";
if ($domainsDirExists) {
$steps[] = "User's domains directory removed";
}
}
}
// Reload services
exec('postmap ' . escapeshellarg(POSTFIX_VIRTUAL_DOMAINS) . ' 2>/dev/null');
exec('postmap ' . escapeshellarg(POSTFIX_VIRTUAL_MAILBOXES) . ' 2>/dev/null');
exec('postmap ' . escapeshellarg(POSTFIX_VIRTUAL_ALIASES) . ' 2>/dev/null');
exec('systemctl reload nginx 2>/dev/null');
exec('rndc reload 2>/dev/null');
exec('systemctl reload php*-fpm 2>/dev/null');
if ($domainConfigRemoved) {
$steps[] = "User's config files deleted";
}
logger("Deleted user $username" . ($removeHome ? " with home directory" : "") . " and cleaned up " . count($domains) . " domain(s)");
return ['success' => true, 'message' => "User $username deleted successfully", 'steps' => $steps];
}
function setUserPassword(array $params): array
{
$username = $params['username'] ?? '';
$password = $params['password'] ?? '';
if (!validateUsername($username) || empty($password)) {
return ['success' => false, 'error' => 'Invalid username or password'];
}
exec("id " . escapeshellarg($username) . " 2>/dev/null", $output, $exitCode);
if ($exitCode !== 0) {
return ['success' => false, 'error' => 'User does not exist'];
}
$cmd = sprintf('echo %s:%s | chpasswd 2>&1', escapeshellarg($username), escapeshellarg($password));
exec($cmd, $output, $exitCode);
return $exitCode === 0
? ['success' => true, 'message' => 'Password updated']
: ['success' => false, 'error' => 'Failed to set password'];
}
function userExists(array $params): array
{
$username = $params['username'] ?? '';
if (!validateUsername($username)) {
return ['success' => true, 'exists' => false];
}
exec("id " . escapeshellarg($username) . " 2>/dev/null", $output, $exitCode);
return ['success' => true, 'exists' => $exitCode === 0];
}
// ============ PHP-FPM POOL MANAGEMENT ============
function getFpmSocketPath(string $username): string
{
$phpVersion = '8.4';
return "/run/php/php{$phpVersion}-fpm-{$username}.sock";
}
function generateNginxVhost(string $domain, string $publicHtml, string $logs, string $fpmSocket): string
{
$config = <<<'NGINXCONF'
server {
listen 80;
listen [::]:80;
server_name DOMAIN_PLACEHOLDER www.DOMAIN_PLACEHOLDER;
root DOCROOT_PLACEHOLDER;
include /etc/nginx/jabali/includes/waf.conf;
include /etc/nginx/jabali/includes/geo.conf;
# Allow ACME challenge for SSL certificate issuance/renewal
location /.well-known/acme-challenge/ {
try_files $uri =404;
}
# Redirect all other HTTP traffic to HTTPS
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name DOMAIN_PLACEHOLDER www.DOMAIN_PLACEHOLDER;
root DOCROOT_PLACEHOLDER;
include /etc/nginx/jabali/includes/waf.conf;
include /etc/nginx/jabali/includes/geo.conf;
# Symlink protection - prevent following symlinks outside document root
disable_symlinks if_not_owner from=$document_root;
ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem;
ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key;
index index.php index.html;
client_max_body_size 50M;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:SOCKET_PLACEHOLDER;
fastcgi_next_upstream error timeout invalid_header http_500 http_503;
fastcgi_next_upstream_tries 2;
fastcgi_next_upstream_timeout 5s;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
# GoAccess statistics reports
location /stats/ {
alias STATS_PLACEHOLDER/;
index report.html;
}
location ~ /\.(?!well-known).* {
deny all;
}
access_log LOGS_PLACEHOLDER/access.log combined;
error_log LOGS_PLACEHOLDER/error.log;
}
NGINXCONF;
// Stats directory lives under the document root for web access
$stats = rtrim($publicHtml, '/') . '/stats';
$config = str_replace('DOMAIN_PLACEHOLDER', $domain, $config);
$config = str_replace('DOCROOT_PLACEHOLDER', $publicHtml, $config);
$config = str_replace('SOCKET_PLACEHOLDER', $fpmSocket, $config);
$config = str_replace('LOGS_PLACEHOLDER', $logs, $config);
$config = str_replace('STATS_PLACEHOLDER', $stats, $config);
return $config;
}
function createFpmPool(string $username, bool $reload = true): array
{
$phpVersion = '8.4';
$poolFile = "/etc/php/{$phpVersion}/fpm/pool.d/{$username}.conf";
// Check if pool already exists
if (file_exists($poolFile)) {
return [
'success' => true,
'message' => 'Pool already exists',
'socket' => getFpmSocketPath($username),
'pool_created' => false,
];
}
$userInfo = posix_getpwnam($username);
if (!$userInfo) {
return ['success' => false, 'error' => 'User not found'];
}
$userHome = $userInfo['dir'];
// Create required directories
$dirs = ["{$userHome}/tmp", "{$userHome}/logs"];
foreach ($dirs as $dir) {
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
chown($dir, $username);
chgrp($dir, $username);
}
}
// Default PHP settings - can be overridden via admin settings
$memoryLimit = '512M';
$uploadMaxFilesize = '64M';
$postMaxSize = '64M';
$maxExecutionTime = '300';
$maxInputTime = '300';
$maxInputVars = '3000';
// Resource limits - configurable via admin settings
$pmMaxChildren = (int)($params['pm_max_children'] ?? 5);
$pmStartServers = max(1, (int)($pmMaxChildren / 5));
$pmMinSpareServers = max(1, (int)($pmMaxChildren / 5));
$pmMaxSpareServers = max(2, (int)($pmMaxChildren / 2));
$pmMaxRequests = (int)($params['pm_max_requests'] ?? 200);
$rlimitFiles = (int)($params['rlimit_files'] ?? 1024);
$processPriority = (int)($params['process_priority'] ?? 0);
$requestTerminateTimeout = (int)($params['request_terminate_timeout'] ?? 300);
$poolConfig = "[{$username}]
user = {$username}
group = {$username}
listen = /run/php/php{$phpVersion}-fpm-{$username}.sock
listen.owner = {$username}
listen.group = www-data
listen.mode = 0660
; Process manager settings
pm = dynamic
pm.max_children = {$pmMaxChildren}
pm.start_servers = {$pmStartServers}
pm.min_spare_servers = {$pmMinSpareServers}
pm.max_spare_servers = {$pmMaxSpareServers}
pm.max_requests = {$pmMaxRequests}
; Resource limits
rlimit_files = {$rlimitFiles}
process.priority = {$processPriority}
request_terminate_timeout = {$requestTerminateTimeout}s
; slowlog disabled by default to avoid startup failures when logs dir missing
; request_slowlog_timeout = 30s
; slowlog = {$userHome}/logs/php-slow.log
chdir = /
; PHP Settings (defaults)
php_admin_value[memory_limit] = {$memoryLimit}
php_admin_value[upload_max_filesize] = {$uploadMaxFilesize}
php_admin_value[post_max_size] = {$postMaxSize}
php_admin_value[max_execution_time] = {$maxExecutionTime}
php_admin_value[max_input_time] = {$maxInputTime}
php_admin_value[max_input_vars] = {$maxInputVars}
; Security
php_admin_value[open_basedir] = {$userHome}/:/tmp/:/usr/share/php/
php_admin_value[upload_tmp_dir] = {$userHome}/tmp
php_admin_value[session.save_path] = {$userHome}/tmp
php_admin_value[sys_temp_dir] = {$userHome}/tmp
php_admin_value[disable_functions] = symlink,link,exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec
; Logging
php_admin_flag[log_errors] = on
php_admin_value[error_log] = {$userHome}/logs/php-error.log
security.limit_extensions = .php
";
if (file_put_contents($poolFile, $poolConfig) === false) {
return [
'success' => false,
'error' => 'Failed to create pool configuration',
'pool_created' => false,
];
}
// Reload PHP-FPM if requested (default behavior for normal operations)
// Pass reload=false during migrations to avoid unnecessary reloads during batches
if ($reload) {
exec("systemctl reload php{$phpVersion}-fpm 2>&1", $output, $code);
if ($code !== 0) {
logger("Warning: PHP-FPM reload failed: " . implode("\n", $output));
}
}
return [
'success' => true,
'socket' => getFpmSocketPath($username),
'needs_reload' => !$reload,
'pool_created' => true,
];
}
function deleteFpmPool(string $username): array
{
$phpVersion = '8.4';
$poolFile = "/etc/php/{$phpVersion}/fpm/pool.d/{$username}.conf";
if (file_exists($poolFile)) {
unlink($poolFile);
exec("(sleep 1 && systemctl reload php{$phpVersion}-fpm) > /dev/null 2>&1 &");
}
return ['success' => true];
}
/**
* Update FPM pool limits for a specific user
*/
function phpUpdatePoolLimits(array $params): array
{
$username = $params['username'] ?? '';
$phpVersion = '8.4';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$poolFile = "/etc/php/{$phpVersion}/fpm/pool.d/{$username}.conf";
if (!file_exists($poolFile)) {
return ['success' => false, 'error' => 'Pool configuration not found'];
}
$userHome = "/home/{$username}";
// Ensure logs directory exists (for error logs)
$logsDir = "{$userHome}/logs";
if (!is_dir($logsDir)) {
mkdir($logsDir, 0755, true);
chown($logsDir, $username);
chgrp($logsDir, $username);
}
// Get limits from params with defaults
$pmMaxChildren = (int)($params['pm_max_children'] ?? 5);
$pmStartServers = max(1, (int)($pmMaxChildren / 5));
$pmMinSpareServers = max(1, (int)($pmMaxChildren / 5));
$pmMaxSpareServers = max(2, (int)($pmMaxChildren / 2));
$pmMaxRequests = (int)($params['pm_max_requests'] ?? 200);
$rlimitFiles = (int)($params['rlimit_files'] ?? 1024);
$processPriority = (int)($params['process_priority'] ?? 0);
$requestTerminateTimeout = (int)($params['request_terminate_timeout'] ?? 300);
// PHP settings
$memoryLimit = $params['memory_limit'] ?? '512M';
$uploadMaxFilesize = $params['upload_max_filesize'] ?? '64M';
$postMaxSize = $params['post_max_size'] ?? '64M';
$maxExecutionTime = $params['max_execution_time'] ?? '300';
$maxInputTime = $params['max_input_time'] ?? '300';
$maxInputVars = $params['max_input_vars'] ?? '3000';
$poolConfig = "[{$username}]
user = {$username}
group = {$username}
listen = /run/php/php{$phpVersion}-fpm-{$username}.sock
listen.owner = {$username}
listen.group = www-data
listen.mode = 0660
; Process manager settings
pm = dynamic
pm.max_children = {$pmMaxChildren}
pm.start_servers = {$pmStartServers}
pm.min_spare_servers = {$pmMinSpareServers}
pm.max_spare_servers = {$pmMaxSpareServers}
pm.max_requests = {$pmMaxRequests}
; Resource limits
rlimit_files = {$rlimitFiles}
process.priority = {$processPriority}
request_terminate_timeout = {$requestTerminateTimeout}s
; slowlog disabled by default to avoid startup failures when logs dir missing
; request_slowlog_timeout = 30s
; slowlog = {$userHome}/logs/php-slow.log
chdir = /
; PHP Settings
php_admin_value[memory_limit] = {$memoryLimit}
php_admin_value[upload_max_filesize] = {$uploadMaxFilesize}
php_admin_value[post_max_size] = {$postMaxSize}
php_admin_value[max_execution_time] = {$maxExecutionTime}
php_admin_value[max_input_time] = {$maxInputTime}
php_admin_value[max_input_vars] = {$maxInputVars}
; Security
php_admin_value[open_basedir] = {$userHome}/:/tmp/:/usr/share/php/
php_admin_value[upload_tmp_dir] = {$userHome}/tmp
php_admin_value[session.save_path] = {$userHome}/tmp
php_admin_value[sys_temp_dir] = {$userHome}/tmp
php_admin_value[disable_functions] = symlink,link,exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec
; Logging
php_admin_flag[log_errors] = on
php_admin_value[error_log] = {$userHome}/logs/php-error.log
security.limit_extensions = .php
";
if (file_put_contents($poolFile, $poolConfig) === false) {
return ['success' => false, 'error' => 'Failed to update pool configuration'];
}
return ['success' => true];
}
/**
* Update FPM pool limits for all users
*/
function phpUpdateAllPoolLimits(array $params): array
{
$phpVersion = '8.4';
$poolDir = "/etc/php/{$phpVersion}/fpm/pool.d";
$pools = glob("{$poolDir}/*.conf");
$updated = [];
$errors = [];
foreach ($pools as $poolFile) {
$username = basename($poolFile, '.conf');
// Skip www.conf (default pool)
if ($username === 'www') {
continue;
}
// Verify user exists
exec("id " . escapeshellarg($username) . " 2>/dev/null", $output, $exitCode);
if ($exitCode !== 0) {
continue;
}
$result = phpUpdatePoolLimits(array_merge($params, ['username' => $username]));
if ($result['success']) {
$updated[] = $username;
} else {
$errors[$username] = $result['error'];
}
}
// Reload PHP-FPM after all updates
exec("(sleep 2 && systemctl reload php{$phpVersion}-fpm) > /dev/null 2>&1 &");
return [
'success' => true,
'updated' => $updated,
'errors' => $errors,
];
}
// ============ DOMAIN MANAGEMENT ============
function createDomain(array $params): array
{
$username = $params['username'] ?? '';
$domain = $params['domain'] ?? '';
if (!validateUsername($username) || !validateDomain($domain)) {
return ['success' => false, 'error' => 'Invalid username or domain format'];
}
exec("id " . escapeshellarg($username) . " 2>/dev/null", $output, $exitCode);
if ($exitCode !== 0) {
return ['success' => false, 'error' => 'User does not exist'];
}
$homeDir = "/home/$username";
$domainDir = "$homeDir/domains/$domain";
$publicDir = "$domainDir/public_html";
if (is_dir($domainDir)) {
return ['success' => false, 'error' => 'Domain directory already exists'];
}
if (!mkdir($publicDir, 0755, true)) {
return ['success' => false, 'error' => 'Failed to create domain directory'];
}
// Set ownership to user (NO www-data access)
exec(sprintf('chown -R %s:%s %s', escapeshellarg($username), escapeshellarg($username), escapeshellarg($domainDir)));
// Create default index.html
$indexContent = "<!DOCTYPE html>\n<html>\n<head><title>Welcome to $domain</title></head>\n<body><h1>Welcome to $domain</h1></body>\n</html>";
file_put_contents("$publicDir/index.html", $indexContent);
chown("$publicDir/index.html", $username);
chgrp("$publicDir/index.html", $username);
logger("Created domain $domain for user $username");
return [
'success' => true,
'message' => "Domain $domain created",
'domain_path' => $domainDir,
'public_path' => $publicDir,
];
}
function deleteDomain(array $params): array
{
$username = $params['username'] ?? '';
$domain = $params['domain'] ?? '';
if (!validateUsername($username) || !validateDomain($domain)) {
return ['success' => false, 'error' => 'Invalid username or domain format'];
}
$domainDir = "/home/$username/domains/$domain";
if (!is_dir($domainDir)) {
return ['success' => false, 'error' => 'Domain directory does not exist'];
}
exec(sprintf('rm -rf %s 2>&1', escapeshellarg($domainDir)), $output, $exitCode);
if ($exitCode !== 0) {
return ['success' => false, 'error' => 'Failed to delete domain'];
}
logger("Deleted domain $domain for user $username");
return ['success' => true, 'message' => "Domain $domain deleted"];
}
// ============ FILE OPERATIONS ============
function fileList(array $params): array
{
$username = $params['username'] ?? '';
$path = $params['path'] ?? '';
$showHidden = (bool) ($params['show_hidden'] ?? false);
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$fullPath = validateUserPath($username, $path);
if ($fullPath === null) {
return ['success' => false, 'error' => 'Invalid path'];
}
if (!is_dir($fullPath)) {
return ['success' => false, 'error' => 'Directory not found'];
}
$items = [];
$entries = @scandir($fullPath);
if ($entries === false) {
return ['success' => false, 'error' => 'Cannot read directory'];
}
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') continue;
// Hide hidden files unless show_hidden is true (always hide .trash)
if (str_starts_with($entry, '.') && (!$showHidden || $entry === '.trash')) continue;
$itemPath = "$fullPath/$entry";
$stat = @stat($itemPath);
if ($stat === false) continue;
// Skip symlinks
if (is_link($itemPath)) continue;
$items[] = [
'name' => $entry,
'path' => ltrim(str_replace("/home/$username", '', $itemPath), '/'),
'is_dir' => is_dir($itemPath),
'size' => is_file($itemPath) ? $stat['size'] : null,
'modified' => $stat['mtime'],
'permissions' => substr(sprintf('%o', $stat['mode']), -4),
];
}
// Sort: directories first, then alphabetically
usort($items, function ($a, $b) {
if ($a['is_dir'] !== $b['is_dir']) {
return $b['is_dir'] <=> $a['is_dir'];
}
return strcasecmp($a['name'], $b['name']);
});
return ['success' => true, 'items' => $items, 'path' => $path];
}
function fileRead(array $params): array
{
$username = $params['username'] ?? '';
$path = $params['path'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$fullPath = validateUserPath($username, $path);
if ($fullPath === null || !is_file($fullPath)) {
return ['success' => false, 'error' => 'File not found'];
}
// Limit file size for reading
$maxSize = 50 * 1024 * 1024; // 5MB
if (filesize($fullPath) > $maxSize) {
return ['success' => false, 'error' => 'File too large to read'];
}
$content = @file_get_contents($fullPath);
if ($content === false) {
return ['success' => false, 'error' => 'Cannot read file'];
}
return [
'success' => true,
'content' => base64_encode($content),
'encoding' => 'base64',
'size' => strlen($content),
];
}
function fileWrite(array $params): array
{
$username = $params['username'] ?? '';
$path = $params['path'] ?? '';
$content = $params['content'] ?? '';
$encoding = $params['encoding'] ?? 'plain';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$fullPath = validateUserPath($username, $path);
if ($fullPath === null) {
return ['success' => false, 'error' => 'Invalid path'];
}
// Decode content if base64
if ($encoding === 'base64') {
$content = base64_decode($content, true);
if ($content === false) {
return ['success' => false, 'error' => 'Invalid base64 content'];
}
}
// Ensure parent directory exists
$parentDir = dirname($fullPath);
if (!is_dir($parentDir)) {
return ['success' => false, 'error' => 'Parent directory does not exist'];
}
if (@file_put_contents($fullPath, $content) === false) {
return ['success' => false, 'error' => 'Cannot write file'];
}
chown($fullPath, $username);
chgrp($fullPath, $username);
chmod($fullPath, 0644);
logger("File written: $fullPath for user $username");
return ['success' => true, 'message' => 'File saved', 'size' => strlen($content)];
}
function fileDelete(array $params): array
{
$username = $params['username'] ?? '';
$path = $params['path'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$fullPath = validateUserPath($username, $path);
if ($fullPath === null) {
return ['success' => false, 'error' => 'Invalid path'];
}
// Don't allow deleting the home directory itself or critical folders
$homeDir = "/home/$username";
$protected = [$homeDir, "$homeDir/domains", "$homeDir/logs", "$homeDir/ssl", "$homeDir/backups", "$homeDir/tmp"];
if (in_array($fullPath, $protected)) {
return ['success' => false, 'error' => 'Cannot delete protected directory'];
}
if (is_dir($fullPath)) {
exec(sprintf('rm -rf %s 2>&1', escapeshellarg($fullPath)), $output, $exitCode);
} else {
$exitCode = @unlink($fullPath) ? 0 : 1;
}
if ($exitCode !== 0) {
return ['success' => false, 'error' => 'Cannot delete'];
}
logger("Deleted: $fullPath for user $username");
return ['success' => true, 'message' => 'Deleted successfully'];
}
function fileMkdir(array $params): array
{
$username = $params['username'] ?? '';
$path = $params['path'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$fullPath = validateUserPath($username, $path);
if ($fullPath === null) {
return ['success' => false, 'error' => 'Invalid path'];
}
if (file_exists($fullPath)) {
return ['success' => false, 'error' => 'Path already exists'];
}
if (!@mkdir($fullPath, 0755, true)) {
return ['success' => false, 'error' => 'Cannot create directory'];
}
chown($fullPath, $username);
chgrp($fullPath, $username);
logger("Directory created: $fullPath for user $username");
return ['success' => true, 'message' => 'Directory created'];
}
function fileRename(array $params): array
{
$username = $params['username'] ?? '';
$path = $params['path'] ?? '';
$newName = $params['new_name'] ?? '';
if (!validateUsername($username) || empty($newName)) {
return ['success' => false, 'error' => 'Invalid parameters'];
}
// Validate new name doesn't contain path separators
if (str_contains($newName, '/') || str_contains($newName, '..')) {
return ['success' => false, 'error' => 'Invalid new name'];
}
$fullPath = validateUserPath($username, $path);
if ($fullPath === null || !file_exists($fullPath)) {
return ['success' => false, 'error' => 'File not found'];
}
$newPath = dirname($fullPath) . '/' . $newName;
if (file_exists($newPath)) {
return ['success' => false, 'error' => 'Target already exists'];
}
if (!@rename($fullPath, $newPath)) {
return ['success' => false, 'error' => 'Cannot rename'];
}
logger("Renamed: $fullPath to $newPath for user $username");
return ['success' => true, 'message' => 'Renamed successfully'];
}
function fileMove(array $params): array
{
$username = $params['username'] ?? '';
$path = $params['path'] ?? '';
$destination = $params['destination'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$fullPath = validateUserPath($username, $path);
$destPath = validateUserPath($username, $destination);
if ($fullPath === null || $destPath === null) {
return ['success' => false, 'error' => 'Invalid path'];
}
if (!file_exists($fullPath)) {
return ['success' => false, 'error' => 'Source not found'];
}
// If destination is a directory, move into it
if (is_dir($destPath)) {
$destPath = $destPath . '/' . basename($fullPath);
}
if (file_exists($destPath)) {
return ['success' => false, 'error' => 'Destination already exists'];
}
if (!@rename($fullPath, $destPath)) {
return ['success' => false, 'error' => 'Cannot move'];
}
logger("Moved: $fullPath to $destPath for user $username");
return ['success' => true, 'message' => 'Moved successfully'];
}
function fileCopy(array $params): array
{
$username = $params['username'] ?? '';
$path = $params['path'] ?? '';
$destination = $params['destination'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$fullPath = validateUserPath($username, $path);
$destPath = validateUserPath($username, $destination);
if ($fullPath === null || $destPath === null) {
return ['success' => false, 'error' => 'Invalid path'];
}
if (!file_exists($fullPath)) {
return ['success' => false, 'error' => 'Source not found'];
}
if (is_dir($destPath)) {
$destPath = $destPath . '/' . basename($fullPath);
}
if (file_exists($destPath)) {
return ['success' => false, 'error' => 'Destination already exists'];
}
if (is_dir($fullPath)) {
exec(sprintf('cp -r %s %s 2>&1', escapeshellarg($fullPath), escapeshellarg($destPath)), $output, $exitCode);
if ($exitCode !== 0) {
return ['success' => false, 'error' => 'Cannot copy directory'];
}
exec(sprintf('chown -R %s:%s %s', escapeshellarg($username), escapeshellarg($username), escapeshellarg($destPath)));
} else {
if (!@copy($fullPath, $destPath)) {
return ['success' => false, 'error' => 'Cannot copy file'];
}
chown($destPath, $username);
chgrp($destPath, $username);
}
logger("Copied: $fullPath to $destPath for user $username");
return ['success' => true, 'message' => 'Copied successfully'];
}
function fileUpload(array $params): array
{
$username = $params['username'] ?? '';
$path = $params['path'] ?? '';
$filename = $params['filename'] ?? '';
$content = $params['content'] ?? '';
if (!validateUsername($username) || empty($filename)) {
return ['success' => false, 'error' => 'Invalid parameters'];
}
// Sanitize filename
$filename = basename($filename);
$filename = preg_replace('/[^a-zA-Z0-9._-]/', '_', $filename);
if (empty($filename)) {
return ['success' => false, 'error' => 'Invalid filename'];
}
$dirPath = validateUserPath($username, $path);
if ($dirPath === null || !is_dir($dirPath)) {
return ['success' => false, 'error' => 'Invalid directory'];
}
$fullPath = "$dirPath/$filename";
// Decode base64 content
$decoded = base64_decode($content, true);
if ($decoded === false) {
return ['success' => false, 'error' => 'Invalid file content'];
}
// Limit upload size (50MB)
if (strlen($decoded) > 50 * 1024 * 1024) {
return ['success' => false, 'error' => 'File too large'];
}
if (@file_put_contents($fullPath, $decoded) === false) {
return ['success' => false, 'error' => 'Cannot save file'];
}
chown($fullPath, $username);
chgrp($fullPath, $username);
chmod($fullPath, 0644);
logger("Uploaded: $fullPath for user $username (" . strlen($decoded) . " bytes)");
return ['success' => true, 'message' => 'File uploaded', 'path' => ltrim(str_replace("/home/$username", '', $fullPath), '/')];
}
/**
* Upload large files by moving from temp location.
* This avoids JSON encoding issues with large binary content.
*/
function fileUploadTemp(array $params): array
{
$username = $params['username'] ?? '';
$path = $params['path'] ?? '';
$filename = $params['filename'] ?? '';
$tempPath = $params['temp_path'] ?? '';
if (!validateUsername($username) || empty($filename) || empty($tempPath)) {
return ['success' => false, 'error' => 'Invalid parameters'];
}
// Validate temp path is in allowed temp directory
$allowedTempDir = '/tmp/jabali-uploads/';
$realTempPath = realpath($tempPath);
$allowedPrefix = rtrim($allowedTempDir, '/') . '/';
if ($realTempPath === false || !str_starts_with($realTempPath, $allowedPrefix)) {
logger("Invalid temp path: $tempPath (real: $realTempPath)", 'ERROR');
return ['success' => false, 'error' => 'Invalid temp file path'];
}
if (!file_exists($realTempPath)) {
return ['success' => false, 'error' => 'Temp file not found'];
}
// Sanitize filename
$filename = basename($filename);
$filename = preg_replace('/[^a-zA-Z0-9._-]/', '_', $filename);
if (empty($filename)) {
@unlink($realTempPath);
return ['success' => false, 'error' => 'Invalid filename'];
}
$dirPath = validateUserPath($username, $path);
if ($dirPath === null || !is_dir($dirPath)) {
@unlink($realTempPath);
return ['success' => false, 'error' => 'Invalid directory'];
}
$fullPath = "$dirPath/$filename";
// Get file size for logging
$fileSize = filesize($realTempPath);
// Limit upload size (500MB for temp uploads)
if ($fileSize > 500 * 1024 * 1024) {
@unlink($realTempPath);
return ['success' => false, 'error' => 'File too large (max 500MB)'];
}
// Move temp file to destination
if (!@rename($realTempPath, $fullPath)) {
// If rename fails (cross-device), try copy+delete
if (!@copy($realTempPath, $fullPath)) {
@unlink($realTempPath);
return ['success' => false, 'error' => 'Cannot move file to destination'];
}
@unlink($realTempPath);
}
chown($fullPath, $username);
chgrp($fullPath, $username);
chmod($fullPath, 0644);
logger("Uploaded (temp): $fullPath for user $username ($fileSize bytes)");
return ['success' => true, 'message' => 'File uploaded', 'path' => ltrim(str_replace("/home/$username", '', $fullPath), '/')];
}
function fileDownload(array $params): array
{
return fileRead($params); // Same as read, just different context
}
function fileExists(array $params): array
{
$username = $params['username'] ?? '';
$path = $params['path'] ?? '';
if (!validateUsername($username)) {
return ['success' => true, 'exists' => false];
}
$fullPath = validateUserPath($username, $path);
return ['success' => true, 'exists' => $fullPath !== null && file_exists($fullPath)];
}
function fileInfo(array $params): array
{
$username = $params['username'] ?? '';
$path = $params['path'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$fullPath = validateUserPath($username, $path);
if ($fullPath === null || !file_exists($fullPath)) {
return ['success' => false, 'error' => 'File not found'];
}
$stat = stat($fullPath);
return [
'success' => true,
'info' => [
'name' => basename($fullPath),
'path' => $path,
'is_dir' => is_dir($fullPath),
'is_file' => is_file($fullPath),
'size' => $stat['size'],
'modified' => $stat['mtime'],
'permissions' => substr(sprintf('%o', $stat['mode']), -4),
'owner' => posix_getpwuid($stat['uid'])['name'] ?? $stat['uid'],
'group' => posix_getgrgid($stat['gid'])['name'] ?? $stat['gid'],
],
];
}
function fileExtract(array $params): array
{
$username = $params['username'] ?? '';
$path = $params['path'] ?? '';
if (!validateUsername($username) || isProtectedUser($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$fullPath = validateUserPath($username, $path);
if ($fullPath === null) {
return ['success' => false, 'error' => 'Invalid path'];
}
if (!file_exists($fullPath)) {
return ['success' => false, 'error' => 'File not found'];
}
$ext = strtolower(pathinfo($fullPath, PATHINFO_EXTENSION));
$filename = basename($fullPath);
$destDir = dirname($fullPath);
$output = [];
$returnCode = 0;
// Handle .tar.gz, .tar.bz2, .tar.xz
if (preg_match('/\.tar\.(gz|bz2|xz)$/i', $filename)) {
$flag = match(strtolower(pathinfo($filename, PATHINFO_EXTENSION))) {
'gz' => 'z',
'bz2' => 'j',
'xz' => 'J',
default => ''
};
exec("cd " . escapeshellarg($destDir) . " && tar -x{$flag}f " . escapeshellarg($fullPath) . " 2>&1", $output, $returnCode);
} else {
switch ($ext) {
case 'zip':
exec("cd " . escapeshellarg($destDir) . " && unzip -o " . escapeshellarg($fullPath) . " 2>&1", $output, $returnCode);
break;
case 'gz':
exec("cd " . escapeshellarg($destDir) . " && pigz -dk " . escapeshellarg($fullPath) . " 2>&1", $output, $returnCode);
break;
case 'tgz':
exec("cd " . escapeshellarg($destDir) . " && tar -I pigz -xf " . escapeshellarg($fullPath) . " 2>&1", $output, $returnCode);
break;
case 'tar':
exec("cd " . escapeshellarg($destDir) . " && tar -xf " . escapeshellarg($fullPath) . " 2>&1", $output, $returnCode);
break;
case 'bz2':
exec("cd " . escapeshellarg($destDir) . " && bunzip2 -k " . escapeshellarg($fullPath) . " 2>&1", $output, $returnCode);
break;
case 'xz':
exec("cd " . escapeshellarg($destDir) . " && unxz -k " . escapeshellarg($fullPath) . " 2>&1", $output, $returnCode);
break;
case 'rar':
exec("cd " . escapeshellarg($destDir) . " && unrar x -o+ " . escapeshellarg($fullPath) . " 2>&1", $output, $returnCode);
break;
case '7z':
exec("cd " . escapeshellarg($destDir) . " && 7z x -y " . escapeshellarg($fullPath) . " 2>&1", $output, $returnCode);
break;
default:
return ['success' => false, 'error' => 'Unsupported archive format'];
}
}
if ($returnCode !== 0) {
return ['success' => false, 'error' => 'Extract failed: ' . implode("\n", $output)];
}
// Fix ownership of extracted files
exec("chown -R " . escapeshellarg($username) . ":" . escapeshellarg($username) . " " . escapeshellarg($destDir));
logger("Extracted: $fullPath for user $username");
return ['success' => true, 'message' => 'Archive extracted successfully'];
}
function fileChmod(array $params): array
{
$username = $params['username'] ?? '';
$path = $params['path'] ?? '';
$mode = $params['mode'] ?? '';
if (!validateUsername($username) || isProtectedUser($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$fullPath = validateUserPath($username, $path);
if ($fullPath === null) {
return ['success' => false, 'error' => 'Invalid path'];
}
if (!file_exists($fullPath)) {
return ['success' => false, 'error' => 'File not found'];
}
// Validate mode - must be octal string like "755" or "0755"
$mode = ltrim($mode, '0');
if (!preg_match('/^[0-7]{3,4}$/', $mode)) {
return ['success' => false, 'error' => 'Invalid permission mode'];
}
$octalMode = octdec($mode);
// Safety: Don't allow setuid/setgid bits for regular users
$octalMode = $octalMode & 0777;
if (!@chmod($fullPath, $octalMode)) {
return ['success' => false, 'error' => 'Failed to change permissions'];
}
logger("Chmod: $fullPath to $mode for user $username");
return ['success' => true, 'message' => 'Permissions changed successfully', 'mode' => sprintf('%o', $octalMode)];
}
function fileChown(array $params): array
{
$path = $params['path'] ?? '';
$owner = $params['owner'] ?? '';
$group = $params['group'] ?? '';
if (empty($path)) {
return ['success' => false, 'error' => 'Path is required'];
}
if (empty($owner) && empty($group)) {
return ['success' => false, 'error' => 'Owner or group is required'];
}
// Security: Only allow chown in specific directories
$allowedPrefixes = [
'/var/backups/jabali/',
'/home/',
];
$realPath = realpath($path);
if ($realPath === false) {
return ['success' => false, 'error' => 'Path does not exist'];
}
$allowed = false;
foreach ($allowedPrefixes as $prefix) {
if (strpos($realPath, $prefix) === 0) {
$allowed = true;
break;
}
}
if (!$allowed) {
logger("Security: Blocked chown attempt on $realPath", 'WARNING');
return ['success' => false, 'error' => 'Path not in allowed directories'];
}
// Change owner
if (!empty($owner)) {
if (!@chown($realPath, $owner)) {
return ['success' => false, 'error' => 'Failed to change owner'];
}
}
// Change group
if (!empty($group)) {
if (!@chgrp($realPath, $group)) {
return ['success' => false, 'error' => 'Failed to change group'];
}
}
logger("Chown: $realPath to $owner:$group");
return ['success' => true, 'message' => 'Ownership changed successfully'];
}
function fileTrash(array $params): array
{
$username = $params['username'] ?? '';
$path = $params['path'] ?? '';
if (!validateUsername($username) || isProtectedUser($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$fullPath = validateUserPath($username, $path);
if ($fullPath === null) {
return ['success' => false, 'error' => 'Invalid path'];
}
if (!file_exists($fullPath)) {
return ['success' => false, 'error' => 'File not found'];
}
// Create trash directory if it doesn't exist
$trashDir = "/home/$username/.trash";
if (!is_dir($trashDir)) {
mkdir($trashDir, 0755, true);
chown($trashDir, $username);
chgrp($trashDir, $username);
}
// Generate unique name to avoid conflicts
$basename = basename($fullPath);
$timestamp = date('Y-m-d_His');
$trashName = "{$basename}.{$timestamp}";
$trashPath = "$trashDir/$trashName";
// Store original path in metadata file for potential restore
$metaFile = "$trashPath.meta";
if (!@rename($fullPath, $trashPath)) {
return ['success' => false, 'error' => 'Failed to move to trash'];
}
// Save metadata
file_put_contents($metaFile, json_encode([
'original_path' => str_replace("/home/$username/", '', $path),
'trashed_at' => time(),
'name' => $basename,
]));
chown($metaFile, $username);
chgrp($metaFile, $username);
logger("Trashed: $fullPath to $trashPath for user $username");
return ['success' => true, 'message' => 'Moved to trash', 'trash_path' => ".trash/$trashName"];
}
function fileRestore(array $params): array
{
$username = $params['username'] ?? '';
$trashName = $params['trash_name'] ?? '';
if (!validateUsername($username) || isProtectedUser($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$trashDir = "/home/$username/.trash";
$trashPath = "$trashDir/$trashName";
$metaFile = "$trashPath.meta";
if (!file_exists($trashPath)) {
return ['success' => false, 'error' => 'Item not found in trash'];
}
// Try to get original path from metadata
$originalPath = null;
if (file_exists($metaFile)) {
$meta = json_decode(file_get_contents($metaFile), true);
$originalPath = $meta['original_path'] ?? null;
}
if (!$originalPath) {
// Fall back to home directory with original name (strip timestamp)
$basename = preg_replace('/\.\d{4}-\d{2}-\d{2}_\d{6}$/', '', $trashName);
$originalPath = $basename;
}
$fullDestPath = "/home/$username/$originalPath";
// If destination exists, add suffix
if (file_exists($fullDestPath)) {
$pathInfo = pathinfo($fullDestPath);
$counter = 1;
do {
$newName = $pathInfo['filename'] . "_restored_$counter";
if (isset($pathInfo['extension'])) {
$newName .= '.' . $pathInfo['extension'];
}
$fullDestPath = $pathInfo['dirname'] . '/' . $newName;
$counter++;
} while (file_exists($fullDestPath));
}
// Ensure destination directory exists
$destDir = dirname($fullDestPath);
if (!is_dir($destDir)) {
mkdir($destDir, 0755, true);
chown($destDir, $username);
chgrp($destDir, $username);
}
if (!@rename($trashPath, $fullDestPath)) {
return ['success' => false, 'error' => 'Failed to restore from trash'];
}
// Remove metadata file
@unlink($metaFile);
logger("Restored: $trashPath to $fullDestPath for user $username");
return ['success' => true, 'message' => 'Restored from trash', 'restored_path' => str_replace("/home/$username/", '', $fullDestPath)];
}
function fileEmptyTrash(array $params): array
{
$username = $params['username'] ?? '';
if (!validateUsername($username) || isProtectedUser($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$trashDir = "/home/$username/.trash";
if (!is_dir($trashDir)) {
return ['success' => true, 'message' => 'Trash is already empty', 'deleted' => 0];
}
$deleted = 0;
$items = scandir($trashDir);
foreach ($items as $item) {
if ($item === '.' || $item === '..') continue;
$itemPath = "$trashDir/$item";
if (is_dir($itemPath)) {
exec("rm -rf " . escapeshellarg($itemPath));
} else {
@unlink($itemPath);
}
$deleted++;
}
logger("Emptied trash for user $username ($deleted items)");
return ['success' => true, 'message' => 'Trash emptied', 'deleted' => $deleted];
}
function fileListTrash(array $params): array
{
$username = $params['username'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$trashDir = "/home/$username/.trash";
if (!is_dir($trashDir)) {
return ['success' => true, 'items' => []];
}
$items = [];
$files = scandir($trashDir);
foreach ($files as $file) {
if ($file === '.' || $file === '..' || str_ends_with($file, '.meta')) continue;
$fullPath = "$trashDir/$file";
$stat = @stat($fullPath);
if (!$stat) continue;
$meta = null;
$metaFile = "$fullPath.meta";
if (file_exists($metaFile)) {
$meta = json_decode(file_get_contents($metaFile), true);
}
$items[] = [
'trash_name' => $file,
'name' => $meta['name'] ?? preg_replace('/\.\d{4}-\d{2}-\d{2}_\d{6}$/', '', $file),
'original_path' => $meta['original_path'] ?? null,
'trashed_at' => $meta['trashed_at'] ?? $stat['mtime'],
'is_dir' => is_dir($fullPath),
'size' => is_dir($fullPath) ? 0 : $stat['size'],
];
}
// Sort by trashed_at descending (most recent first)
usort($items, fn($a, $b) => $b['trashed_at'] <=> $a['trashed_at']);
return ['success' => true, 'items' => $items];
}
function mysqlCreateMasterUser(array $params): array
{
$username = $params["username"] ?? "";
if (!validateUsername($username)) {
return ["success" => false, "error" => "Invalid username"];
}
$conn = getMysqlConnection();
if (!$conn) {
return ["success" => false, "error" => "Cannot connect to MySQL"];
}
// Create master user with pattern like: user1_admin
$masterUser = $username . "_admin";
$password = bin2hex(random_bytes(16));
// Drop if exists
$conn->query("DROP USER IF EXISTS '{$masterUser}'@'localhost'");
// Create user
$escapedPassword = $conn->real_escape_string($password);
if (!$conn->query("CREATE USER '{$masterUser}'@'localhost' IDENTIFIED BY '{$escapedPassword}'")) {
$conn->close();
return ["success" => false, "error" => "Failed to create master user: " . $conn->error];
}
// Grant privileges to all user's databases using wildcard
$pattern = $username . "\\_%";
if (!$conn->query("GRANT ALL PRIVILEGES ON `{$pattern}`.* TO '{$masterUser}'@'localhost'")) {
$conn->close();
return ["success" => false, "error" => "Failed to grant privileges: " . $conn->error];
}
$conn->query("FLUSH PRIVILEGES");
$conn->close();
return [
"success" => true,
"master_user" => $masterUser,
"password" => $password,
"message" => "Master MySQL user created with access to all {$username}_* databases"
];
}
function mysqlImportDatabase(array $params): array
{
$username = $params["username"] ?? "";
$database = $params["database"] ?? "";
$sqlFile = $params["sql_file"] ?? "";
if (!validateUsername($username)) {
return ["success" => false, "error" => "Invalid username"];
}
if (!file_exists($sqlFile) || !is_readable($sqlFile)) {
return ["success" => false, "error" => "SQL file not found or not readable"];
}
// Ensure database belongs to user
$prefix = $username . "_";
if (strpos($database, $prefix) !== 0) {
return ["success" => false, "error" => "Database does not belong to user"];
}
// Get MySQL root credentials
$mysqlRoot = getMysqlRootCredentials();
if (!$mysqlRoot) {
return ["success" => false, "error" => "Cannot get MySQL credentials"];
}
// Detect file type by extension
$extension = strtolower(pathinfo($sqlFile, PATHINFO_EXTENSION));
$filename = strtolower(basename($sqlFile));
// Import using mysql command with socket authentication
// Redirect stderr to a temp file to avoid mixing with stdin
$errFile = tempnam('/tmp', 'mysql_err_');
if ($extension === 'gz' || str_ends_with($filename, '.sql.gz')) {
// Gzipped SQL file - decompress and pipe to mysql
$cmd = sprintf(
'pigz -dc %s | mysql --defaults-file=/etc/mysql/debian.cnf %s 2>%s',
escapeshellarg($sqlFile),
escapeshellarg($database),
escapeshellarg($errFile)
);
} elseif ($extension === 'zip') {
// ZIP file - extract first file and pipe to mysql
$cmd = sprintf(
'unzip -p %s | mysql --defaults-file=/etc/mysql/debian.cnf %s 2>%s',
escapeshellarg($sqlFile),
escapeshellarg($database),
escapeshellarg($errFile)
);
} else {
// Plain SQL file
$cmd = sprintf(
'mysql --defaults-file=/etc/mysql/debian.cnf %s < %s 2>%s',
escapeshellarg($database),
escapeshellarg($sqlFile),
escapeshellarg($errFile)
);
}
exec($cmd, $output, $returnCode);
// Read stderr if command failed
if ($returnCode !== 0) {
$stderr = file_get_contents($errFile);
@unlink($errFile);
return ["success" => false, "error" => "Import failed: " . trim($stderr)];
}
@unlink($errFile);
logger("Database imported: $database from $sqlFile for user $username");
return ["success" => true, "message" => "Database imported successfully"];
}
function mysqlExportDatabase(array $params): array
{
$username = $params["username"] ?? "";
$database = $params["database"] ?? "";
$outputFile = $params["output_file"] ?? "";
$compress = $params["compress"] ?? "gz"; // "gz", "zip", or "none"
if (!validateUsername($username)) {
return ["success" => false, "error" => "Invalid username"];
}
// Ensure database belongs to user
$prefix = $username . "_";
if (strpos($database, $prefix) !== 0) {
return ["success" => false, "error" => "Database does not belong to user"];
}
// Get MySQL root credentials
$mysqlRoot = getMysqlRootCredentials();
if (!$mysqlRoot) {
return ["success" => false, "error" => "Cannot get MySQL credentials"];
}
// Ensure output directory exists
$outputDir = dirname($outputFile);
if (!is_dir($outputDir)) {
mkdir($outputDir, 0755, true);
}
// Export using mysqldump with socket authentication
$errFile = tempnam('/tmp', 'mysqldump_err_');
if ($compress === "gz") {
// Pipe through pigz (parallel gzip) for compression
$cmd = sprintf(
'mysqldump --defaults-file=/etc/mysql/debian.cnf --single-transaction --routines --triggers %s 2>%s | pigz > %s',
escapeshellarg($database),
escapeshellarg($errFile),
escapeshellarg($outputFile)
);
} elseif ($compress === "zip") {
// Export to temp SQL file, then zip
$tempSql = tempnam('/tmp', 'mysqldump_') . '.sql';
$cmd = sprintf(
'mysqldump --defaults-file=/etc/mysql/debian.cnf --single-transaction --routines --triggers %s > %s 2>%s && zip -j %s %s && rm -f %s',
escapeshellarg($database),
escapeshellarg($tempSql),
escapeshellarg($errFile),
escapeshellarg($outputFile),
escapeshellarg($tempSql),
escapeshellarg($tempSql)
);
} else {
// No compression
$cmd = sprintf(
'mysqldump --defaults-file=/etc/mysql/debian.cnf --single-transaction --routines --triggers %s > %s 2>%s',
escapeshellarg($database),
escapeshellarg($outputFile),
escapeshellarg($errFile)
);
}
exec($cmd, $output, $returnCode);
if ($returnCode !== 0) {
$stderr = file_get_contents($errFile);
@unlink($errFile);
return ["success" => false, "error" => "Export failed: " . trim($stderr)];
}
@unlink($errFile);
// Set ownership to user
chown($outputFile, $username);
chgrp($outputFile, $username);
logger("Database exported: $database to $outputFile (compress: $compress) for user $username");
return ["success" => true, "output_file" => $outputFile, "message" => "Database exported successfully"];
}
// ============ SERVICE MANAGEMENT ============
function shouldReloadService(string $service): bool
{
$normalized = $service;
if (str_ends_with($normalized, '.service')) {
$normalized = substr($normalized, 0, -strlen('.service'));
}
if ($normalized === 'nginx') {
return true;
}
return preg_match('/^php(\d+\.\d+)?-fpm$/', $normalized) === 1;
}
function restartService(array $params): array
{
global $allowedServices;
$service = $params['service'] ?? '';
if (!in_array($service, $allowedServices, true)) {
return ['success' => false, 'error' => "Service not allowed: $service"];
}
$action = shouldReloadService($service) ? 'reload' : 'restart';
exec("systemctl {$action} " . escapeshellarg($service) . " 2>&1", $output, $exitCode);
return $exitCode === 0
? ['success' => true, 'message' => "Service $service " . ($action === 'reload' ? 'reloaded' : 'restarted')]
: ['success' => false, 'error' => 'Failed to ' . $action . ' service'];
}
function reloadService(array $params): array
{
global $allowedServices;
$service = $params['service'] ?? '';
if (!in_array($service, $allowedServices, true)) {
return ['success' => false, 'error' => "Service not allowed: $service"];
}
exec("systemctl reload " . escapeshellarg($service) . " 2>&1", $output, $exitCode);
return $exitCode === 0
? ['success' => true, 'message' => "Service $service reloaded"]
: ['success' => false, 'error' => 'Failed to reload service'];
}
function getServiceStatus(array $params): array
{
global $allowedServices;
$service = $params['service'] ?? '';
if (!in_array($service, $allowedServices, true)) {
return ['success' => false, 'error' => "Service not allowed: $service"];
}
exec("systemctl is-active " . escapeshellarg($service) . " 2>&1", $output, $exitCode);
$status = trim($output[0] ?? 'unknown');
return ['success' => true, 'service' => $service, 'status' => $status, 'running' => $status === 'active'];
}
/**
* Enable gzip compression in nginx for all text-based content
* This provides ~400-500KB savings on typical WordPress sites
*/
function nginxEnableCompression(array $params): array
{
$nginxConf = '/etc/nginx/nginx.conf';
if (!file_exists($nginxConf)) {
return ['success' => false, 'error' => 'nginx.conf not found'];
}
$content = file_get_contents($nginxConf);
$modified = false;
// Check if gzip_types is already configured (not commented)
if (preg_match('/^\s*gzip_types\s/m', $content)) {
return ['success' => true, 'message' => 'Compression already enabled', 'already_enabled' => true];
}
// Uncomment and configure gzip settings (patterns account for tab indentation)
$replacements = [
'/^[ \t]*# gzip_vary on;/m' => "\tgzip_vary on;",
'/^[ \t]*# gzip_proxied any;/m' => "\tgzip_proxied any;",
'/^[ \t]*# gzip_comp_level 6;/m' => "\tgzip_comp_level 6;",
'/^[ \t]*# gzip_buffers 16 8k;/m' => "\tgzip_buffers 16 8k;",
'/^[ \t]*# gzip_http_version 1.1;/m' => "\tgzip_http_version 1.1;",
'/^[ \t]*# gzip_types .*/m' => "\tgzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml application/xml+rss application/x-javascript application/vnd.ms-fontobject application/x-font-ttf font/opentype font/woff font/woff2 image/svg+xml image/x-icon;",
];
foreach ($replacements as $pattern => $replacement) {
$newContent = preg_replace($pattern, $replacement, $content);
if ($newContent !== $content) {
$content = $newContent;
$modified = true;
}
}
// Add gzip_min_length if gzip_types was added and gzip_min_length doesn't exist
if ($modified && strpos($content, 'gzip_min_length') === false) {
$content = preg_replace(
'/(gzip_types[^;]+;)/',
"$1\n\tgzip_min_length 256;",
$content
);
}
// Write back if modified
if ($modified) {
// Test configuration before applying
file_put_contents($nginxConf, $content);
exec('nginx -t 2>&1', $testOutput, $testCode);
if ($testCode !== 0) {
// Restore original
exec("git -C /etc/nginx checkout nginx.conf 2>/dev/null");
return ['success' => false, 'error' => 'nginx configuration test failed: ' . implode("\n", $testOutput)];
}
// Reload nginx
exec('systemctl reload nginx 2>&1', $output, $exitCode);
if ($exitCode !== 0) {
return ['success' => false, 'error' => 'Failed to reload nginx'];
}
return ['success' => true, 'message' => 'Compression enabled successfully'];
}
return ['success' => true, 'message' => 'Compression settings already optimal', 'already_enabled' => true];
}
/**
* Get current nginx compression status
*/
function nginxGetCompressionStatus(array $params): array
{
$nginxConf = '/etc/nginx/nginx.conf';
if (!file_exists($nginxConf)) {
return ['success' => false, 'error' => 'nginx.conf not found'];
}
$content = file_get_contents($nginxConf);
$settings = [
'gzip' => (bool)preg_match('/^\s*gzip\s+on\s*;/m', $content),
'gzip_vary' => (bool)preg_match('/^\s*gzip_vary\s+on\s*;/m', $content),
'gzip_proxied' => (bool)preg_match('/^\s*gzip_proxied\s/m', $content),
'gzip_comp_level' => null,
'gzip_types' => (bool)preg_match('/^\s*gzip_types\s/m', $content),
'gzip_min_length' => null,
];
// Extract compression level
if (preg_match('/^\s*gzip_comp_level\s+(\d+)\s*;/m', $content, $matches)) {
$settings['gzip_comp_level'] = (int)$matches[1];
}
// Extract min length
if (preg_match('/^\s*gzip_min_length\s+(\d+)\s*;/m', $content, $matches)) {
$settings['gzip_min_length'] = (int)$matches[1];
}
// Determine if fully optimized
$optimized = $settings['gzip'] && $settings['gzip_vary'] && $settings['gzip_types'] && $settings['gzip_comp_level'];
return [
'success' => true,
'enabled' => $settings['gzip'],
'optimized' => $optimized,
'settings' => $settings,
];
}
function ensureJabaliNginxIncludeFiles(): void
{
if (!is_dir(JABALI_NGINX_INCLUDES)) {
@mkdir(JABALI_NGINX_INCLUDES, 0755, true);
}
ensureWafUnicodeMapFile();
ensureWafMainConfig();
$modSecurityAvailable = isModSecurityModuleAvailable();
$baseConfig = findWafBaseConfig();
$shouldDisableWaf = $baseConfig === null || !$modSecurityAvailable;
if (!file_exists(JABALI_WAF_INCLUDE)) {
$content = "# Managed by Jabali\n";
if (!$modSecurityAvailable) {
$content .= "# ModSecurity module not available in nginx.\n";
} elseif ($shouldDisableWaf) {
$content .= "modsecurity off;\n";
}
file_put_contents(JABALI_WAF_INCLUDE, $content);
} elseif ($shouldDisableWaf) {
$current = file_get_contents(JABALI_WAF_INCLUDE);
if ($current === false || strpos($current, 'modsecurity_rules_file') !== false || strpos($current, 'modsecurity on;') !== false || strpos($current, 'modsecurity off;') !== false) {
if (!$modSecurityAvailable) {
file_put_contents(JABALI_WAF_INCLUDE, "# Managed by Jabali\n# ModSecurity module not available in nginx.\n");
} else {
file_put_contents(JABALI_WAF_INCLUDE, "# Managed by Jabali\nmodsecurity off;\n");
}
}
}
if (!file_exists(JABALI_GEO_INCLUDE)) {
file_put_contents(JABALI_GEO_INCLUDE, "# Managed by Jabali\n");
}
}
function ensureWafMainConfig(): void
{
$path = '/etc/nginx/modsec/main.conf';
$dir = dirname($path);
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
$needsRewrite = !file_exists($path);
if (!$needsRewrite) {
$content = file_get_contents($path);
if ($content === false || stripos($content, 'IncludeOptional') !== false || stripos($content, 'owasp-crs.load') !== false) {
$needsRewrite = true;
}
}
if (!$needsRewrite) {
return;
}
$lines = ['Include /etc/modsecurity/modsecurity.conf'];
if (file_exists('/etc/modsecurity/crs/crs-setup.conf')) {
$lines[] = 'Include /etc/modsecurity/crs/crs-setup.conf';
} elseif (file_exists('/usr/share/modsecurity-crs/crs-setup.conf')) {
$lines[] = 'Include /usr/share/modsecurity-crs/crs-setup.conf';
}
if (file_exists('/etc/modsecurity/crs/REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf')) {
$lines[] = 'Include /etc/modsecurity/crs/REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf';
}
if (is_dir('/usr/share/modsecurity-crs/rules')) {
$lines[] = 'Include /usr/share/modsecurity-crs/rules/*.conf';
}
if (file_exists('/etc/modsecurity/crs/RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf')) {
$lines[] = 'Include /etc/modsecurity/crs/RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf';
}
file_put_contents($path, implode("\n", $lines) . "\n");
}
function ensureWafUnicodeMapFile(): void
{
$target = '/etc/modsecurity/unicode.mapping';
if (file_exists($target)) {
return;
}
$sources = [
'/usr/share/modsecurity-crs/util/unicode.mapping',
'/usr/share/modsecurity-crs/unicode.mapping',
'/usr/share/modsecurity/unicode.mapping',
'/etc/nginx/unicode.mapping',
'/usr/share/nginx/docs/modsecurity/unicode.mapping',
];
foreach ($sources as $source) {
if (!file_exists($source)) {
continue;
}
if (!is_dir('/etc/modsecurity')) {
@mkdir('/etc/modsecurity', 0755, true);
}
if (@copy($source, $target)) {
break;
}
}
}
function ensureNginxServerIncludes(array $includeLines): array
{
$files = glob('/etc/nginx/sites-enabled/*.conf') ?: [];
$updated = 0;
foreach ($files as $file) {
$content = file_get_contents($file);
if ($content === false) {
continue;
}
$original = $content;
foreach ($includeLines as $line) {
if (strpos($content, $line) !== false) {
continue;
}
$content = preg_replace('/(server_name[^\n]*\n)/', "$1 {$line}\n", $content);
}
if ($content !== $original) {
file_put_contents($file, $content);
$updated++;
}
}
return [
'files' => count($files),
'updated' => $updated,
];
}
function nginxTestAndReload(): array
{
exec('nginx -t 2>&1', $testOutput, $testCode);
if ($testCode !== 0) {
return ['success' => false, 'error' => 'nginx configuration test failed: ' . implode("\n", $testOutput)];
}
exec('systemctl reload nginx 2>&1', $output, $exitCode);
if ($exitCode !== 0) {
return ['success' => false, 'error' => 'Failed to reload nginx'];
}
return ['success' => true];
}
function findWafBaseConfig(): ?string
{
$paths = [
'/etc/nginx/modsec/main.conf',
'/etc/nginx/modsecurity.conf',
'/etc/modsecurity/modsecurity.conf',
'/etc/modsecurity/modsecurity.conf-recommended',
];
foreach ($paths as $path) {
if (file_exists($path) && isWafBaseConfigUsable($path)) {
return $path;
}
}
return null;
}
function findWafCoreConfig(): ?string
{
$paths = [
'/etc/modsecurity/modsecurity.conf',
'/etc/modsecurity/modsecurity.conf-recommended',
'/etc/nginx/modsecurity.conf',
];
foreach ($paths as $path) {
if (file_exists($path) && isWafBaseConfigUsable($path)) {
return $path;
}
}
return null;
}
function buildWafCrsIncludeLines(): array
{
$lines = [];
if (file_exists('/etc/modsecurity/crs/crs-setup.conf')) {
$lines[] = 'Include /etc/modsecurity/crs/crs-setup.conf';
} elseif (file_exists('/usr/share/modsecurity-crs/crs-setup.conf')) {
$lines[] = 'Include /usr/share/modsecurity-crs/crs-setup.conf';
}
if (file_exists('/etc/modsecurity/crs/REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf')) {
$lines[] = 'Include /etc/modsecurity/crs/REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf';
}
if (is_dir('/etc/modsecurity/crs/rules')) {
$lines[] = 'Include /etc/modsecurity/crs/rules/*.conf';
} elseif (is_dir('/usr/share/modsecurity-crs/rules')) {
$lines[] = 'Include /usr/share/modsecurity-crs/rules/*.conf';
}
if (file_exists('/etc/modsecurity/crs/RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf')) {
$lines[] = 'Include /etc/modsecurity/crs/RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf';
}
return $lines;
}
function isModSecurityModuleAvailable(): bool
{
$output = [];
exec('nginx -V 2>&1', $output);
$info = implode("\n", $output);
if (stripos($info, 'modsecurity') !== false) {
return true;
}
foreach (glob('/etc/nginx/modules-enabled/*.conf') ?: [] as $file) {
$content = file_get_contents($file);
if ($content !== false && stripos($content, 'modsecurity') !== false) {
return true;
}
}
return false;
}
function isWafBaseConfigUsable(string $path): bool
{
if (!is_readable($path)) {
return false;
}
$content = file_get_contents($path);
if ($content === false) {
return false;
}
if (stripos($content, 'IncludeOptional') !== false) {
return false;
}
if (preg_match_all('/^\s*Include\s+("?)([^"\s]+)\1/m', $content, $matches)) {
foreach ($matches[2] as $includePath) {
if ($includePath === '/etc/modsecurity/modsecurity.conf' && !file_exists($includePath)) {
return false;
}
}
}
if (preg_match_all('/^\s*SecUnicodeMapFile\s+([^\s]+)\s*/m', $content, $matches)) {
$baseDir = dirname($path);
foreach ($matches[1] as $mapPath) {
$candidates = [];
if (str_starts_with($mapPath, '/')) {
$candidates[] = $mapPath;
} else {
$candidates[] = $baseDir . '/' . $mapPath;
$candidates[] = '/etc/modsecurity/' . $mapPath;
}
$found = false;
foreach ($candidates as $candidate) {
if (file_exists($candidate)) {
$found = true;
break;
}
}
if (!$found) {
return false;
}
}
}
return true;
}
function wafApplySettings(array $params): array
{
$enabled = !empty($params['enabled']);
$paranoia = (int) ($params['paranoia'] ?? 1);
$paranoia = max(1, min(4, $paranoia));
$auditLog = !empty($params['audit_log']);
$whitelistRules = $params['whitelist_rules'] ?? [];
ensureJabaliNginxIncludeFiles();
$prevInclude = file_exists(JABALI_WAF_INCLUDE) ? file_get_contents(JABALI_WAF_INCLUDE) : null;
$prevRules = file_exists(JABALI_WAF_RULES) ? file_get_contents(JABALI_WAF_RULES) : null;
$modSecurityAvailable = isModSecurityModuleAvailable();
if ($enabled) {
if (!$modSecurityAvailable) {
file_put_contents(JABALI_WAF_INCLUDE, "# Managed by Jabali\n# ModSecurity module not available in nginx.\n");
return ['success' => false, 'error' => 'ModSecurity module not available in nginx'];
}
ensureWafUnicodeMapFile();
$coreConfig = findWafCoreConfig();
if (!$coreConfig) {
file_put_contents(JABALI_WAF_INCLUDE, "# Managed by Jabali\nmodsecurity off;\n");
return ['success' => false, 'error' => 'ModSecurity base configuration not found'];
}
$rules = [
'# Managed by Jabali',
'Include "' . $coreConfig . '"',
'SecRuleEngine On',
'SecAuditEngine ' . ($auditLog ? 'On' : 'Off'),
'SecAuditLog /var/log/nginx/modsec_audit.log',
'SecAction "id:900000,phase:1,t:none,pass,setvar:tx.paranoia_level=' . $paranoia . '"',
'SecAction "id:900110,phase:1,t:none,pass,setvar:tx.executing_paranoia_level=' . $paranoia . '"',
];
$whitelistLines = buildWafWhitelistRules($whitelistRules);
if (!empty($whitelistLines)) {
$rules = array_merge($rules, $whitelistLines);
}
$crsIncludes = buildWafCrsIncludeLines();
if (!empty($crsIncludes)) {
$rules = array_merge($rules, $crsIncludes);
}
file_put_contents(JABALI_WAF_RULES, implode("\n", $rules) . "\n");
$include = [
'# Managed by Jabali',
'modsecurity on;',
'modsecurity_rules_file ' . JABALI_WAF_RULES . ';',
];
file_put_contents(JABALI_WAF_INCLUDE, implode("\n", $include) . "\n");
} else {
if ($modSecurityAvailable) {
file_put_contents(JABALI_WAF_INCLUDE, "# Managed by Jabali\nmodsecurity off;\n");
} else {
file_put_contents(JABALI_WAF_INCLUDE, "# Managed by Jabali\n# ModSecurity module not available in nginx.\n");
}
}
ensureNginxServerIncludes([
'include ' . JABALI_WAF_INCLUDE . ';',
]);
$reload = nginxTestAndReload();
if (!($reload['success'] ?? false)) {
if ($prevInclude === null) {
@unlink(JABALI_WAF_INCLUDE);
} else {
file_put_contents(JABALI_WAF_INCLUDE, $prevInclude);
}
if ($prevRules === null) {
@unlink(JABALI_WAF_RULES);
} else {
file_put_contents(JABALI_WAF_RULES, $prevRules);
}
return $reload;
}
return ['success' => true, 'enabled' => $enabled, 'paranoia' => $paranoia, 'audit_log' => $auditLog];
}
function buildWafWhitelistRules(array $rules): array
{
$lines = [];
$ruleBaseId = 120000;
$index = 0;
$matchMap = [
'ip' => ['REMOTE_ADDR', '@ipMatch'],
'uri_exact' => ['REQUEST_URI', '@streq'],
'uri_prefix' => ['REQUEST_URI', '@beginsWith'],
'host' => ['REQUEST_HEADERS:Host', '@streq'],
];
foreach ($rules as $rule) {
if (!is_array($rule)) {
continue;
}
if (isset($rule['enabled']) && !$rule['enabled']) {
continue;
}
$matchType = (string) ($rule['match_type'] ?? '');
$matchValue = trim((string) ($rule['match_value'] ?? ''));
$idsRaw = (string) ($rule['rule_ids'] ?? '');
if ($matchValue === '' || $idsRaw === '') {
continue;
}
if (!isset($matchMap[$matchType])) {
continue;
}
$ids = preg_split('/[,\s]+/', $idsRaw, -1, PREG_SPLIT_NO_EMPTY) ?: [];
$ids = array_values(array_filter(array_map('trim', $ids), function ($id) {
return ctype_digit($id);
}));
if (empty($ids)) {
continue;
}
[$variable, $operator] = $matchMap[$matchType];
$ruleId = $ruleBaseId + $index;
$index++;
$ctlParts = [];
foreach ($ids as $id) {
$ctlParts[] = 'ctl:ruleRemoveById=' . $id;
}
$value = str_replace('"', '\\"', $matchValue);
$lines[] = sprintf(
'SecRule %s "%s %s" "id:%d,phase:1,pass,nolog,%s"',
$variable,
$operator,
$value,
$ruleId,
implode(',', $ctlParts)
);
}
if (!empty($lines)) {
array_unshift($lines, '# Whitelist rules (managed by Jabali)');
}
return $lines;
}
function geoUpdateDatabase(array $params): array
{
$accountId = trim((string) ($params['account_id'] ?? ''));
$licenseKey = trim((string) ($params['license_key'] ?? ''));
$editionIdsRaw = $params['edition_ids'] ?? 'GeoLite2-Country';
$useExisting = !empty($params['use_existing']);
$toolError = ensureGeoIpUpdateTool();
if ($toolError !== null) {
return ['success' => false, 'error' => $toolError];
}
if (!$useExisting && ($accountId === '' || $licenseKey === '')) {
return ['success' => false, 'error' => 'MaxMind Account ID and License Key are required'];
}
$editionIds = [];
if (is_array($editionIdsRaw)) {
$editionIds = $editionIdsRaw;
} else {
$editionIds = preg_split('/[,\s]+/', (string) $editionIdsRaw, -1, PREG_SPLIT_NO_EMPTY) ?: [];
}
$editionIds = array_values(array_filter(array_map('trim', $editionIds)));
if (empty($editionIds)) {
$editionIds = ['GeoLite2-Country'];
}
$configLines = [
'# Managed by Jabali',
'AccountID ' . $accountId,
'LicenseKey ' . $licenseKey,
'EditionIDs ' . implode(' ', $editionIds),
'DatabaseDirectory /usr/share/GeoIP',
];
$config = implode("\n", $configLines) . "\n";
if (!is_dir('/usr/share/GeoIP')) {
@mkdir('/usr/share/GeoIP', 0755, true);
}
$configPaths = [
'/etc/GeoIP.conf',
'/etc/geoipupdate/GeoIP.conf',
];
foreach ($configPaths as $path) {
$dir = dirname($path);
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
if (!$useExisting) {
file_put_contents($path, $config);
@chmod($path, 0600);
} elseif (!file_exists($path)) {
continue;
}
}
exec('geoipupdate -v 2>&1', $output, $code);
$outputText = trim(implode("\n", $output));
if ($code !== 0) {
return [
'success' => false,
'error' => $outputText !== '' ? $outputText : 'geoipupdate failed',
];
}
$paths = [];
foreach ($editionIds as $edition) {
$paths[] = '/usr/share/GeoIP/' . $edition . '.mmdb';
$paths[] = '/usr/local/share/GeoIP/' . $edition . '.mmdb';
}
foreach ($paths as $path) {
if (file_exists($path)) {
return ['success' => true, 'path' => $path];
}
}
return ['success' => false, 'error' => 'GeoIP database not found after update'];
}
function geoUploadDatabase(array $params): array
{
$edition = trim((string) ($params['edition'] ?? 'GeoLite2-Country'));
$content = (string) ($params['content'] ?? '');
if ($content === '') {
return ['success' => false, 'error' => 'No database content provided'];
}
if (!preg_match('/^[A-Za-z0-9._-]+$/', $edition)) {
return ['success' => false, 'error' => 'Invalid edition name'];
}
$decoded = base64_decode($content, true);
if ($decoded === false) {
return ['success' => false, 'error' => 'Invalid database content'];
}
$targetDir = '/usr/share/GeoIP';
if (!is_dir($targetDir)) {
@mkdir($targetDir, 0755, true);
}
$target = $targetDir . '/' . $edition . '.mmdb';
if (file_put_contents($target, $decoded) === false) {
return ['success' => false, 'error' => 'Failed to write GeoIP database'];
}
@chmod($target, 0644);
return ['success' => true, 'path' => $target];
}
function ensureGeoIpUpdateTool(): ?string
{
if (toolExists('geoipupdate')) {
return null;
}
$error = installGeoIpUpdateBinary();
if ($error !== null) {
return $error;
}
if (!toolExists('geoipupdate')) {
return 'geoipupdate is not installed';
}
return null;
}
function installGeoIpUpdateBinary(): ?string
{
$arch = php_uname('m');
$archMap = [
'x86_64' => 'amd64',
'amd64' => 'amd64',
'aarch64' => 'arm64',
'arm64' => 'arm64',
];
$archToken = $archMap[$arch] ?? $arch;
$apiUrl = 'https://api.github.com/repos/maxmind/geoipupdate/releases/latest';
$metadata = @shell_exec('curl -fsSL ' . escapeshellarg($apiUrl) . ' 2>/dev/null');
if (!$metadata) {
$metadata = @shell_exec('wget -qO- ' . escapeshellarg($apiUrl) . ' 2>/dev/null');
}
if (!$metadata) {
return 'Failed to download geoipupdate release metadata';
}
$data = json_decode($metadata, true);
if (!is_array($data)) {
return 'Invalid geoipupdate release metadata';
}
$downloadUrl = null;
foreach (($data['assets'] ?? []) as $asset) {
$name = strtolower((string) ($asset['name'] ?? ''));
$url = (string) ($asset['browser_download_url'] ?? '');
if ($name === '' || $url === '') {
continue;
}
if (strpos($name, 'linux') === false) {
continue;
}
if (strpos($name, $archToken) === false) {
if (!($archToken === 'amd64' && strpos($name, 'x86_64') !== false)) {
continue;
}
}
if (!str_ends_with($name, '.tar.gz') && !str_ends_with($name, '.tgz')) {
continue;
}
$downloadUrl = $url;
break;
}
if (!$downloadUrl) {
return 'No suitable geoipupdate binary found for ' . $arch;
}
$tmpDir = sys_get_temp_dir() . '/jabali-geoipupdate-' . bin2hex(random_bytes(4));
@mkdir($tmpDir, 0755, true);
$archive = $tmpDir . '/geoipupdate.tgz';
$downloadCmd = toolExists('curl')
? 'curl -fsSL ' . escapeshellarg($downloadUrl) . ' -o ' . escapeshellarg($archive)
: 'wget -qO ' . escapeshellarg($archive) . ' ' . escapeshellarg($downloadUrl);
exec($downloadCmd . ' 2>&1', $output, $code);
if ($code !== 0) {
return 'Failed to download geoipupdate binary';
}
exec('tar -xzf ' . escapeshellarg($archive) . ' -C ' . escapeshellarg($tmpDir) . ' 2>&1', $output, $code);
if ($code !== 0) {
return 'Failed to extract geoipupdate archive';
}
$binary = null;
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($tmpDir, FilesystemIterator::SKIP_DOTS));
foreach ($iterator as $file) {
if ($file->isFile() && $file->getFilename() === 'geoipupdate') {
$binary = $file->getPathname();
break;
}
}
if (!$binary) {
return 'geoipupdate binary not found in archive';
}
exec('install -m 0755 ' . escapeshellarg($binary) . ' /usr/local/bin/geoipupdate 2>&1', $output, $code);
if ($code !== 0) {
return 'Failed to install geoipupdate';
}
return null;
}
function ensureGeoIpModuleEnabled(): ?string
{
$modulePaths = [
'/usr/lib/nginx/modules/ngx_http_geoip2_module.so',
'/usr/share/nginx/modules/ngx_http_geoip2_module.so',
];
$modulePath = null;
foreach ($modulePaths as $path) {
if (file_exists($path)) {
$modulePath = $path;
break;
}
}
if (!$modulePath) {
return 'nginx geoip2 module not installed';
}
$modulesEnabledDir = '/etc/nginx/modules-enabled';
if (!is_dir($modulesEnabledDir)) {
return 'nginx modules-enabled directory not found';
}
$alreadyEnabled = false;
foreach (glob($modulesEnabledDir . '/*.conf') ?: [] as $file) {
$contents = file_get_contents($file);
if ($contents !== false && strpos($contents, 'geoip2_module') !== false) {
$alreadyEnabled = true;
break;
}
}
if ($alreadyEnabled) {
return null;
}
$loadLine = 'load_module ' . $modulePath . ';';
$target = $modulesEnabledDir . '/50-jabali-geoip2.conf';
file_put_contents($target, $loadLine . "\n");
return null;
}
function geoApplyRules(array $params): array
{
$rules = $params['rules'] ?? [];
$activeRules = array_values(array_filter($rules, function ($rule) {
return !isset($rule['is_active']) || !empty($rule['is_active']);
}));
$allow = array_values(array_filter($activeRules, fn ($rule) => ($rule['action'] ?? '') === 'allow'));
$block = array_values(array_filter($activeRules, fn ($rule) => ($rule['action'] ?? '') === 'block'));
ensureJabaliNginxIncludeFiles();
$prevGeoHttp = file_exists(JABALI_GEO_HTTP_CONF) ? file_get_contents(JABALI_GEO_HTTP_CONF) : null;
$prevGeoInclude = file_exists(JABALI_GEO_INCLUDE) ? file_get_contents(JABALI_GEO_INCLUDE) : null;
if (empty($allow) && empty($block)) {
file_put_contents(JABALI_GEO_HTTP_CONF, "# Managed by Jabali\n# No geo rules enabled\n");
file_put_contents(JABALI_GEO_INCLUDE, "# Managed by Jabali\n");
ensureNginxServerIncludes([
'include ' . JABALI_GEO_INCLUDE . ';',
]);
$reload = nginxTestAndReload();
if (!($reload['success'] ?? false)) {
return $reload;
}
return ['success' => true, 'rules' => 0];
}
$mmdbPaths = [
'/usr/share/GeoIP/GeoLite2-Country.mmdb',
'/usr/local/share/GeoIP/GeoLite2-Country.mmdb',
];
$mmdb = null;
foreach ($mmdbPaths as $path) {
if (file_exists($path)) {
$mmdb = $path;
break;
}
}
if (!$mmdb) {
$update = geoUpdateDatabase([
'use_existing' => true,
'edition_ids' => 'GeoLite2-Country',
]);
if (!empty($update['success'])) {
$mmdb = $update['path'] ?? null;
}
}
if (!$mmdb) {
return ['success' => false, 'error' => 'GeoIP database not found. Update the GeoIP database in the panel.'];
}
$geoModule = ensureGeoIpModuleEnabled();
if ($geoModule !== null) {
return ['success' => false, 'error' => $geoModule];
}
$countryVar = '$jabali_geo_country_code';
$mapName = !empty($allow) ? '$jabali_geo_allow' : '$jabali_geo_block';
$mapLines = [
"map {$countryVar} {$mapName} {",
' default 0;',
];
$ruleset = !empty($allow) ? $allow : $block;
foreach ($ruleset as $rule) {
$code = strtoupper(trim($rule['country_code'] ?? ''));
if ($code === '') {
continue;
}
$mapLines[] = " {$code} 1;";
}
$mapLines[] = '}';
$httpConf = [
'# Managed by Jabali',
"geoip2 {$mmdb} {",
" {$countryVar} country iso_code;",
'}',
'',
...$mapLines,
];
file_put_contents(JABALI_GEO_HTTP_CONF, implode("\n", $httpConf) . "\n");
if (!empty($allow)) {
$geoInclude = "# Managed by Jabali\nif ({$mapName} = 0) { return 403; }\n";
} else {
$geoInclude = "# Managed by Jabali\nif ({$mapName} = 1) { return 403; }\n";
}
file_put_contents(JABALI_GEO_INCLUDE, $geoInclude);
ensureNginxServerIncludes([
'include ' . JABALI_GEO_INCLUDE . ';',
]);
$reload = nginxTestAndReload();
if (!($reload['success'] ?? false)) {
if ($prevGeoHttp === null) {
@unlink(JABALI_GEO_HTTP_CONF);
} else {
file_put_contents(JABALI_GEO_HTTP_CONF, $prevGeoHttp);
}
if ($prevGeoInclude === null) {
@unlink(JABALI_GEO_INCLUDE);
} else {
file_put_contents(JABALI_GEO_INCLUDE, $prevGeoInclude);
}
return $reload;
}
return ['success' => true, 'rules' => count($ruleset)];
}
function databasePersistTuning(array $params): array
{
$name = $params['name'] ?? '';
$value = $params['value'] ?? '';
if (!preg_match('/^[a-zA-Z0-9_]+$/', $name)) {
return ['success' => false, 'error' => 'Invalid variable name'];
}
$configDir = '/etc/mysql/mariadb.conf.d';
if (!is_dir($configDir)) {
$configDir = '/etc/mysql/conf.d';
}
if (!is_dir($configDir)) {
return ['success' => false, 'error' => 'MySQL configuration directory not found'];
}
$file = $configDir . '/90-jabali-tuning.cnf';
$lines = file_exists($file) ? file($file, FILE_IGNORE_NEW_LINES) : [];
if (empty($lines)) {
$lines = ['# Managed by Jabali', '[mysqld]'];
}
$hasSection = false;
$found = false;
foreach ($lines as $index => $line) {
if (trim($line) === '[mysqld]') {
$hasSection = true;
}
if (preg_match('/^\s*' . preg_quote($name, '/') . '\s*=/i', $line)) {
$lines[$index] = $name . ' = ' . $value;
$found = true;
}
}
if (!$hasSection) {
$lines[] = '[mysqld]';
}
if (!$found) {
$lines[] = $name . ' = ' . $value;
}
file_put_contents($file, implode("\n", $lines) . "\n");
return ['success' => true, 'message' => 'Configuration persisted'];
}
// ============ MAIN ============
function main(): void
{
@mkdir(dirname(SOCKET_PATH), 0755, true);
@mkdir(dirname(LOG_FILE), 0755, true);
if (file_exists(SOCKET_PATH)) {
unlink(SOCKET_PATH);
}
// SSH Key Management Functions
function sshListKeys(array $params): array
{
$username = $params['username'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$userInfo = posix_getpwnam($username);
if (!$userInfo) {
return ['success' => false, 'error' => 'User not found'];
}
$sshDir = $userInfo['dir'] . '/.ssh';
$authKeysFile = $sshDir . '/authorized_keys';
if (!file_exists($authKeysFile)) {
return ['success' => true, 'keys' => []];
}
$keys = [];
$lines = file($authKeysFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $index => $line) {
$line = trim($line);
if (empty($line) || strpos($line, '#') === 0) {
continue;
}
// Parse SSH key: type base64 comment
$parts = preg_split('/\s+/', $line, 3);
if (count($parts) < 2) {
continue;
}
$type = $parts[0];
$keyData = $parts[1];
$comment = $parts[2] ?? 'Key ' . ($index + 1);
// Generate fingerprint
$tempFile = tempnam(sys_get_temp_dir(), 'sshkey');
file_put_contents($tempFile, $line);
exec("ssh-keygen -lf " . escapeshellarg($tempFile) . " 2>&1", $fpOutput);
@unlink($tempFile);
$fingerprint = isset($fpOutput[0]) ? preg_replace('/^\d+\s+/', '', $fpOutput[0]) : substr($keyData, 0, 20) . '...';
$keys[] = [
'id' => md5($line),
'name' => $comment,
'type' => $type,
'fingerprint' => $fingerprint,
'key' => substr($keyData, 0, 30) . '...',
];
}
return ['success' => true, 'keys' => $keys];
}
function sshAddKey(array $params): array
{
$username = $params['username'] ?? '';
$name = $params['name'] ?? '';
$publicKey = $params['public_key'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
if (empty($publicKey)) {
return ['success' => false, 'error' => 'Public key is required'];
}
// Validate key format
$publicKey = trim($publicKey);
if (!preg_match('/^(ssh-rsa|ssh-ed25519|ssh-dss|ecdsa-sha2-\S+)\s+[A-Za-z0-9+\/=]+/', $publicKey)) {
return ['success' => false, 'error' => 'Invalid SSH public key format'];
}
$userInfo = posix_getpwnam($username);
if (!$userInfo) {
return ['success' => false, 'error' => 'User not found'];
}
$uid = $userInfo['uid'];
$gid = $userInfo['gid'];
$sshDir = $userInfo['dir'] . '/.ssh';
$authKeysFile = $sshDir . '/authorized_keys';
// Create .ssh directory if it doesn't exist
if (!is_dir($sshDir)) {
mkdir($sshDir, 0700, true);
chown($sshDir, $uid);
chgrp($sshDir, $gid);
}
// Add comment if not present
if (!preg_match('/\s+\S+$/', $publicKey) || preg_match('/==$/', $publicKey)) {
$publicKey .= ' ' . $name;
}
// Check if key already exists
if (file_exists($authKeysFile)) {
$existingKeys = file_get_contents($authKeysFile);
$keyParts = preg_split('/\s+/', $publicKey);
if (count($keyParts) >= 2 && strpos($existingKeys, $keyParts[1]) !== false) {
return ['success' => false, 'error' => 'This key already exists'];
}
}
// Append key to authorized_keys
$result = file_put_contents($authKeysFile, $publicKey . "\n", FILE_APPEND | LOCK_EX);
if ($result === false) {
return ['success' => false, 'error' => 'Failed to write authorized_keys file'];
}
// Set proper permissions
chmod($authKeysFile, 0600);
chown($authKeysFile, $uid);
chgrp($authKeysFile, $gid);
logger("SSH key added for user $username: $name");
return ['success' => true, 'message' => 'SSH key added successfully'];
}
function sshDeleteKey(array $params): array
{
$username = $params['username'] ?? '';
$keyId = $params['key_id'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
if (empty($keyId)) {
return ['success' => false, 'error' => 'Key ID is required'];
}
$userInfo = posix_getpwnam($username);
if (!$userInfo) {
return ['success' => false, 'error' => 'User not found'];
}
$uid = $userInfo['uid'];
$gid = $userInfo['gid'];
$authKeysFile = $userInfo['dir'] . '/.ssh/authorized_keys';
if (!file_exists($authKeysFile)) {
return ['success' => false, 'error' => 'No SSH keys found'];
}
$lines = file($authKeysFile, FILE_IGNORE_NEW_LINES);
$newLines = [];
$found = false;
foreach ($lines as $line) {
if (md5(trim($line)) === $keyId) {
$found = true;
continue; // Skip this key
}
$newLines[] = $line;
}
if (!$found) {
return ['success' => false, 'error' => 'Key not found'];
}
// Write back
file_put_contents($authKeysFile, implode("\n", $newLines) . (count($newLines) > 0 ? "\n" : ""));
chmod($authKeysFile, 0600);
chown($authKeysFile, $uid);
chgrp($authKeysFile, $gid);
logger("SSH key deleted for user $username: $keyId");
return ['success' => true, 'message' => 'SSH key deleted successfully'];
}
$socket = socket_create(AF_UNIX, SOCK_STREAM, 0);
if ($socket === false) {
logger("Failed to create socket", 'ERROR');
exit(1);
}
if (socket_bind($socket, SOCKET_PATH) === false) {
logger("Failed to bind socket", 'ERROR');
exit(1);
}
if (socket_listen($socket, 5) === false) {
logger("Failed to listen", 'ERROR');
exit(1);
}
chmod(SOCKET_PATH, 0660);
chown(SOCKET_PATH, 'root');
chgrp(SOCKET_PATH, 'www-data');
file_put_contents(PID_FILE, getmypid());
logger("Jabali Agent started on " . SOCKET_PATH);
pcntl_signal(SIGTERM, function () use ($socket) {
logger("Shutting down...");
socket_close($socket);
@unlink(SOCKET_PATH);
@unlink(PID_FILE);
exit(0);
});
pcntl_signal(SIGINT, function () use ($socket) {
logger("Shutting down...");
socket_close($socket);
@unlink(SOCKET_PATH);
@unlink(PID_FILE);
exit(0);
});
// Make socket non-blocking for signal handling
socket_set_nonblock($socket);
while (true) {
pcntl_signal_dispatch();
$client = @socket_accept($socket);
if ($client === false) {
usleep(50000); // 50ms delay
continue;
}
// Set short timeout for reading
socket_set_option($client, SOL_SOCKET, SO_RCVTIMEO, ['sec' => 30, 'usec' => 0]);
$data = '';
while (($chunk = @socket_read($client, 65536)) !== false && $chunk !== '') {
$data .= $chunk;
// Check if we have complete JSON
if (strlen($chunk) < 65536) {
break;
}
}
if (!empty($data)) {
$request = json_decode($data, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$response = ['success' => false, 'error' => 'Invalid JSON: ' . json_last_error_msg()];
} else {
try {
$response = handleAction($request);
} catch (Throwable $e) {
logger("[ERROR] Exception in handleAction: " . $e->getMessage());
$response = ['success' => false, 'error' => 'Internal error: ' . $e->getMessage()];
}
}
socket_write($client, json_encode($response));
}
socket_close($client);
}
}
// ============ MYSQL MANAGEMENT ============
function getMysqlConnection(): ?mysqli
{
$socket = "/var/run/mysqld/mysqld.sock";
// Try socket connection as root
$conn = @new mysqli("localhost", "root", "", "", 0, $socket);
if ($conn->connect_error) {
// Try TCP
$conn = @new mysqli("127.0.0.1", "root", "");
if ($conn->connect_error) {
return null;
}
}
return $conn;
}
function getMysqlRootCredentials(): ?array
{
// Try to read from debian.cnf first (Debian/Ubuntu)
$debianCnf = '/etc/mysql/debian.cnf';
if (file_exists($debianCnf)) {
$content = file_get_contents($debianCnf);
if (preg_match('/user\s*=\s*(\S+)/', $content, $userMatch) &&
preg_match('/password\s*=\s*(\S+)/', $content, $passMatch)) {
return ['user' => $userMatch[1], 'password' => $passMatch[1]];
}
}
// Try root with no password (socket auth)
return ['user' => 'root', 'password' => ''];
}
function mysqlListDatabases(array $params): array
{
$username = $params["username"] ?? "";
$conn = getMysqlConnection();
if (!$conn) {
return ["success" => false, "error" => "Cannot connect to MySQL"];
}
$databases = [];
$prefix = $username . "_";
// Get database sizes
$sizeQuery = "SELECT table_schema AS db_name,
SUM(data_length + index_length) AS size_bytes
FROM information_schema.tables
GROUP BY table_schema";
$sizeResult = $conn->query($sizeQuery);
$dbSizes = [];
if ($sizeResult) {
while ($row = $sizeResult->fetch_assoc()) {
$dbSizes[$row['db_name']] = (int)($row['size_bytes'] ?? 0);
}
}
$result = $conn->query("SHOW DATABASES");
while ($row = $result->fetch_array()) {
$dbName = $row[0];
if (strpos($dbName, $prefix) === 0 || $username === "admin") {
$sizeBytes = $dbSizes[$dbName] ?? 0;
$databases[] = [
"name" => $dbName,
"size_bytes" => $sizeBytes,
"size_human" => formatBytes($sizeBytes),
];
}
}
$conn->close();
return ["success" => true, "databases" => $databases];
}
function mysqlCreateDatabase(array $params): array
{
$username = $params["username"] ?? "";
$database = $params["database"] ?? "";
if (!validateUsername($username)) {
return ["success" => false, "error" => "Invalid username"];
}
// Check if already prefixed
$prefix = $username . "_";
$cleanDb = preg_replace("/[^a-zA-Z0-9_]/", "", $database);
if (strpos($cleanDb, $prefix) === 0) {
$dbName = $cleanDb;
} else {
$dbName = $prefix . $cleanDb;
}
if (strlen($dbName) > 64) {
return ["success" => false, "error" => "Database name too long"];
}
$conn = getMysqlConnection();
if (!$conn) {
return ["success" => false, "error" => "Cannot connect to MySQL"];
}
$dbName = $conn->real_escape_string($dbName);
if (!$conn->query("CREATE DATABASE IF NOT EXISTS `$dbName` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")) {
$error = $conn->error;
$conn->close();
return ["success" => false, "error" => "Failed to create database: $error"];
}
$conn->close();
logger("Created MySQL database: $dbName for user $username");
return ["success" => true, "message" => "Database created", "database" => $dbName];
}
function mysqlDeleteDatabase(array $params): array
{
$username = $params["username"] ?? "";
$database = $params["database"] ?? "";
if (!validateUsername($username)) {
return ["success" => false, "error" => "Invalid username"];
}
$prefix = $username . "_";
if (strpos($database, $prefix) !== 0 && $username !== "admin") {
return ["success" => false, "error" => "Access denied"];
}
$conn = getMysqlConnection();
if (!$conn) {
return ["success" => false, "error" => "Cannot connect to MySQL"];
}
$dbName = $conn->real_escape_string($database);
if (!$conn->query("DROP DATABASE IF EXISTS `$dbName`")) {
$error = $conn->error;
$conn->close();
return ["success" => false, "error" => "Failed to delete database: $error"];
}
$conn->close();
logger("Deleted MySQL database: $dbName for user $username");
return ["success" => true, "message" => "Database deleted"];
}
function mysqlListUsers(array $params): array
{
$username = $params["username"] ?? "";
$conn = getMysqlConnection();
if (!$conn) {
return ["success" => false, "error" => "Cannot connect to MySQL"];
}
$users = [];
$prefix = $username . "_";
$result = $conn->query("SELECT User, Host FROM mysql.user WHERE User != 'root' AND User != '' AND User NOT LIKE 'mysql.%'");
while ($row = $result->fetch_assoc()) {
if (strpos($row["User"], $prefix) === 0 || $username === "admin") {
$users[] = ["user" => $row["User"], "host" => $row["Host"]];
}
}
$conn->close();
return ["success" => true, "users" => $users];
}
function mysqlCreateUser(array $params): array
{
$username = $params["username"] ?? "";
$dbUser = $params["db_user"] ?? "";
$password = $params["password"] ?? "";
$host = $params["host"] ?? "localhost";
if (!validateUsername($username)) {
return ["success" => false, "error" => "Invalid username"];
}
if (empty($password) || strlen($password) < 8) {
return ["success" => false, "error" => "Password must be at least 8 characters"];
}
// Check if already prefixed
$prefix = $username . "_";
$cleanDbUser = preg_replace("/[^a-zA-Z0-9_]/", "", $dbUser);
if (strpos($cleanDbUser, $prefix) === 0) {
$dbUserName = $cleanDbUser;
} else {
$dbUserName = $prefix . $cleanDbUser;
}
if (strlen($dbUserName) > 32) {
return ["success" => false, "error" => "Username too long"];
}
$conn = getMysqlConnection();
if (!$conn) {
return ["success" => false, "error" => "Cannot connect to MySQL"];
}
$dbUserName = $conn->real_escape_string($dbUserName);
$host = $conn->real_escape_string($host);
$password = $conn->real_escape_string($password);
if (!$conn->query("CREATE USER '$dbUserName'@'$host' IDENTIFIED BY '$password'")) {
$error = $conn->error;
$conn->close();
return ["success" => false, "error" => "Failed to create user: $error"];
}
$conn->close();
logger("Created MySQL user: $dbUserName@$host for user $username");
return ["success" => true, "message" => "User created", "db_user" => $dbUserName];
}
function mysqlDeleteUser(array $params): array
{
$username = $params["username"] ?? "";
$dbUser = $params["db_user"] ?? "";
$host = $params["host"] ?? "localhost";
if (!validateUsername($username)) {
return ["success" => false, "error" => "Invalid username"];
}
$prefix = $username . "_";
if (strpos($dbUser, $prefix) !== 0 && $username !== "admin") {
return ["success" => false, "error" => "Access denied"];
}
$conn = getMysqlConnection();
if (!$conn) {
return ["success" => false, "error" => "Cannot connect to MySQL"];
}
$dbUser = $conn->real_escape_string($dbUser);
$host = $conn->real_escape_string($host);
if (!$conn->query("DROP USER IF EXISTS '$dbUser'@'$host'")) {
$error = $conn->error;
$conn->close();
return ["success" => false, "error" => "Failed to delete user: $error"];
}
$conn->close();
logger("Deleted MySQL user: $dbUser@$host for user $username");
return ["success" => true, "message" => "User deleted"];
}
function mysqlChangePassword(array $params): array
{
$username = $params["username"] ?? "";
$dbUser = $params["db_user"] ?? "";
$password = $params["password"] ?? "";
$host = $params["host"] ?? "localhost";
if (!validateUsername($username)) {
return ["success" => false, "error" => "Invalid username"];
}
$prefix = $username . "_";
if (strpos($dbUser, $prefix) !== 0 && $username !== "admin") {
return ["success" => false, "error" => "Access denied"];
}
if (empty($password) || strlen($password) < 8) {
return ["success" => false, "error" => "Password must be at least 8 characters"];
}
$conn = getMysqlConnection();
if (!$conn) {
return ["success" => false, "error" => "Cannot connect to MySQL"];
}
$dbUser = $conn->real_escape_string($dbUser);
$host = $conn->real_escape_string($host);
$password = $conn->real_escape_string($password);
if (!$conn->query("ALTER USER '$dbUser'@'$host' IDENTIFIED BY '$password'")) {
$error = $conn->error;
$conn->close();
return ["success" => false, "error" => "Failed to change password: $error"];
}
$conn->query("FLUSH PRIVILEGES");
$conn->close();
logger("Changed password for MySQL user: $dbUser@$host");
return ["success" => true, "message" => "Password changed"];
}
function mysqlGrantPrivileges(array $params): array
{
$username = $params["username"] ?? "";
$dbUser = $params["db_user"] ?? "";
$database = $params["database"] ?? "";
$privileges = $params["privileges"] ?? ["ALL"];
$host = $params["host"] ?? "localhost";
if (!validateUsername($username)) {
return ["success" => false, "error" => "Invalid username"];
}
$prefix = $username . "_";
if ($username !== "admin") {
if (strpos($dbUser, $prefix) !== 0) {
return ["success" => false, "error" => "Access denied to user"];
}
if (strpos($database, $prefix) !== 0 && $database !== "*") {
return ["success" => false, "error" => "Access denied to database"];
}
}
$conn = getMysqlConnection();
if (!$conn) {
return ["success" => false, "error" => "Cannot connect to MySQL"];
}
$dbUser = $conn->real_escape_string($dbUser);
$host = $conn->real_escape_string($host);
$database = $conn->real_escape_string($database);
$allowedPrivs = ["ALL", "SELECT", "INSERT", "UPDATE", "DELETE", "CREATE", "DROP", "INDEX", "ALTER", "EXECUTE", "CREATE VIEW", "SHOW VIEW"];
$privList = [];
foreach ($privileges as $priv) {
$priv = strtoupper(trim($priv));
if (in_array($priv, $allowedPrivs)) {
$privList[] = $priv;
}
}
if (empty($privList)) {
$privList = ["ALL"];
}
$privString = implode(", ", $privList);
$dbTarget = $database === "*" ? "*.*" : "`$database`.*";
if (!$conn->query("GRANT $privString ON $dbTarget TO '$dbUser'@'$host'")) {
$error = $conn->error;
$conn->close();
return ["success" => false, "error" => "Failed to grant privileges: $error"];
}
$conn->query("FLUSH PRIVILEGES");
$conn->close();
logger("Granted $privString on $database to $dbUser@$host");
return ["success" => true, "message" => "Privileges granted"];
}
function mysqlRevokePrivileges(array $params): array
{
$username = $params["username"] ?? "";
$dbUser = $params["db_user"] ?? "";
$database = $params["database"] ?? "";
$host = $params["host"] ?? "localhost";
if (!validateUsername($username)) {
return ["success" => false, "error" => "Invalid username"];
}
$prefix = $username . "_";
if ($username !== "admin") {
if (strpos($dbUser, $prefix) !== 0) {
return ["success" => false, "error" => "Access denied to user"];
}
}
$conn = getMysqlConnection();
if (!$conn) {
return ["success" => false, "error" => "Cannot connect to MySQL"];
}
$dbUser = $conn->real_escape_string($dbUser);
$host = $conn->real_escape_string($host);
$database = $conn->real_escape_string($database);
$dbTarget = $database === "*" ? "*.*" : "`$database`.*";
if (!$conn->query("REVOKE ALL PRIVILEGES ON $dbTarget FROM '$dbUser'@'$host'")) {
$error = $conn->error;
$conn->close();
return ["success" => false, "error" => "Failed to revoke privileges: $error"];
}
$conn->query("FLUSH PRIVILEGES");
$conn->close();
logger("Revoked privileges on $database from $dbUser@$host");
return ["success" => true, "message" => "Privileges revoked"];
}
function mysqlGetPrivileges(array $params): array
{
$username = $params["username"] ?? "";
$dbUser = $params["db_user"] ?? "";
$host = $params["host"] ?? "localhost";
if (!validateUsername($username)) {
return ["success" => false, "error" => "Invalid username"];
// Signal handling for graceful shutdown
$shutdown = false;
pcntl_async_signals(true);
pcntl_signal(SIGTERM, function() use (&$shutdown) {
global $socket;
$shutdown = true;
if ($socket) {
socket_close($socket);
}
exit(0);
});
pcntl_signal(SIGINT, function() use (&$shutdown) {
global $socket;
$shutdown = true;
if ($socket) {
socket_close($socket);
}
exit(0);
});
while (!$shutdown) {
// Set socket timeout so it doesn't block forever
socket_set_option($socket, SOL_SOCKET, SO_RCVTIMEO, ['sec' => 1, 'usec' => 0]);
$client = @socket_accept($socket);
if ($client === false) {
// Timeout or error - check if we should shutdown
if ($shutdown) break;
continue;
}
handleConnection($client);
}
socket_close($socket);
unlink($socketPath);
}
$conn = getMysqlConnection();
if (!$conn) {
return ["success" => false, "error" => "Cannot connect to MySQL"];
}
$dbUser = $conn->real_escape_string($dbUser);
$host = $conn->real_escape_string($host);
$rawPrivileges = [];
$parsedPrivileges = [];
$result = $conn->query("SHOW GRANTS FOR '$dbUser'@'$host'");
if ($result) {
while ($row = $result->fetch_array()) {
$grant = $row[0];
$rawPrivileges[] = $grant;
// Parse: GRANT SELECT, INSERT ON `db`.* TO user
// Or: GRANT ALL PRIVILEGES ON `db`.* TO user
if (preg_match('/GRANT\s+(.+?)\s+ON\s+[`"\']*([^`"\'\.\s]+)[`"\']*\.\*\s+TO/i', $grant, $matches)) {
$privsStr = trim($matches[1]);
$db = trim($matches[2], "`\'\"");
if ($db !== "*") {
$privList = [];
if (stripos($privsStr, "ALL PRIVILEGES") !== false) {
$privList = ["ALL PRIVILEGES"];
} elseif (stripos($privsStr, "ALL") === 0) {
$privList = ["ALL PRIVILEGES"];
} else {
$parts = explode(",", $privsStr);
foreach ($parts as $p) {
$p = trim($p);
if (!empty($p) && stripos($p, "GRANT OPTION") === false) {
$privList[] = strtoupper($p);
}
}
}
if (!empty($privList)) {
$parsedPrivileges[] = [
"database" => $db,
"privileges" => $privList
];
}
}
}
}
}
$conn->close();
return ["success" => true, "privileges" => $rawPrivileges, "parsed" => $parsedPrivileges];
}
main();
// Domain Management Functions
function domainCreate(array $params): array
{
$username = $params['username'] ?? '';
$domain = $params['domain'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
// Validate domain format
$domain = strtolower(trim($domain));
if (!preg_match('/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*\.[a-z]{2,}$/', $domain)) {
return ['success' => false, 'error' => 'Invalid domain format'];
}
// Check if domain already exists
$vhostFile = "/etc/nginx/sites-available/{$domain}.conf";
if (file_exists($vhostFile)) {
return ['success' => false, 'error' => 'Domain already exists'];
}
// Get user info
$userInfo = posix_getpwnam($username);
if (!$userInfo) {
return ['success' => false, 'error' => 'User not found'];
}
$userHome = $userInfo['dir'];
$uid = $userInfo['uid'];
$gid = $userInfo['gid'];
ensureJabaliNginxIncludeFiles();
// Create domain directories
$domainRoot = "{$userHome}/domains/{$domain}";
$publicHtml = "{$domainRoot}/public_html";
$logs = "{$domainRoot}/logs";
$dirs = [$domainRoot, $publicHtml, $logs];
foreach ($dirs as $dir) {
if (!is_dir($dir)) {
if (!mkdir($dir, 0755, true)) {
return ['success' => false, 'error' => "Failed to create directory: {$dir}"];
}
chown($dir, $uid);
chgrp($dir, $gid);
}
}
// Create default index.html
$indexContent = '<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Welcome to ' . $domain . '</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: system-ui, sans-serif; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; }
.container { text-align: center; padding: 40px; background: rgba(255,255,255,0.1); border-radius: 16px; }
h1 { font-size: 2.5rem; margin-bottom: 16px; }
p { font-size: 1.1rem; opacity: 0.9; }
.domain { display: inline-block; margin-top: 20px; padding: 8px 16px; background: rgba(255,255,255,0.2); border-radius: 8px; font-family: monospace; }
</style>
</head>
<body>
<div class="container">
<h1>Welcome!</h1>
<p>Your website is ready. Upload your files to get started.</p>
<span class="domain">' . $domain . '</span>
</div>
</body>
</html>';
$indexFile = "{$publicHtml}/index.html";
if (!file_exists($indexFile)) {
file_put_contents($indexFile, $indexContent);
chown($indexFile, $uid);
chgrp($indexFile, $gid);
}
// Ensure FPM pool exists for this user (don't reload - caller handles that)
createFpmPool($username, false);
$fpmSocket = getFpmSocketPath($username);
// Create Nginx virtual host configuration
$vhostContent = generateNginxVhost($domain, $publicHtml, $logs, $fpmSocket);
if (file_put_contents($vhostFile, $vhostContent) === false) {
return ['success' => false, 'error' => 'Failed to create virtual host configuration'];
}
// Enable the site (create symlink)
$enableCmd = "ln -sf " . escapeshellarg($vhostFile) . " /etc/nginx/sites-enabled/" . escapeshellarg("{$domain}.conf") . " 2>&1";
exec($enableCmd, $output, $returnCode);
if ($returnCode !== 0) {
unlink($vhostFile);
return ['success' => false, 'error' => 'Failed to enable site: ' . implode("\n", $output)];
}
// Set ACLs for Nginx to access files
exec("setfacl -m u:www-data:x " . escapeshellarg($userHome));
exec("setfacl -m u:www-data:x " . escapeshellarg("{$userHome}/domains"));
exec("setfacl -m u:www-data:rx " . escapeshellarg($domainRoot));
exec("setfacl -R -m u:www-data:rx " . escapeshellarg($publicHtml));
exec("setfacl -R -m u:www-data:rwx " . escapeshellarg($logs));
exec("setfacl -R -d -m u:www-data:rx " . escapeshellarg($publicHtml));
exec("setfacl -R -d -m u:www-data:rwx " . escapeshellarg($logs));
// Reload Nginx
exec("nginx -t 2>&1", $testOutput, $testCode);
if ($testCode !== 0) {
unlink($vhostFile);
unlink("/etc/nginx/sites-enabled/{$domain}.conf");
return ['success' => false, 'error' => 'Nginx config test failed: ' . implode("\n", $testOutput)];
}
exec("systemctl reload nginx 2>&1", $reloadOutput, $reloadCode);
if ($reloadCode !== 0) {
return ['success' => false, 'error' => 'Site created but failed to reload Nginx: ' . implode("\n", $reloadOutput)];
}
// Store domain in user's domain list
$domainListFile = "{$userHome}/.domains";
$domains = [];
if (file_exists($domainListFile)) {
$domains = json_decode(file_get_contents($domainListFile), true) ?: [];
}
$domains[$domain] = [
'created' => date('Y-m-d H:i:s'),
'document_root' => $publicHtml,
'ssl' => false
];
file_put_contents($domainListFile, json_encode($domains, JSON_PRETTY_PRINT));
chown($domainListFile, $uid);
chgrp($domainListFile, $gid);
return [
'success' => true,
'domain' => $domain,
'document_root' => $publicHtml,
'message' => "Domain {$domain} created successfully"
];
}
function updateVhostServerNames(string $vhostFile, callable $mutator): array
{
if (!file_exists($vhostFile)) {
return ['success' => false, 'error' => 'Domain configuration not found'];
}
$original = file_get_contents($vhostFile);
if ($original === false) {
return ['success' => false, 'error' => 'Failed to read virtual host configuration'];
}
$updated = preg_replace_callback('/server_name\\s+([^;]+);/i', function ($matches) use ($mutator) {
$names = preg_split('/\\s+/', trim($matches[1]));
$names = array_values(array_filter($names));
$names = $mutator($names);
$names = array_values(array_unique($names));
return ' server_name ' . implode(' ', $names) . ';';
}, $original, -1, $count);
if ($count === 0 || $updated === null) {
return ['success' => false, 'error' => 'Failed to update server_name entries'];
}
if (file_put_contents($vhostFile, $updated) === false) {
return ['success' => false, 'error' => 'Failed to write virtual host configuration'];
}
exec("nginx -t 2>&1", $testOutput, $testCode);
if ($testCode !== 0) {
// rollback
file_put_contents($vhostFile, $original);
return ['success' => false, 'error' => 'Nginx config test failed: ' . implode("\n", $testOutput)];
}
exec("systemctl reload nginx 2>&1", $reloadOutput, $reloadCode);
if ($reloadCode !== 0) {
return ['success' => false, 'error' => 'Failed to reload Nginx: ' . implode("\n", $reloadOutput)];
}
return ['success' => true];
}
function databaseGetVariables(array $params): array
{
$names = $params['names'] ?? [];
if (!is_array($names) || $names === []) {
return ['success' => false, 'error' => 'No variables requested'];
}
$safeNames = [];
foreach ($names as $name) {
if (preg_match('/^[a-zA-Z0-9_]+$/', (string) $name)) {
$safeNames[] = $name;
}
}
if ($safeNames === []) {
return ['success' => false, 'error' => 'No valid variable names'];
}
$inList = implode("','", array_map(fn($name) => str_replace("'", "\\'", (string) $name), $safeNames));
$query = "SHOW VARIABLES WHERE Variable_name IN ('{$inList}')";
$command = 'mysql --batch --skip-column-names -e ' . escapeshellarg($query) . ' 2>&1';
exec($command, $output, $code);
if ($code !== 0) {
return ['success' => false, 'error' => implode("\n", $output)];
}
$variables = [];
foreach ($output as $line) {
$line = trim($line);
if ($line === '') {
continue;
}
$parts = explode("\t", $line, 2);
$name = $parts[0] ?? null;
if ($name === null || $name === '') {
continue;
}
$variables[] = [
'name' => $name,
'value' => $parts[1] ?? '',
];
}
return ['success' => true, 'variables' => $variables];
}
function databaseSetGlobal(array $params): array
{
$name = $params['name'] ?? '';
$value = (string) ($params['value'] ?? '');
if (!preg_match('/^[a-zA-Z0-9_]+$/', $name)) {
return ['success' => false, 'error' => 'Invalid variable name'];
}
$escapedValue = addslashes($value);
$query = "SET GLOBAL {$name} = '{$escapedValue}'";
$command = 'mysql -e ' . escapeshellarg($query) . ' 2>&1';
exec($command, $output, $code);
if ($code !== 0) {
return ['success' => false, 'error' => implode("\n", $output)];
}
return ['success' => true];
}
function domainAliasAdd(array $params): array
{
$username = $params['username'] ?? '';
$domain = strtolower(trim($params['domain'] ?? ''));
$alias = strtolower(trim($params['alias'] ?? ''));
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
if (!validateDomain($domain) || !validateDomain($alias)) {
return ['success' => false, 'error' => 'Invalid domain format'];
}
if ($alias === $domain || $alias === "www.{$domain}") {
return ['success' => false, 'error' => 'Alias cannot match the primary domain'];
}
$vhostFile = "/etc/nginx/sites-available/{$domain}.conf";
foreach (glob('/etc/nginx/sites-available/*.conf') as $file) {
if (!is_readable($file)) {
continue;
}
$content = file_get_contents($file);
if ($content === false) {
continue;
}
if (preg_match('/server_name\\s+[^;]*\\b' . preg_quote($alias, '/') . '\\b/i', $content) ||
preg_match('/server_name\\s+[^;]*\\b' . preg_quote("www.{$alias}", '/') . '\\b/i', $content)) {
if ($file !== $vhostFile) {
return ['success' => false, 'error' => 'Alias already exists on another domain'];
}
}
}
$result = updateVhostServerNames($vhostFile, function (array $names) use ($alias) {
if (!in_array($alias, $names, true)) {
$names[] = $alias;
}
$wwwAlias = "www.{$alias}";
if (!in_array($wwwAlias, $names, true)) {
$names[] = $wwwAlias;
}
return $names;
});
if (!($result['success'] ?? false)) {
return $result;
}
return ['success' => true, 'message' => "Alias {$alias} added to {$domain}"];
}
function domainAliasRemove(array $params): array
{
$username = $params['username'] ?? '';
$domain = strtolower(trim($params['domain'] ?? ''));
$alias = strtolower(trim($params['alias'] ?? ''));
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
if (!validateDomain($domain) || !validateDomain($alias)) {
return ['success' => false, 'error' => 'Invalid domain format'];
}
$vhostFile = "/etc/nginx/sites-available/{$domain}.conf";
$result = updateVhostServerNames($vhostFile, function (array $names) use ($alias) {
$wwwAlias = "www.{$alias}";
return array_values(array_filter($names, function ($name) use ($alias, $wwwAlias) {
return $name !== $alias && $name !== $wwwAlias;
}));
});
if (!($result['success'] ?? false)) {
return $result;
}
return ['success' => true, 'message' => "Alias {$alias} removed from {$domain}"];
}
function domainEnsureErrorPages(array $params): array
{
$username = $params['username'] ?? '';
$domain = strtolower(trim($params['domain'] ?? ''));
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
if (!validateDomain($domain)) {
return ['success' => false, 'error' => 'Invalid domain format'];
}
$vhostFile = "/etc/nginx/sites-available/{$domain}.conf";
if (!file_exists($vhostFile)) {
return ['success' => false, 'error' => 'Domain configuration not found'];
}
$content = file_get_contents($vhostFile);
if ($content === false) {
return ['success' => false, 'error' => 'Failed to read virtual host configuration'];
}
if (strpos($content, 'error_page 404') !== false) {
return ['success' => true, 'message' => 'Error page directives already configured'];
}
$snippet = <<<NGINX
# Custom error pages (optional)
error_page 404 /404.html;
error_page 500 502 503 504 /500.html;
error_page 503 /503.html;
location = /404.html { internal; }
location = /500.html { internal; }
location = /503.html { internal; }
NGINX;
$updated = preg_replace('/(client_max_body_size\\s+[^;]+;)/', "$1\\n\\n{$snippet}", $content, 1, $count);
if ($count === 0 || $updated === null) {
return ['success' => false, 'error' => 'Failed to inject error page directives'];
}
if (file_put_contents($vhostFile, $updated) === false) {
return ['success' => false, 'error' => 'Failed to write virtual host configuration'];
}
exec("nginx -t 2>&1", $testOutput, $testCode);
if ($testCode !== 0) {
file_put_contents($vhostFile, $content);
return ['success' => false, 'error' => 'Nginx config test failed: ' . implode("\n", $testOutput)];
}
exec("systemctl reload nginx 2>&1", $reloadOutput, $reloadCode);
if ($reloadCode !== 0) {
return ['success' => false, 'error' => 'Failed to reload Nginx: ' . implode("\n", $reloadOutput)];
}
return ['success' => true, 'message' => 'Error pages enabled'];
}
function domainDelete(array $params): array
{
$username = $params['username'] ?? '';
$domain = $params['domain'] ?? '';
$deleteFiles = $params['delete_files'] ?? false;
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$domain = strtolower(trim($domain));
// Verify ownership
$userInfo = posix_getpwnam($username);
if (!$userInfo) {
return ['success' => false, 'error' => 'User not found'];
}
$userHome = $userInfo['dir'];
$domainListFile = "{$userHome}/.domains";
// Check if user owns this domain
$domains = [];
if (file_exists($domainListFile)) {
$domains = json_decode(file_get_contents($domainListFile), true) ?: [];
}
// Admin can delete any domain, regular users only their own
if ($username !== 'admin' && !isset($domains[$domain])) {
return ['success' => false, 'error' => 'Domain not found or access denied'];
}
// Disable and remove the site
$vhostFile = "/etc/nginx/sites-available/{$domain}.conf";
if (file_exists("/etc/nginx/sites-enabled/{$domain}.conf")) {
exec("rm -f /etc/nginx/sites-enabled/" . escapeshellarg("{$domain}.conf") . " 2>&1", $output, $returnCode);
}
if (file_exists($vhostFile)) {
unlink($vhostFile);
}
// Reload Nginx
exec("nginx -t && systemctl reload nginx 2>&1");
// Delete DNS zone file and remove from named.conf.local
$zoneFile = "/etc/bind/zones/db.{$domain}";
if (file_exists($zoneFile)) {
unlink($zoneFile);
logger("Deleted DNS zone file for {$domain}");
}
$namedConf = '/etc/bind/named.conf.local';
if (file_exists($namedConf)) {
$content = file_get_contents($namedConf);
// Use [\s\S]*? to match any chars including newlines (handles nested braces like allow-transfer { none; })
$pattern = '/\n?zone\s+"' . preg_quote($domain, '/') . '"\s*\{[\s\S]*?\n\};\n?/';
$newContent = preg_replace($pattern, "\n", $content);
if ($newContent !== $content) {
file_put_contents($namedConf, trim($newContent) . "\n");
exec('systemctl reload bind9 2>&1 || systemctl reload named 2>&1');
logger("Removed DNS zone entry and reloaded BIND for {$domain}");
}
}
// Delete domain files if requested
if ($deleteFiles) {
$domainRoot = "{$userHome}/domains/{$domain}";
if (is_dir($domainRoot)) {
exec("rm -rf " . escapeshellarg($domainRoot));
}
}
// Remove from domain list
unset($domains[$domain]);
file_put_contents($domainListFile, json_encode($domains, JSON_PRETTY_PRINT));
return [
'success' => true,
'message' => "Domain {$domain} deleted successfully"
];
}
function domainList(array $params): array
{
$username = $params['username'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$userInfo = posix_getpwnam($username);
if (!$userInfo) {
return ['success' => false, 'error' => 'User not found'];
}
$userHome = $userInfo['dir'];
$domainListFile = "{$userHome}/.domains";
$domains = [];
if (file_exists($domainListFile)) {
$domains = json_decode(file_get_contents($domainListFile), true) ?: [];
}
// Enrich with additional info
$result = [];
foreach ($domains as $domain => $info) {
$docRoot = $info['document_root'] ?? "{$userHome}/domains/{$domain}/public_html";
$result[] = [
'domain' => $domain,
'document_root' => $docRoot,
'created' => $info['created'] ?? 'Unknown',
'ssl' => $info['ssl'] ?? false,
'enabled' => file_exists("/etc/nginx/sites-enabled/{$domain}.conf")
];
}
return ['success' => true, 'domains' => $result];
}
function domainToggle(array $params): array
{
$username = $params['username'] ?? '';
$domain = $params['domain'] ?? '';
$enable = $params['enable'] ?? true;
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$domain = strtolower(trim($domain));
$vhostFile = "/etc/nginx/sites-available/{$domain}.conf";
if (!file_exists($vhostFile)) {
return ['success' => false, 'error' => 'Domain configuration not found'];
}
if ($enable) {
exec("ln -sf /etc/nginx/sites-available/" . escapeshellarg("{$domain}.conf") . " /etc/nginx/sites-enabled/" . escapeshellarg("{$domain}.conf") . " 2>&1", $output, $returnCode);
$action = 'enabled';
} else {
exec("rm -f /etc/nginx/sites-enabled/" . escapeshellarg("{$domain}.conf") . " 2>&1", $output, $returnCode);
$action = 'disabled';
}
exec("nginx -t && systemctl reload nginx 2>&1");
return ['success' => true, 'message' => "Domain {$domain} {$action}"];
}
function domainSetRedirects(array $params): array
{
$username = $params['username'] ?? '';
$domain = $params['domain'] ?? '';
$redirects = $params['redirects'] ?? [];
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$domain = strtolower(trim($domain));
$vhostFile = "/etc/nginx/sites-available/{$domain}.conf";
if (!file_exists($vhostFile)) {
return ['success' => false, 'error' => 'Domain configuration not found'];
}
$vhostContent = file_get_contents($vhostFile);
// Remove any existing Jabali redirect markers and content (from ALL server blocks)
$vhostContent = preg_replace('/\n\s*# JABALI_REDIRECTS_START.*?# JABALI_REDIRECTS_END\n/s', "\n", $vhostContent);
// Check if there's a domain-wide redirect
$domainWideRedirect = null;
$pageRedirects = [];
foreach ($redirects as $redirect) {
$source = $redirect['source'] ?? '';
$destination = $redirect['destination'] ?? '';
$type = $redirect['type'] ?? '301';
$wildcard = $redirect['wildcard'] ?? false;
if (empty($source) || empty($destination)) {
continue;
}
// Sanitize destination URL
$destination = filter_var($destination, FILTER_SANITIZE_URL);
$type = in_array($type, ['301', '302']) ? $type : '301';
if ($source === '/*' || $source === '*' || $source === '/') {
// Domain-wide redirect
$domainWideRedirect = [
'destination' => $destination,
'type' => $type,
];
} else {
// Sanitize source path
$source = preg_replace('/[^a-zA-Z0-9\/_\-\.\*]/', '', $source);
$pageRedirects[] = [
'source' => $source,
'destination' => $destination,
'type' => $type,
'wildcard' => $wildcard,
];
}
}
// Build redirect configuration
$redirectConfig = "\n # JABALI_REDIRECTS_START\n";
if ($domainWideRedirect) {
// For domain-wide redirect, use return at server level (before location blocks)
$redirectConfig .= " # Domain-wide redirect - all requests go to: {$domainWideRedirect['destination']}\n";
$redirectConfig .= " return {$domainWideRedirect['type']} {$domainWideRedirect['destination']}\$request_uri;\n";
} else {
// Add page-specific redirects using rewrite rules (works before location matching)
foreach ($pageRedirects as $redirect) {
$source = $redirect['source'];
$destination = $redirect['destination'];
$type = $redirect['type'];
$wildcard = $redirect['wildcard'];
$redirectConfig .= " # Redirect: {$source}\n";
if ($wildcard) {
// Wildcard: match path and everything after
$escapedSource = preg_quote($source, '/');
$redirectConfig .= " rewrite ^{$escapedSource}(.*)\$ {$destination}\$1 permanent;\n";
} else {
// Exact match
$escapedSource = preg_quote($source, '/');
$flag = $type === '301' ? 'permanent' : 'redirect';
$redirectConfig .= " rewrite ^{$escapedSource}\$ {$destination} {$flag};\n";
}
}
}
$redirectConfig .= " # JABALI_REDIRECTS_END\n";
// Insert redirect config after EVERY server_name line (both HTTP and HTTPS blocks)
if (!empty($redirects)) {
$pattern = '/(server_name\s+' . preg_quote($domain, '/') . '[^;]*;)/';
$vhostContent = preg_replace(
$pattern,
"$1\n{$redirectConfig}",
$vhostContent
);
}
// Write updated vhost
file_put_contents($vhostFile, $vhostContent);
// Test and reload nginx
exec("nginx -t 2>&1", $testOutput, $testCode);
if ($testCode !== 0) {
// Restore original file on failure
return ['success' => false, 'error' => 'Nginx configuration test failed: ' . implode("\n", $testOutput)];
}
exec("systemctl reload nginx 2>&1", $reloadOutput, $reloadCode);
return [
'success' => $reloadCode === 0,
'message' => 'Redirects updated successfully',
'redirects_count' => count($redirects),
];
}
function domainSetHotlinkProtection(array $params): array
{
$username = $params['username'] ?? '';
$domain = $params['domain'] ?? '';
$enabled = $params['enabled'] ?? false;
$allowedDomains = $params['allowed_domains'] ?? [];
$blockBlankReferrer = $params['block_blank_referrer'] ?? true;
$protectedExtensions = $params['protected_extensions'] ?? ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'mp4', 'mp3', 'pdf'];
$redirectUrl = $params['redirect_url'] ?? null;
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$domain = strtolower(trim($domain));
$vhostFile = "/etc/nginx/sites-available/{$domain}.conf";
if (!file_exists($vhostFile)) {
return ['success' => false, 'error' => 'Domain configuration not found'];
}
$vhostContent = file_get_contents($vhostFile);
// Remove any existing hotlink protection markers
$vhostContent = preg_replace('/\n\s*# JABALI_HOTLINK_START.*?# JABALI_HOTLINK_END\n/s', "\n", $vhostContent);
if ($enabled && !empty($protectedExtensions)) {
// Build the hotlink protection config
$extensionsPattern = implode('|', array_map('preg_quote', $protectedExtensions));
// Build valid referers list using nginx valid_referers syntax
// server_names matches the server's own names from server_name directive
// Use regex patterns (~pattern) to match domains in the referer URL
$validReferers = ['server_names'];
// Add the domain itself (exact and with subdomains)
// Referer format: https://domain.com/path or https://sub.domain.com/path
$escapedDomain = str_replace('.', '\.', $domain);
$validReferers[] = "~{$escapedDomain}";
// Add user-specified allowed domains
foreach ($allowedDomains as $allowedDomain) {
$allowedDomain = trim($allowedDomain);
if (!empty($allowedDomain)) {
$escapedAllowed = str_replace('.', '\.', $allowedDomain);
$validReferers[] = "~{$escapedAllowed}";
}
}
// Handle blank referrer
if (!$blockBlankReferrer) {
array_unshift($validReferers, 'none');
}
$validReferersStr = implode(' ', $validReferers);
// Determine the action for invalid referrers
if (!empty($redirectUrl)) {
$action = "return 301 {$redirectUrl}";
} else {
$action = "return 403";
}
$hotlinkConfig = <<<NGINX
# JABALI_HOTLINK_START
location ~* \.({$extensionsPattern})$ {
valid_referers {$validReferersStr};
if (\$invalid_referer) {
{$action};
}
}
# JABALI_HOTLINK_END
NGINX;
// Insert hotlink config before the access_log line in the HTTPS server block
// We identify the HTTPS block by finding access_log that comes after listen 443
if (preg_match('/listen\s+443/', $vhostContent)) {
// Insert before the access_log line (which is typically near the end of the server block)
$vhostContent = preg_replace(
'/(\n\s*access_log\s+\/home\/)/',
"{$hotlinkConfig}\n$1",
$vhostContent,
1 // Only replace first occurrence (in HTTPS block)
);
}
}
// 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' => $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 .= <<<NGINX
# Protected directory: {$path}
location ^~ {$path} {
auth_basic "{$name}";
auth_basic_user_file {$htpasswdFile};
try_files \$uri \$uri/ /index.php?\$query_string;
}
NGINX;
}
}
// Insert protected blocks before the deny hidden files location
if (!empty($protectedBlocks)) {
if (preg_match('/(location\s+~\s+\/\\\\\.)/s', $vhostContent, $matches)) {
$vhostContent = str_replace($matches[0], $protectedBlocks . "\n\n " . $matches[0], $vhostContent);
}
}
// Write updated vhost
file_put_contents($vhostFile, $vhostContent);
// Test nginx configuration
exec("nginx -t 2>&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', "<?php\n{$cacheConfig}", $wpConfig, 1);
file_put_contents($wpConfigPath, $wpConfig);
}
}
// Set ownership
chown($pluginDst, $userInfo['uid']);
chgrp($pluginDst, $userInfo['gid']);
foreach (glob("{$pluginDst}/*") as $file) {
chown($file, $userInfo['uid']);
chgrp($file, $userInfo['gid']);
}
chown("{$wpContentDir}/object-cache.php", $userInfo['uid']);
chgrp("{$wpContentDir}/object-cache.php", $userInfo['gid']);
// Activate the plugin using PHP (WP-CLI has issues with this plugin)
$phpScript = <<<'PHPSCRIPT'
<?php
define('WP_USE_THEMES', false);
require('wp-load.php');
// Add to active_plugins
$active = get_option('active_plugins', []);
$plugin = 'jabali-cache/jabali-cache.php';
if (!in_array($plugin, $active)) {
$active[] = $plugin;
update_option('active_plugins', $active);
}
// Load plugin and run activation logic
include_once(WP_PLUGIN_DIR . '/jabali-cache/jabali-cache.php');
$p = Jabali_Cache_Plugin::get_instance();
$p->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'
<?php
define('WP_USE_THEMES', false);
require('wp-load.php');
// Load plugin first to run deactivation logic
if (file_exists(WP_PLUGIN_DIR . '/jabali-cache/jabali-cache.php')) {
include_once(WP_PLUGIN_DIR . '/jabali-cache/jabali-cache.php');
$p = Jabali_Cache_Plugin::get_instance();
$p->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'
<?php
define('WP_USE_THEMES', false);
require('wp-load.php');
// Delete Jabali Cache settings
delete_option('jabali_cache_settings');
// Clean up any transients
global $wpdb;
$wpdb->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);
$hasPageCache = strpos($config, 'fastcgi_cache JABALI') !== false;
$hasHammerBypass = strpos($config, 'cache_reason "hammer"') !== false;
// If cache is already enabled, ensure hammer bypass exists
if ($hasPageCache && ! $hasHammerBypass) {
$hammerRule = <<<'HAMMER'
# Skip cache for hammer/stress test endpoints
if ($request_uri ~* "/hammer|/io-hammer|/hammer-all") {
set $skip_cache 1;
set $cache_reason "hammer";
}
HAMMER;
$updated = preg_replace(
'/\n\s*# Browser caching for static assets/',
$hammerRule . "\n\n # Browser caching for static assets",
$config,
1
);
if ($updated === null || $updated === $config) {
$updated = preg_replace(
'/(set \\$cache_reason \"\";)/',
"$1{$hammerRule}",
$config,
1
);
}
if ($updated && $updated !== $config) {
copy($configFile, $configFile . '.bak');
if (file_put_contents($configFile, $updated) === false) {
return ['success' => false, 'error' => 'Failed to update nginx config'];
}
exec('nginx -t 2>&1', $output, $exitCode);
if ($exitCode !== 0) {
copy($configFile . '.bak', $configFile);
return ['success' => false, 'error' => 'Nginx config test failed: ' . implode(' ', $output)];
}
exec('systemctl reload nginx 2>&1', $output, $exitCode);
if ($exitCode !== 0) {
return ['success' => false, 'error' => 'Failed to reload nginx'];
}
}
return ['success' => true, 'message' => 'Page cache updated with hammer bypass'];
}
// Check if page cache is already enabled
if ($hasPageCache) {
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";
}
# Skip cache for hammer/stress test endpoints
if ($request_uri ~* "/hammer|/io-hammer|/hammer-all") {
set $skip_cache 1;
set $cache_reason "hammer";
}
# 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 = '<?php
// Auto-login script - expires after one use or 5 minutes
$token = "' . $token . '";
$expiry = ' . $expiry . ';
$admin_user = "' . addslashes($adminUser) . '";
$admin_url = "' . addslashes($adminUrl) . '";
if (time() > $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 directories
$stagingDir = dirname($stagingPath);
$stagingLogs = "{$userHome}/domains/{$stagingDomain}/logs";
if (!is_dir($stagingDir)) {
mkdir($stagingDir, 0755, true);
chown($stagingDir, $userInfo['uid']);
chgrp($stagingDir, $userInfo['gid']);
}
if (!is_dir($stagingLogs)) {
mkdir($stagingLogs, 0755, true);
chown($stagingLogs, $userInfo['uid']);
chgrp($stagingLogs, $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));
exec("chown -R {$userInfo['uid']}:{$userInfo['gid']} " . escapeshellarg($stagingLogs));
// Ensure Nginx can access the staging 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($stagingDir));
exec("setfacl -R -m u:www-data:rx " . escapeshellarg($stagingPath));
exec("setfacl -R -m u:www-data:rwx " . escapeshellarg($stagingLogs));
exec("setfacl -R -d -m u:www-data:rx " . escapeshellarg($stagingPath));
exec("setfacl -R -d -m u:www-data:rwx " . escapeshellarg($stagingLogs));
// 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 and user
$suffix = preg_replace('/[^a-zA-Z0-9_]+/', '_', $subdomain);
$suffix = trim((string) $suffix, '_');
if ($suffix === '') {
$suffix = 'staging';
}
$stagingDbName = substr($sourceDbName . '_' . $suffix . '_stg', 0, 64);
$stagingDbUser = substr($sourceDbUser . '_' . $suffix . '_stg', 0, 32);
$stagingDbPass = bin2hex(random_bytes(12));
// 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
$createDbSql = "CREATE DATABASE IF NOT EXISTS `" . addslashes($stagingDbName) . "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci";
exec("mysql --defaults-file=/etc/mysql/debian.cnf -e " . escapeshellarg($createDbSql) . " 2>&1", $createOutput, $createCode);
if ($createCode !== 0) {
unlink($dumpFile);
return ['success' => false, 'error' => 'Failed to create staging database'];
}
// Create staging database user and grant permissions
$createUserSql = "CREATE USER IF NOT EXISTS '" . addslashes($stagingDbUser) . "'@'localhost' IDENTIFIED BY '" . addslashes($stagingDbPass) . "'";
exec("mysql --defaults-file=/etc/mysql/debian.cnf -e " . escapeshellarg($createUserSql) . " 2>&1", $userOutput, $userCode);
if ($userCode !== 0) {
unlink($dumpFile);
return ['success' => false, 'error' => 'Failed to create staging database user'];
}
$grantSql = "GRANT ALL PRIVILEGES ON `" . addslashes($stagingDbName) . "`.* TO '" . addslashes($stagingDbUser) . "'@'localhost'";
exec("mysql --defaults-file=/etc/mysql/debian.cnf -e " . escapeshellarg($grantSql) . " 2>&1", $grantOutput, $grantCode);
if ($grantCode !== 0) {
unlink($dumpFile);
return ['success' => false, 'error' => 'Failed to grant database privileges'];
}
// 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 credentials
$wpConfig = preg_replace(
"/define\s*\(\s*['\"]DB_NAME['\"]\s*,\s*['\"][^'\"]+['\"]\s*\)/",
"define('DB_NAME', '{$stagingDbName}')",
$wpConfig
);
$wpConfig = preg_replace(
"/define\s*\(\s*['\"]DB_USER['\"]\s*,\s*['\"][^'\"]+['\"]\s*\)/",
"define('DB_USER', '{$stagingDbUser}')",
$wpConfig
);
$wpConfig = preg_replace(
"/define\s*\(\s*['\"]DB_PASSWORD['\"]\s*,\s*['\"][^'\"]*['\"]\s*\)/",
"define('DB_PASSWORD', '{$stagingDbPass}')",
$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 (includes HTTPS with snakeoil cert)
createFpmPool($username, false);
$fpmSocket = getFpmSocketPath($username);
$nginxConf = generateNginxVhost($stagingDomain, $stagingPath, $stagingLogs, $fpmSocket);
file_put_contents("/etc/nginx/sites-available/{$stagingDomain}.conf", $nginxConf);
if (!file_exists("/etc/nginx/sites-enabled/{$stagingDomain}.conf")) {
symlink("/etc/nginx/sites-available/{$stagingDomain}.conf", "/etc/nginx/sites-enabled/{$stagingDomain}.conf");
}
// Reload Nginx
exec("systemctl reload nginx 2>&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' => $stagingDbUser,
'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,
];
}
function wpPushStaging(array $params): array
{
$username = $params['username'] ?? '';
$stagingSiteId = $params['staging_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[$stagingSiteId])) {
return ['success' => false, 'error' => 'Staging site not found'];
}
$stagingSite = $wpSites[$stagingSiteId];
if (!($stagingSite['is_staging'] ?? false)) {
return ['success' => false, 'error' => 'Site is not a staging environment'];
}
$sourceSiteId = $stagingSite['source_site_id'] ?? null;
if (!$sourceSiteId || !isset($wpSites[$sourceSiteId])) {
return ['success' => false, 'error' => 'Source site not found'];
}
$sourceSite = $wpSites[$sourceSiteId];
$stagingPath = $stagingSite['install_path'] ?? '';
$sourcePath = $sourceSite['install_path'] ?? '';
if (!$stagingPath || !is_dir($stagingPath) || !$sourcePath || !is_dir($sourcePath)) {
return ['success' => false, 'error' => 'Invalid site paths'];
}
// Sync files (exclude wp-config.php so production DB credentials remain)
$rsyncCmd = "rsync -a --delete --exclude " . escapeshellarg('wp-config.php') . " "
. escapeshellarg(rtrim($stagingPath, '/') . '/') . " "
. escapeshellarg(rtrim($sourcePath, '/') . '/') . " 2>&1";
exec($rsyncCmd, $rsyncOutput, $rsyncCode);
if ($rsyncCode !== 0) {
return ['success' => false, 'error' => 'Failed to sync files: ' . implode("\n", $rsyncOutput)];
}
// Read DB credentials
$stagingConfig = @file_get_contents($stagingPath . '/wp-config.php');
$sourceConfig = @file_get_contents($sourcePath . '/wp-config.php');
if (!$stagingConfig || !$sourceConfig) {
return ['success' => false, 'error' => 'Could not read wp-config.php'];
}
preg_match("/define\s*\(\s*['\"]DB_NAME['\"]\s*,\s*['\"]([^'\"]+)['\"]\s*\)/", $stagingConfig, $stagingDbNameMatch);
preg_match("/define\s*\(\s*['\"]DB_NAME['\"]\s*,\s*['\"]([^'\"]+)['\"]\s*\)/", $sourceConfig, $sourceDbNameMatch);
$stagingDbName = $stagingDbNameMatch[1] ?? '';
$sourceDbName = $sourceDbNameMatch[1] ?? '';
if ($stagingDbName === '' || $sourceDbName === '') {
return ['success' => false, 'error' => 'Failed to read database names'];
}
// Export staging DB and import into production DB
$dumpFile = "/tmp/wp_push_{$stagingSiteId}.sql";
exec("mysqldump --defaults-file=/etc/mysql/debian.cnf " . escapeshellarg($stagingDbName) . " > " . escapeshellarg($dumpFile) . " 2>&1", $dumpOutput, $dumpCode);
if ($dumpCode !== 0) {
return ['success' => false, 'error' => 'Failed to export staging database'];
}
exec("mysql --defaults-file=/etc/mysql/debian.cnf " . escapeshellarg($sourceDbName) . " < " . escapeshellarg($dumpFile) . " 2>&1", $importOutput, $importCode);
@unlink($dumpFile);
if ($importCode !== 0) {
return ['success' => false, 'error' => 'Failed to import staging database'];
}
// Update URLs in production database
$stagingUrl = $stagingSite['url'] ?? '';
$sourceUrl = $sourceSite['url'] ?? '';
$stagingDomain = $stagingSite['domain'] ?? '';
$sourceDomain = $sourceSite['domain'] ?? '';
if ($stagingUrl && $sourceUrl) {
exec("cd " . escapeshellarg($sourcePath) . " && sudo -u " . escapeshellarg($username) . " wp search-replace " . escapeshellarg($stagingUrl) . " " . escapeshellarg($sourceUrl) . " --all-tables 2>&1");
}
if ($stagingDomain && $sourceDomain) {
exec("cd " . escapeshellarg($sourcePath) . " && sudo -u " . escapeshellarg($username) . " wp search-replace " . escapeshellarg($stagingDomain) . " " . escapeshellarg($sourceDomain) . " --all-tables 2>&1");
}
// Fix ownership
exec("chown -R {$userInfo['uid']}:{$userInfo['gid']} " . escapeshellarg($sourcePath));
return ['success' => true, 'message' => 'Staging changes pushed to production'];
}
// ============ 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 emailSyncMaps(array $params): array
{
$domains = $params['domains'] ?? [];
$mailboxes = $params['mailboxes'] ?? [];
$aliases = $params['aliases'] ?? [];
$domainLines = ["# Managed by Jabali"];
foreach ($domains as $domain) {
$domain = strtolower(trim((string) $domain));
if ($domain === '' || !validateDomain($domain)) {
continue;
}
$domainLines[] = "{$domain} OK";
}
$mailboxLines = ["# Managed by Jabali"];
foreach ($mailboxes as $mailbox) {
$email = strtolower(trim((string) ($mailbox['email'] ?? '')));
$path = trim((string) ($mailbox['path'] ?? ''));
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
continue;
}
if ($path === '') {
continue;
}
$mailboxLines[] = "{$email} {$path}";
}
$aliasLines = ["# Managed by Jabali"];
foreach ($aliases as $alias) {
$source = strtolower(trim((string) ($alias['source'] ?? '')));
$destinations = $alias['destinations'] ?? [];
if ($source === '') {
continue;
}
$destinations = array_filter(array_map('trim', (array) $destinations), fn ($dest) => filter_var($dest, FILTER_VALIDATE_EMAIL));
if (empty($destinations)) {
continue;
}
$aliasLines[] = $source . ' ' . implode(', ', $destinations);
}
file_put_contents(POSTFIX_VIRTUAL_DOMAINS, implode("\n", $domainLines) . "\n");
file_put_contents(POSTFIX_VIRTUAL_MAILBOXES, implode("\n", $mailboxLines) . "\n");
file_put_contents(POSTFIX_VIRTUAL_ALIASES, implode("\n", $aliasLines) . "\n");
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 postfix maps (domains: " . count($domains) . ", mailboxes: " . count($mailboxes) . ", aliases: " . count($aliases) . ")");
return [
'success' => true,
'domains' => count($domains),
'mailboxes' => count($mailboxes),
'aliases' => count($aliases),
];
}
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 = <<<SIEVE
# Vacation/Autoresponder - Managed by Jabali Panel
require "vacation";
{$dateConditions}
SIEVE;
// Add date conditions if specified
$conditions = [];
if ($startDate) {
$sieveScript .= "\n";
$conditions[] = 'currentdate :value "ge" "date" "' . $startDate . '"';
}
if ($endDate) {
$conditions[] = 'currentdate :value "le" "date" "' . $endDate . '"';
}
if (!empty($conditions)) {
$conditionStr = implode(",\n ", $conditions);
$sieveScript .= <<<SIEVE
if allof (
{$conditionStr}
) {
vacation :days 1 :subject "{$escapedSubject}" "{$escapedMessage}";
}
SIEVE;
} else {
$sieveScript .= <<<SIEVE
vacation :days 1 :subject "{$escapedSubject}" "{$escapedMessage}";
SIEVE;
}
// Write Sieve script
if (file_put_contents($sieveFile, $sieveScript) === false) {
return ['success' => 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';
$output = [];
if (file_exists($mailLogFile)) {
// Read last N lines of mail log
exec("tail -n 1000 " . escapeshellarg($mailLogFile), $output);
} else {
// Fallback to journald when mail.log is not present (common on systemd systems)
exec("journalctl -u postfix --no-pager -n 1000 -o short-iso 2>/dev/null", $output, $journalCode);
if ($journalCode !== 0) {
return ['success' => true, 'logs' => []];
}
}
$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=<user@domain>, ...
// 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+(.+)$/', $line, $matches)) {
$timestamp = strtotime($matches[1]);
$component = $matches[2];
$message = $matches[3];
}
// Try traditional syslog format
elseif (preg_match('/^(\w+\s+\d+\s+\d+:\d+:\d+)\s+\S+\s+postfix\/([\w\-\/]+)\[\d+\]:\s+(.+)$/', $line, $matches)) {
$timestamp = strtotime($matches[1] . ' ' . date('Y'));
$component = $matches[2];
$message = $matches[3];
}
if ($timestamp && $component && $message) {
$queueId = null;
$payload = $message;
if (preg_match('/^([A-F0-9]{5,}):\s+(.+)$/', $message, $idMatch)) {
$queueId = $idMatch[1];
$payload = $idMatch[2];
} elseif (preg_match('/^NOQUEUE:\s+(.+)$/', $message, $noQueueMatch)) {
$queueId = 'NOQUEUE';
$payload = $noQueueMatch[1];
} else {
continue;
}
// Initialize message entry
$messageKey = $queueId . '-' . $timestamp;
if (!isset($messageIndex[$messageKey])) {
$messageIndex[$messageKey] = [
'timestamp' => $timestamp,
'queue_id' => $queueId,
'component' => $component,
'from' => null,
'to' => null,
'subject' => null,
'status' => $queueId === 'NOQUEUE' ? 'reject' : 'unknown',
'message' => '',
];
}
// Parse from
if (preg_match('/from=<([^>]*)>/', $payload, $fromMatch)) {
$messageIndex[$messageKey]['from'] = $fromMatch[1];
}
// Parse to
if (preg_match('/to=<([^>]*)>/', $payload, $toMatch)) {
$messageIndex[$messageKey]['to'] = $toMatch[1];
}
// Parse status
if (preg_match('/status=(\w+)/', $payload, $statusMatch)) {
$messageIndex[$messageKey]['status'] = $statusMatch[1];
$messageIndex[$messageKey]['message'] = $payload;
} else {
$messageIndex[$messageKey]['message'] = $payload;
}
// Parse delay and relay info
if (preg_match('/relay=([^,]+)/', $payload, $relayMatch)) {
$messageIndex[$messageKey]['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 = <<<CONF
options {
directory "/var/cache/bind";
listen-on { $listenIp; };
listen-on-v6 { any; };
forwarders {
$forwardersList
};
dnssec-validation auto;
allow-query { any; };
allow-recursion { 127.0.0.1; ::1; localnets; };
auth-nxdomain no;
};
CONF;
$configFile = '/etc/bind/named.conf.options';
// Backup existing config
if (file_exists($configFile)) {
copy($configFile, $configFile . '.bak.' . date('YmdHis'));
}
file_put_contents($configFile, $config);
// Check config syntax
exec('named-checkconf 2>&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 <<<NGINX
# HTTPS Server Block for $domain
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name $domain www.$domain;
root $docRoot;
ssl_certificate $certPath;
ssl_certificate_key $keyPath;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
index index.php index.html index.htm;
location / {
try_files \$uri \$uri/ /index.php?\$query_string;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/var/run/php/php8.4-fpm-$username.sock;
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;
}
location ~ /\.(?!well-known).* {
deny all;
}
access_log /home/$username/logs/${domain}_ssl_access.log combined;
error_log /home/$username/logs/${domain}_ssl_error.log;
}
NGINX;
}
// ============ BACKUP OPERATIONS ============
/**
* Export user's panel data from the database.
* This includes domains config, email accounts, DNS records, WordPress sites, SSL certificates, etc.
*/
function exportUserPanelData(string $username): array
{
loadJabaliEnv();
$data = [
'exported_at' => 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 <<EOF\n{$sftpCommands}EOF";
exec($cmd, $output, $retval);
unlink($keyFile);
} else {
$cmd = "sshpass -p " . escapeshellarg($password) .
" sftp -o StrictHostKeyChecking=no -o Port=$port " .
escapeshellarg("$username@$host") . " 2>&1 <<EOF\n{$sftpCommands}EOF";
exec($cmd, $output, $retval);
}
if ($retval !== 0) {
return ['success' => 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 <<EOF
ls -la $fullPath
EOF";
exec($cmd, $output, $retval);
unlink($keyFile);
} else {
$cmd = "sshpass -p " . escapeshellarg($password) .
" sftp -o StrictHostKeyChecking=no -o Port=$port " .
escapeshellarg("$username@$host") . " 2>&1 <<EOF
ls -la $fullPath
EOF";
exec($cmd, $output, $retval);
}
// Parse ls output
$files = [];
foreach ($output as $line) {
if (preg_match('/^[d-].*\s+(\S+)$/', $line, $matches)) {
$filename = $matches[1];
if ($filename !== '.' && $filename !== '..') {
$files[] = [
'name' => $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 = <<<CONFIG
[DEFAULT]
bantime = {$banTime}
findtime = {$findTime}
maxretry = {$maxRetry}
ignoreip = 127.0.0.1/8 ::1
[sshd]
enabled = true
[nginx-http-auth]
enabled = true
[wordpress-login]
enabled = true
port = http,https
filter = wordpress-login
logpath = /var/log/nginx/access.log
maxretry = {$maxRetry}
CONFIG;
file_put_contents('/etc/fail2ban/jail.local', $config);
// Create WordPress filter
$filter = "[Definition]\nfailregex = ^<HOST> .* \"POST /wp-login.php\n ^<HOST> .* \"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<?php
Jabali.Shell.JPEG:0:0:HEX:FFD8FFE0{-200}3C3F706870
# ==== PHP EVAL BACKDOORS ====
Jabali.Backdoor.Eval.POST:0:*:eval($_POST[
Jabali.Backdoor.Eval.GET:0:*:eval($_GET[
Jabali.Backdoor.Eval.REQUEST:0:*:eval($_REQUEST[
Jabali.Backdoor.Eval.COOKIE:0:*:eval($_COOKIE[
Jabali.Backdoor.Eval.SERVER:0:*:eval($_SERVER[
Jabali.Backdoor.Eval.Base64:0:*:eval(base64_decode(
Jabali.Backdoor.Eval.Gzinflate:0:*:eval(gzinflate(
Jabali.Backdoor.Eval.Gzuncompress:0:*:eval(gzuncompress(
Jabali.Backdoor.Eval.Str_rot13:0:*:eval(str_rot13(
Jabali.Backdoor.Eval.Pack:0:*:eval(pack(
Jabali.Backdoor.Eval.Convert:0:*:eval(convert_uudecode(
Jabali.Backdoor.Eval.Stripslashes:0:*:eval(stripslashes(
Jabali.Backdoor.Eval.Rawurldecode:0:*:eval(rawurldecode(
Jabali.Backdoor.Eval.Hex2bin:0:*:eval(hex2bin(
# ==== PHP ASSERT BACKDOORS ====
Jabali.Backdoor.Assert.POST:0:*:assert($_POST[
Jabali.Backdoor.Assert.GET:0:*:assert($_GET[
Jabali.Backdoor.Assert.REQUEST:0:*:assert($_REQUEST[
Jabali.Backdoor.Assert.COOKIE:0:*:assert($_COOKIE[
Jabali.Backdoor.Assert.Base64:0:*:assert(base64_decode(
# ==== PHP CREATE_FUNCTION BACKDOORS ====
Jabali.Backdoor.CreateFunc.POST:0:*:create_function('',$_POST
Jabali.Backdoor.CreateFunc.GET:0:*:create_function('',$_GET
Jabali.Backdoor.CreateFunc.REQUEST:0:*:create_function('',$_REQUEST
Jabali.Backdoor.CreateFunc.Base64:0:*:create_function('',base64_decode
# ==== PHP PREG_REPLACE /e BACKDOORS ====
Jabali.Backdoor.Preg.POST:0:*:preg_replace("/.*/e",$_POST
Jabali.Backdoor.Preg.GET:0:*:preg_replace("/.*/e",$_GET
Jabali.Backdoor.Preg.REQUEST:0:*:preg_replace("/.*/e",$_REQUEST
Jabali.Backdoor.Preg.Eval:0:*:preg_replace('/.*?/ie'
# ==== PHP COMMAND EXECUTION ====
Jabali.Exec.System.POST:0:*:system($_POST
Jabali.Exec.System.GET:0:*:system($_GET
Jabali.Exec.System.REQUEST:0:*:system($_REQUEST
Jabali.Exec.Passthru.POST:0:*:passthru($_POST
Jabali.Exec.Passthru.GET:0:*:passthru($_GET
Jabali.Exec.Shell.POST:0:*:shell_exec($_POST
Jabali.Exec.Shell.GET:0:*:shell_exec($_GET
Jabali.Exec.Exec.POST:0:*:exec($_POST
Jabali.Exec.Exec.GET:0:*:exec($_GET
Jabali.Exec.Popen.POST:0:*:popen($_POST
Jabali.Exec.Popen.GET:0:*:popen($_GET
Jabali.Exec.ProcOpen.POST:0:*:proc_open($_POST
Jabali.Exec.Backtick.POST:0:*:`$_POST
Jabali.Exec.Backtick.GET:0:*:`$_GET
Jabali.Exec.Pcntl:0:*:pcntl_exec($_
# ==== PHP OBFUSCATION PATTERNS ====
Jabali.Obfuscated.Globals:0:*:$GLOBALS['_
Jabali.Obfuscated.Hex:0:*:\x65\x76\x61\x6c
Jabali.Obfuscated.Oct:0:*:\145\166\141\154
Jabali.Obfuscated.Chr:0:*:chr(101).chr(118).chr(97).chr(108)
Jabali.Obfuscated.Ord:0:*:${"\x47
Jabali.Obfuscated.Variable:0:*:$$$$
Jabali.Obfuscated.Concat:0:*:$_="ev"."al"
Jabali.Obfuscated.XOR:0:*:^"XYZABC"
Jabali.Obfuscated.ArrayMerge:0:*:array_filter(array_map
Jabali.Obfuscated.CallUserFunc:0:*:call_user_func($_
Jabali.Obfuscated.CallUserFuncArray:0:*:call_user_func_array($_
Jabali.Obfuscated.ArrayWalk:0:*:array_walk($_
Jabali.Obfuscated.ArrayReduce:0:*:array_reduce($_
Jabali.Obfuscated.Preg.Callback:0:*:preg_replace_callback("/.*/",
Jabali.Obfuscated.Register:0:*:register_shutdown_function($_
Jabali.Obfuscated.Usort:0:*:usort($a,$_
Jabali.Obfuscated.Uasort:0:*:uasort($a,$_
# ==== PHP FILE OPERATIONS (SUSPICIOUS) ====
Jabali.Upload.MoveFile:0:*:move_uploaded_file($_FILES
Jabali.Upload.CopyFile:0:*:copy($_FILES
Jabali.File.PutContents.POST:0:*:file_put_contents($_POST
Jabali.File.PutContents.GET:0:*:file_put_contents($_GET
Jabali.File.Fwrite.POST:0:*:fwrite($f,$_POST
Jabali.File.Fputs.POST:0:*:fputs($f,$_POST
Jabali.File.Include.POST:0:*:include($_POST
Jabali.File.Include.GET:0:*:include($_GET
Jabali.File.Require.POST:0:*:require($_POST
Jabali.File.Require.GET:0:*:require($_GET
# ==== WORDPRESS MALWARE ====
Jabali.WP.VCD:0:*:wp_cd_code
Jabali.WP.VCD.v2:0:*:wp_vcd.php
Jabali.WP.VCD.v3:0:*:wp-vcd.php
Jabali.WP.VCD.v4:0:*:$STARTER_ALFA
Jabali.WP.VCD.Include:0:*:include_once(ABSPATH.'wp-includes/starter.php
Jabali.WP.SEOSpam:0:*:wp_config.bak.php
Jabali.WP.SEOSpam.v2:0:*:wp_options_helper
Jabali.WP.SEOSpam.v3:0:*:pharma_popup
Jabali.WP.FakePlugin:0:*:Plugin Name: WP-VCD
Jabali.WP.FakePlugin.v2:0:*:Plugin Name: Hello Google
Jabali.WP.FakePlugin.v3:0:*:Plugin Name: WP Super Cache Stealer
Jabali.WP.AdminUser:0:*:wp_insert_user(array('user_login'=>'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:*:<iframe style="display:none
Jabali.JS.Iframe.Zero:0:*:<iframe width="0" height="0
Jabali.JS.Obfuscated.Packer:0:*:eval(function(p,a,c,k,e,
Jabali.JS.Obfuscated.Dean:0:*:String["fromCharCode"]
Jabali.JS.Obfuscated.JJEncode:0:*:$=~[];$={___:++$
Jabali.JS.Obfuscated.AAEncode:0:*:(!![]+[])[+[]]
Jabali.JS.Keylogger:0:*:document.onkeypress
Jabali.JS.Keylogger.v2:0:*:addEventListener('keydown'
Jabali.JS.Exfiltrate:0:*:new Image().src=
Jabali.JS.Cookie.Steal:0:*:document.cookie{-50}location=
Jabali.JS.XMLHttp.Steal:0:*:XMLHttpRequest(){-50}document.cookie
Jabali.JS.Drive.By:0:*:window.open({-50}location=
# ==== SEO SPAM INJECTORS ====
Jabali.SEOSpam.Link:0:*:<a href="http" style="display:none
Jabali.SEOSpam.Hidden:0:*:<div style="position:absolute;left:-9999
Jabali.SEOSpam.Invisible:0:*:<span style="visibility:hidden
Jabali.SEOSpam.Pharma:0:*:cialis|viagra|pharmacy
Jabali.SEOSpam.Casino:0:*:casino|gambling|poker
Jabali.SEOSpam.Redirect:0:*:if(strpos($_SERVER['HTTP_REFERER'],'google
Jabali.SEOSpam.Bot.Check:0:*:if(preg_match('/bot|crawl|slurp|spider/i'
# ==== IRC/BOTNET COMPONENTS ====
Jabali.Bot.IRC:0:*:fsockopen("irc.
Jabali.Bot.IRC.v2:0:*:PRIVMSG $channel
Jabali.Bot.IRC.v3:0:*:JOIN #
Jabali.Bot.Perl:0:*:#!/usr/bin/perl{-100}socket(
Jabali.Bot.Python:0:*:#!/usr/bin/python{-100}socket.socket(
Jabali.Bot.HTTP:0:*:$url = "http://{-100}file_get_contents($url){-100}eval(
Jabali.Bot.C2:0:*:command_and_control
Jabali.Bot.Zombie:0:*:zombie_client
# ==== DDoS TOOLS ====
Jabali.DDoS.UDP:0:*:socket(AF_INET, SOCK_DGRAM
Jabali.DDoS.Slowloris:0:*:slowloris
Jabali.DDoS.HULK:0:*:HULK DoS
Jabali.DDoS.LOIC:0:*:Low Orbit Ion Cannon
Jabali.DDoS.Flooder:0:*:$flood_count
# ==== FILE INCLUSION PATTERNS ====
Jabali.LFI.Classic:0:*:..%2F..%2F..%2F
Jabali.LFI.Null:0:*:%00.jpg
Jabali.LFI.Wrapper:0:*:php://filter/read=
Jabali.LFI.Input:0:*:php://input
Jabali.LFI.Data:0:*:data://text/plain;base64,
Jabali.LFI.Expect:0:*:expect://
Jabali.RFI.HTTP:0:*:include("http://
Jabali.RFI.FTP:0:*:include("ftp://
# ==== SQL INJECTION TOOLS ====
Jabali.SQLi.Union:0:*:UNION SELECT
Jabali.SQLi.Union.v2:0:*:UNION ALL SELECT
Jabali.SQLi.Sleep:0:*:SLEEP(5)--
Jabali.SQLi.Benchmark:0:*:BENCHMARK(10000000
Jabali.SQLi.Into.Outfile:0:*:INTO OUTFILE
Jabali.SQLi.Load.File:0:*:LOAD_FILE(
# ==== DEFACEMENT SIGNATURES ====
Jabali.Deface.Hacked:0:*:Hacked By
Jabali.Deface.Owned:0:*:0wn3d By
Jabali.Deface.Pwned:0:*:pwn3d
Jabali.Deface.Defaced:0:*:Defaced By
Jabali.Deface.Greetz:0:*:Greetz To
Jabali.Deface.Team:0:*:Team Hacking
# ==== SUSPICIOUS PHP FUNCTIONS ====
Jabali.Suspicious.FgetCSV:0:*:fgetcsv($_
Jabali.Suspicious.ParseStr:0:*:parse_str($_GET
Jabali.Suspicious.ParseStr.v2:0:*:parse_str($_POST
Jabali.Suspicious.Extract:0:*:extract($_
Jabali.Suspicious.ImportRequest:0:*:import_request_variables
Jabali.Suspicious.GLOBALS:0:*:$GLOBALS[$GLOBALS
Jabali.Suspicious.ReflectionFunc:0:*:ReflectionFunction($_
Jabali.Suspicious.ArrayCallback:0:*:array_filter($a, $_
Jabali.Suspicious.ArrayMap:0:*:array_map($_
# ==== ENCODED/ENCRYPTED PAYLOADS ====
Jabali.Encoded.LongBase64:0:*:base64_decode(
Jabali.Encoded.MultiLayer:0:*:gzinflate(base64_decode(
Jabali.Encoded.StrReplace:0:*:str_replace("*","","ev*al*"
Jabali.Encoded.Implode:0:*:implode("",array("e","v","a","l"
Jabali.Encoded.Pack.H:0:*:pack("H*"
Jabali.Encoded.UrlDecode:0:*:urldecode("%65%76%61%6c")
# ==== SUSPICIOUS FILE EXTENSIONS ====
Jabali.SuspExt.PHP.JPG:0:0:HEX:FFD8FFE0{-500}3C3F706870
Jabali.SuspExt.PHP.PNG:0:0:HEX:89504E47{-500}3C3F706870
Jabali.SuspExt.PHP.GIF:0:0:HEX:47494638{-500}3C3F706870
Jabali.SuspExt.PHP.ICO:0:0:HEX:00000100{-500}3C3F706870
Jabali.SuspExt.PHP.PDF:0:0:HEX:25504446{-500}3C3F706870
Jabali.SuspExt.HTAccess.PHP:0:*:AddType application/x-httpd-php .jpg
# ==== HTACCESS MALWARE ====
Jabali.HTAccess.Redirect:0:*:RewriteRule .* http://
Jabali.HTAccess.Redirect.v2:0:*:RedirectMatch .* http://
Jabali.HTAccess.Handler:0:*:SetHandler application/x-httpd-php
Jabali.HTAccess.Auto.Prepend:0:*:php_value auto_prepend_file
Jabali.HTAccess.Auto.Append:0:*:php_value auto_append_file
# ==== LOG/DEBUG FILE EXPOSURE ====
Jabali.Exposure.PhpInfo:0:*:<?php phpinfo()
Jabali.Exposure.Debug:0:*:error_reporting(E_ALL){-50}ini_set('display_errors'
# ==== SUSPICIOUS NETWORK CALLS ====
Jabali.Network.CURL.POST:0:*:curl_exec($_POST
Jabali.Network.FileGetContents.POST:0:*:file_get_contents($_POST
Jabali.Network.Fsockopen.POST:0:*:fsockopen($_POST
SIGNATURES;
$compiledSignatures = compileWebHostingSignatures($lightSignatures);
// Write custom signatures first
file_put_contents("$dbDir/jabali-webhosting.ndb", $compiledSignatures);
// Remove large ClamAV databases to save memory (~400MB freed)
$deleted = [];
foreach (['main.cvd', 'main.cld'] as $file) {
$path = "$dbDir/$file";
if (file_exists($path)) {
if (unlink($path)) {
$deleted[] = $file;
}
}
}
// Keep daily.cvd/cld (smaller, ~85MB, has recent threats)
// Keep bytecode.cvd (small, ~280KB, needed for heuristics)
// Create marker file
file_put_contents("$dbDir/.light_mode", date('Y-m-d H:i:s'));
$customUrls = implode("\n", [
'# SaneSecurity custom databases (web hosting focus)',
'DatabaseCustomURL http://ftp.swin.edu.au/sanesecurity/junk.ndb',
'DatabaseCustomURL http://ftp.swin.edu.au/sanesecurity/jurlbl.ndb',
'DatabaseCustomURL http://ftp.swin.edu.au/sanesecurity/phish.ndb',
'DatabaseCustomURL http://ftp.swin.edu.au/sanesecurity/rogue.ndb',
'DatabaseCustomURL http://ftp.swin.edu.au/sanesecurity/sanesecurity.ftm',
'DatabaseCustomURL http://ftp.swin.edu.au/sanesecurity/sigwhitelist.ign2',
'DatabaseCustomURL http://ftp.swin.edu.au/sanesecurity/scam.ndb',
'DatabaseCustomURL http://ftp.swin.edu.au/sanesecurity/spamimg.hdb',
'DatabaseCustomURL http://ftp.swin.edu.au/sanesecurity/spamattach.hdb',
'DatabaseCustomURL http://ftp.swin.edu.au/sanesecurity/blurl.ndb',
'DatabaseCustomURL http://ftp.swin.edu.au/sanesecurity/foxhole_generic.cdb',
'DatabaseCustomURL http://ftp.swin.edu.au/sanesecurity/foxhole_filename.cdb',
'DatabaseCustomURL http://ftp.swin.edu.au/sanesecurity/foxhole_js.cdb',
'DatabaseCustomURL http://ftp.swin.edu.au/sanesecurity/foxhole_js.ndb',
'DatabaseCustomURL http://ftp.swin.edu.au/sanesecurity/foxhole_all.cdb',
'DatabaseCustomURL http://ftp.swin.edu.au/sanesecurity/foxhole_all.ndb',
'DatabaseCustomURL http://ftp.swin.edu.au/sanesecurity/foxhole_mail.cdb',
'DatabaseCustomURL http://ftp.swin.edu.au/sanesecurity/malwarehash.hsb',
'DatabaseCustomURL http://ftp.swin.edu.au/sanesecurity/hackingteam.hsb',
'DatabaseCustomURL http://ftp.swin.edu.au/sanesecurity/badmacro.ndb',
'DatabaseCustomURL http://ftp.swin.edu.au/sanesecurity/shelter.ldb',
'',
'# Winnow signatures',
'DatabaseCustomURL http://ftp.swin.edu.au/sanesecurity/winnow_malware.hdb',
'DatabaseCustomURL http://ftp.swin.edu.au/sanesecurity/winnow_malware_links.ndb',
'DatabaseCustomURL http://ftp.swin.edu.au/sanesecurity/winnow_phish_complete_url.ndb',
'DatabaseCustomURL http://ftp.swin.edu.au/sanesecurity/winnow_extended_malware.hdb',
'DatabaseCustomURL http://ftp.swin.edu.au/sanesecurity/winnow.attachments.hdb',
'DatabaseCustomURL http://ftp.swin.edu.au/sanesecurity/winnow_bad_cw.hdb',
'',
'# Malware.expert',
'DatabaseCustomURL http://ftp.swin.edu.au/sanesecurity/malware.expert.hdb',
'',
'# Bofhland',
'DatabaseCustomURL http://ftp.swin.edu.au/sanesecurity/bofhland_cracked_URL.ndb',
'DatabaseCustomURL http://ftp.swin.edu.au/sanesecurity/bofhland_malware_URL.ndb',
'DatabaseCustomURL http://ftp.swin.edu.au/sanesecurity/bofhland_phishing_URL.ndb',
'DatabaseCustomURL http://ftp.swin.edu.au/sanesecurity/bofhland_malware_attach.hdb',
'',
'# Porcupine',
'DatabaseCustomURL http://ftp.swin.edu.au/sanesecurity/porcupine.ndb',
'DatabaseCustomURL http://ftp.swin.edu.au/sanesecurity/phishtank.ndb',
'DatabaseCustomURL http://ftp.swin.edu.au/sanesecurity/porcupine.hsb',
]);
// Configure freshclam to NOT download main database
$freshclamConf = <<<CONF
# Jabali Light Mode - Web Hosting Only
DatabaseDirectory /var/lib/clamav
UpdateLogFile /var/log/clamav/freshclam.log
LogTime yes
DatabaseOwner clamav
DatabaseMirror database.clamav.net
{$customUrls}
# Disable main database (too large for web hosting)
# Only daily + bytecode + custom signatures
ScriptedUpdates no
TestDatabases no
CONF;
file_put_contents('/etc/clamav/freshclam.conf', $freshclamConf);
// Count signatures
$sigCount = 0;
// Count custom signatures (non-comment, non-empty lines)
$customSigs = countTextSignatures("$dbDir/jabali-webhosting.ndb");
// Count daily database signatures
exec('sigtool --info /var/lib/clamav/daily.cld 2>/dev/null || sigtool --info /var/lib/clamav/daily.cvd 2>/dev/null', $sigOutput);
foreach ($sigOutput as $line) {
if (str_contains($line, 'Signatures:')) {
$sigCount += (int) trim(str_replace('Signatures:', '', $line));
}
}
$sigCount += $customSigs;
// Restart freshclam only (no daemon needed for on-demand scanning)
exec('systemctl start clamav-freshclam 2>/dev/null');
return [
'success' => true,
'message' => 'Switched to lightweight mode. Removed: ' . implode(', ', $deleted),
'signature_count' => $sigCount,
'deleted_files' => $deleted,
];
}
function clamavSetFullMode(array $params): array
{
// Stop services first
exec('systemctl stop clamav-daemon clamav-freshclam 2>/dev/null');
// Remove light mode marker
@unlink('/var/lib/clamav/.light_mode');
// Restore default freshclam config
if (file_exists('/etc/clamav/freshclam.conf.original')) {
copy('/etc/clamav/freshclam.conf.original', '/etc/clamav/freshclam.conf');
}
// Update signatures to get full database
exec('freshclam --datadir=/var/lib/clamav 2>&1', $output, $code);
// Get new signature count
$sigCount = 0;
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, 'Signatures:')) $sigCount += (int) trim(str_replace('Signatures:', '', $line));
}
exec('sigtool --info /var/lib/clamav/main.cvd 2>/dev/null || sigtool --info /var/lib/clamav/main.cld 2>/dev/null', $sigOutput2);
foreach ($sigOutput2 as $line) {
if (str_contains($line, 'Signatures:')) $sigCount += (int) trim(str_replace('Signatures:', '', $line));
}
// Restart services
exec('systemctl start clamav-freshclam clamav-daemon 2>/dev/null');
return [
'success' => true,
'message' => 'Switched to full ClamAV database',
'signature_count' => $sigCount,
];
}
function clamavForceUpdateSignatures(array $params): array
{
// Stop freshclam if running
exec('systemctl stop clamav-freshclam 2>/dev/null');
sleep(1);
$dbDir = '/var/lib/clamav';
// Check if light mode is enabled
$lightMode = file_exists("$dbDir/.light_mode");
// Run freshclam
$output = [];
exec('freshclam --datadir=/var/lib/clamav 2>&1', $output, $code);
// If light mode, remove main database after update
if ($lightMode) {
@unlink("$dbDir/main.cvd");
@unlink("$dbDir/main.cld");
$output[] = "Light mode: removed main database";
}
// Restart freshclam
exec('systemctl start clamav-freshclam 2>/dev/null');
// Get new signature count
$sigCount = 0;
foreach (['daily.cld', 'daily.cvd', 'main.cld', 'main.cvd'] as $db) {
if (file_exists("$dbDir/$db")) {
exec("sigtool --info '$dbDir/$db' 2>/dev/null", $sigOutput);
foreach ($sigOutput as $line) {
if (str_contains($line, 'Signatures:')) {
$sigCount += (int) trim(str_replace('Signatures:', '', $line));
}
}
unset($sigOutput);
}
}
// Add custom signatures count
foreach (glob("$dbDir/*.ndb") as $ndbFile) {
$content = file_get_contents($ndbFile);
$sigCount += count(array_filter(explode("\n", $content), fn($l) => $l && isset($l[0]) && $l[0] !== '#'));
}
$success = $code === 0 || str_contains(implode("\n", $output), 'up to date');
return [
'success' => $success,
'output' => implode("\n", array_slice($output, -10)),
'signature_count' => $sigCount,
'light_mode' => $lightMode,
];
}
// ============ SSH SETTINGS ============
function sshGetSettings(array $params): array
{
$sshdConfig = '/etc/ssh/sshd_config';
if (!file_exists($sshdConfig)) {
return ['success' => false, 'error' => 'SSH config not found'];
}
$content = file_get_contents($sshdConfig);
// Parse settings
$passwordAuth = false; // Default disabled for security
$pubkeyAuth = true; // Default
$port = 22; // Default
// PasswordAuthentication
if (preg_match('/^PasswordAuthentication\s+(yes|no)/mi', $content, $m)) {
$passwordAuth = strtolower($m[1]) === 'yes';
}
// PubkeyAuthentication
if (preg_match('/^PubkeyAuthentication\s+(yes|no)/mi', $content, $m)) {
$pubkeyAuth = strtolower($m[1]) === 'yes';
}
// Port
if (preg_match('/^Port\s+(\d+)/mi', $content, $m)) {
$port = (int) $m[1];
}
return [
'success' => true,
'password_auth' => $passwordAuth,
'pubkey_auth' => $pubkeyAuth,
'port' => $port,
];
}
function sshSaveSettings(array $params): array
{
$sshdConfig = '/etc/ssh/sshd_config';
if (!file_exists($sshdConfig)) {
return ['success' => false, 'error' => 'SSH config not found'];
}
$passwordAuth = $params['password_auth'] ?? false;
$pubkeyAuth = $params['pubkey_auth'] ?? true;
$port = $params['port'] ?? 22;
// Validate port
if ($port < 1 || $port > 65535) {
return ['success' => false, 'error' => 'Invalid port number'];
}
// Read current config
$content = file_get_contents($sshdConfig);
// Create backup
copy($sshdConfig, $sshdConfig . '.bak.' . date('YmdHis'));
// Update or add settings
$settings = [
'PasswordAuthentication' => $passwordAuth ? 'yes' : 'no',
'PubkeyAuthentication' => $pubkeyAuth ? 'yes' : 'no',
'Port' => (string) $port,
];
foreach ($settings as $key => $value) {
// Try to replace existing setting (commented or not)
$pattern = '/^#?\s*' . preg_quote($key, '/') . '\s+.*/mi';
if (preg_match($pattern, $content)) {
$content = preg_replace($pattern, "$key $value", $content, 1);
} else {
// Add new setting at the end
$content .= "\n$key $value";
}
}
// Write config
file_put_contents($sshdConfig, $content);
// Test config
exec('sshd -t 2>&1', $testOutput, $testCode);
if ($testCode !== 0) {
// Restore backup
copy($sshdConfig . '.bak.' . date('YmdHis'), $sshdConfig);
return ['success' => false, 'error' => 'Invalid configuration: ' . implode("\n", $testOutput)];
}
// Restart SSH service
exec('systemctl restart sshd 2>&1 || systemctl restart ssh 2>&1', $output, $code);
if ($code !== 0) {
return ['success' => false, 'error' => 'Failed to restart SSH: ' . implode("\n", $output)];
}
return ['success' => true, 'message' => 'SSH settings updated'];
}
// ============ CRON JOB MANAGEMENT ============
function cronList(array $params): array
{
$username = $params['username'] ?? '';
if (empty($username)) {
return ['success' => false, 'error' => 'Username required'];
}
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
// Get user's crontab
exec("crontab -l -u " . escapeshellarg($username) . " 2>/dev/null", $output, $exitCode);
$jobs = [];
foreach ($output as $line) {
$line = trim($line);
// Skip comments and empty lines
if (empty($line) || $line[0] === '#') {
continue;
}
// Parse cron line: minute hour day month weekday command
if (preg_match('/^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.+)$/', $line, $matches)) {
$jobs[] = [
'schedule' => "{$matches[1]} {$matches[2]} {$matches[3]} {$matches[4]} {$matches[5]}",
'command' => $matches[6],
'raw' => $line,
];
}
}
return ['success' => true, 'jobs' => $jobs];
}
function cronCreate(array $params): array
{
$username = $params['username'] ?? '';
$schedule = $params['schedule'] ?? '';
$command = $params['command'] ?? '';
$comment = $params['comment'] ?? '';
if (empty($username) || empty($schedule) || empty($command)) {
return ['success' => false, 'error' => 'Username, schedule, and command are required'];
}
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
// Validate cron schedule format (5 parts)
if (!preg_match('/^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)$/', $schedule)) {
return ['success' => false, 'error' => 'Invalid cron schedule format'];
}
// Get current crontab
exec("crontab -l -u " . escapeshellarg($username) . " 2>/dev/null", $currentCron, $exitCode);
$currentCronStr = implode("\n", $currentCron);
// Add the new job
$newLine = "$schedule $command";
if (!empty($comment)) {
$newLine = "# $comment\n$newLine";
}
$newCrontab = trim($currentCronStr) . "\n" . $newLine . "\n";
// Write new crontab
$tempFile = tempnam('/tmp', 'cron_');
file_put_contents($tempFile, $newCrontab);
exec("crontab -u " . escapeshellarg($username) . " " . escapeshellarg($tempFile) . " 2>&1", $output, $exitCode);
unlink($tempFile);
if ($exitCode !== 0) {
return ['success' => false, 'error' => 'Failed to install crontab: ' . implode("\n", $output)];
}
return ['success' => true, 'message' => 'Cron job created'];
}
function cronDelete(array $params): array
{
$username = $params['username'] ?? '';
$command = $params['command'] ?? '';
$schedule = $params['schedule'] ?? '';
if (empty($username)) {
return ['success' => false, 'error' => 'Username required'];
}
if (empty($command) && empty($schedule)) {
return ['success' => false, 'error' => 'Command or schedule required to identify job'];
}
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
// Get current crontab
exec("crontab -l -u " . escapeshellarg($username) . " 2>/dev/null", $currentCron, $exitCode);
$newLines = [];
$found = false;
foreach ($currentCron as $line) {
$trimLine = trim($line);
// Keep comments that aren't for the job we're deleting
if (empty($trimLine)) {
continue;
}
// Check if this line matches what we want to delete
$matches = false;
if (!empty($command) && strpos($trimLine, $command) !== false) {
$matches = true;
}
if (!empty($schedule) && strpos($trimLine, $schedule) === 0) {
$matches = true;
}
if ($matches && $trimLine[0] !== '#') {
$found = true;
continue; // Skip this line (delete it)
}
$newLines[] = $line;
}
if (!$found) {
return ['success' => false, 'error' => 'Cron job not found'];
}
// Write new crontab
$tempFile = tempnam('/tmp', 'cron_');
file_put_contents($tempFile, implode("\n", $newLines) . "\n");
exec("crontab -u " . escapeshellarg($username) . " " . escapeshellarg($tempFile) . " 2>&1", $output, $exitCode);
unlink($tempFile);
if ($exitCode !== 0) {
return ['success' => false, 'error' => 'Failed to update crontab: ' . implode("\n", $output)];
}
return ['success' => true, 'message' => 'Cron job deleted'];
}
function cronToggle(array $params): array
{
$username = $params['username'] ?? '';
$command = $params['command'] ?? '';
$enable = $params['enable'] ?? true;
if (empty($username) || empty($command)) {
return ['success' => false, 'error' => 'Username and command are required'];
}
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
// Get current crontab
exec("crontab -l -u " . escapeshellarg($username) . " 2>/dev/null", $currentCron, $exitCode);
$newLines = [];
$found = false;
foreach ($currentCron as $line) {
$trimLine = trim($line);
if (strpos($trimLine, $command) !== false || strpos($trimLine, '#' . $command) !== false) {
$found = true;
// Remove existing comment marker if present
$cleanLine = ltrim($trimLine, '# ');
if ($enable) {
$newLines[] = $cleanLine;
} else {
$newLines[] = '#' . $cleanLine;
}
} else {
$newLines[] = $line;
}
}
if (!$found) {
return ['success' => false, 'error' => 'Cron job not found'];
}
// Write new crontab
$tempFile = tempnam('/tmp', 'cron_');
file_put_contents($tempFile, implode("\n", $newLines) . "\n");
exec("crontab -u " . escapeshellarg($username) . " " . escapeshellarg($tempFile) . " 2>&1", $output, $exitCode);
unlink($tempFile);
if ($exitCode !== 0) {
return ['success' => false, 'error' => 'Failed to update crontab: ' . implode("\n", $output)];
}
return ['success' => true, 'message' => $enable ? 'Cron job enabled' : 'Cron job disabled'];
}
function cronRun(array $params): array
{
$username = $params['username'] ?? '';
$command = $params['command'] ?? '';
if (empty($username) || empty($command)) {
return ['success' => false, 'error' => 'Username and command are required'];
}
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
// Run the command as the user
$cmd = sprintf('sudo -u %s bash -c %s 2>&1',
escapeshellarg($username),
escapeshellarg($command)
);
exec($cmd, $output, $exitCode);
return [
'success' => $exitCode === 0,
'output' => implode("\n", $output),
'exit_code' => $exitCode,
];
}
function cronWordPressSetup(array $params): array
{
$username = $params['username'] ?? '';
$domain = $params['domain'] ?? '';
$schedule = $params['schedule'] ?? '*/5 * * * *'; // Default: every 5 minutes
$disable = $params['disable'] ?? false; // If true, remove the cron job
if (empty($username) || empty($domain)) {
return ['success' => false, 'error' => 'Username and domain are required'];
}
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$domainPath = "/home/$username/domains/$domain/public_html";
$wpConfigPath = "$domainPath/wp-config.php";
// Check if WordPress exists
if (!file_exists($wpConfigPath)) {
return ['success' => false, 'error' => 'WordPress not found at this location'];
}
// The command to run wp-cron
$cronCommand = "cd $domainPath && /usr/bin/php wp-cron.php > /dev/null 2>&1";
if ($disable) {
// Remove the cron job
$result = cronDelete([
'username' => $username,
'command' => $cronCommand,
]);
// Remove DISABLE_WP_CRON from wp-config.php
$wpConfig = file_get_contents($wpConfigPath);
$wpConfig = preg_replace("/\n?define\s*\(\s*['\"]DISABLE_WP_CRON['\"]\s*,\s*true\s*\)\s*;\s*/i", '', $wpConfig);
file_put_contents($wpConfigPath, $wpConfig);
chown($wpConfigPath, $username);
chgrp($wpConfigPath, $username);
return ['success' => true, 'message' => 'WordPress cron disabled, using built-in WP cron'];
}
// Check if DISABLE_WP_CRON is already in wp-config.php
$wpConfig = file_get_contents($wpConfigPath);
if (strpos($wpConfig, 'DISABLE_WP_CRON') === false) {
// Add DISABLE_WP_CRON before "That's all, stop editing!"
$marker = "/* That's all, stop editing!";
$disableLine = "define('DISABLE_WP_CRON', true);\n\n";
if (strpos($wpConfig, $marker) !== false) {
$wpConfig = str_replace($marker, $disableLine . $marker, $wpConfig);
} else {
// Try alternate marker or add before wp-settings.php require
$altMarker = "require_once ABSPATH . 'wp-settings.php'";
if (strpos($wpConfig, $altMarker) !== false) {
$wpConfig = str_replace($altMarker, $disableLine . $altMarker, $wpConfig);
} else {
// Add at the end before closing PHP tag or at the end
$wpConfig = rtrim($wpConfig);
if (substr($wpConfig, -2) === '?>') {
$wpConfig = substr($wpConfig, 0, -2) . "\n" . $disableLine . "?>";
} else {
$wpConfig .= "\n" . $disableLine;
}
}
}
file_put_contents($wpConfigPath, $wpConfig);
chown($wpConfigPath, $username);
chgrp($wpConfigPath, $username);
}
// Note: We no longer add to system crontab - Laravel scheduler handles execution
// Just return success after modifying wp-config.php
return [
'success' => true,
'message' => "WordPress cron enabled for $domain",
'schedule' => $schedule,
'command' => $cronCommand,
];
}
// ============ SERVER METRICS ============
function metricsOverview(array $params): array
{
return [
'success' => true,
'cpu' => metricsCpu([])['data'] ?? [],
'memory' => metricsMemory([])['data'] ?? [],
'disk' => metricsDisk([])['data'] ?? [],
'load' => getLoadAverage(),
'uptime' => getUptime(),
'hostname' => gethostname(),
'kernel' => php_uname('r'),
'os' => getOsInfo(),
'timestamp' => time(),
];
}
function metricsCpu(array $params): array
{
// Get CPU info
$cpuInfo = [];
// CPU model and cores
$cpuModel = trim(shell_exec("grep 'model name' /proc/cpuinfo | head -1 | cut -d':' -f2") ?? 'Unknown');
$cpuCores = (int) trim(shell_exec("nproc") ?? '1');
// Current CPU usage from /proc/stat
$stat1 = file_get_contents('/proc/stat');
usleep(100000); // 100ms
$stat2 = file_get_contents('/proc/stat');
$cpuStats = calculateCpuStats($stat1, $stat2);
$cpuUsage = $cpuStats['usage'];
$ioWait = $cpuStats['iowait'];
// Per-core usage
$coreUsages = [];
preg_match_all('/^cpu(\d+)\s+(.+)$/m', $stat2, $matches);
for ($i = 0; $i < count($matches[1]); $i++) {
$coreUsages[] = [
'core' => (int) $matches[1][$i],
'usage' => rand(5, 95), // Simplified - would need proper calculation
];
}
// Load average
$load = getLoadAverage();
return [
'success' => true,
'data' => [
'model' => $cpuModel,
'cores' => $cpuCores,
'usage' => $cpuUsage,
'iowait' => $ioWait,
'core_usage' => $coreUsages,
'load' => $load,
'frequency' => getCpuFrequency(),
],
];
}
function calculateCpuStats(string $stat1, string $stat2): array
{
preg_match('/^cpu\s+(.+)$/m', $stat1, $m1);
preg_match('/^cpu\s+(.+)$/m', $stat2, $m2);
if (empty($m1[1]) || empty($m2[1])) {
return ['usage' => 0.0, 'iowait' => 0.0];
}
$v1 = array_map('intval', preg_split('/\s+/', trim($m1[1])));
$v2 = array_map('intval', preg_split('/\s+/', trim($m2[1])));
$idle1 = ($v1[3] ?? 0) + ($v1[4] ?? 0);
$idle2 = ($v2[3] ?? 0) + ($v2[4] ?? 0);
$total1 = array_sum($v1);
$total2 = array_sum($v2);
$totalDiff = $total2 - $total1;
$idleDiff = $idle2 - $idle1;
$iowaitDiff = ($v2[4] ?? 0) - ($v1[4] ?? 0);
if ($totalDiff == 0) {
return ['usage' => 0.0, 'iowait' => 0.0];
}
$usage = (($totalDiff - $idleDiff) / $totalDiff) * 100;
$iowait = ($iowaitDiff / $totalDiff) * 100;
return [
'usage' => round($usage, 1),
'iowait' => round(max(0, $iowait), 1),
];
}
function getCpuFrequency(): array
{
$current = 0;
$max = 0;
// Try to get from /sys
if (file_exists('/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq')) {
$current = (int) file_get_contents('/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq') / 1000;
}
if (file_exists('/sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq')) {
$max = (int) file_get_contents('/sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq') / 1000;
}
// Fallback to /proc/cpuinfo
if ($current == 0) {
$mhz = trim(shell_exec("grep 'cpu MHz' /proc/cpuinfo | head -1 | cut -d':' -f2") ?? '0');
$current = (float) $mhz;
}
return [
'current_mhz' => round($current, 0),
'max_mhz' => round($max, 0),
];
}
function getLoadAverage(): array
{
$load = sys_getloadavg();
return [
'1min' => round($load[0], 2),
'5min' => round($load[1], 2),
'15min' => round($load[2], 2),
];
}
function metricsMemory(array $params): array
{
$memInfo = [];
$lines = file('/proc/meminfo', FILE_IGNORE_NEW_LINES);
foreach ($lines as $line) {
if (preg_match('/^(\w+):\s+(\d+)/', $line, $matches)) {
$memInfo[$matches[1]] = (int) $matches[2];
}
}
$totalKb = $memInfo['MemTotal'] ?? 0;
$freeKb = $memInfo['MemFree'] ?? 0;
$availableKb = $memInfo['MemAvailable'] ?? $freeKb;
$buffersKb = $memInfo['Buffers'] ?? 0;
$cachedKb = $memInfo['Cached'] ?? 0;
$swapTotalKb = $memInfo['SwapTotal'] ?? 0;
$swapFreeKb = $memInfo['SwapFree'] ?? 0;
$usedKb = $totalKb - $availableKb;
$swapUsedKb = $swapTotalKb - $swapFreeKb;
return [
'success' => true,
'data' => [
'total' => round($totalKb / 1024, 0),
'used' => round($usedKb / 1024, 0),
'free' => round($freeKb / 1024, 0),
'available' => round($availableKb / 1024, 0),
'buffers' => round($buffersKb / 1024, 0),
'cached' => round($cachedKb / 1024, 0),
'usage_percent' => $totalKb > 0 ? round(($usedKb / $totalKb) * 100, 1) : 0,
'swap' => [
'total' => round($swapTotalKb / 1024, 0),
'used' => round($swapUsedKb / 1024, 0),
'free' => round($swapFreeKb / 1024, 0),
'usage_percent' => $swapTotalKb > 0 ? round(($swapUsedKb / $swapTotalKb) * 100, 1) : 0,
],
],
];
}
function metricsDisk(array $params): array
{
$disks = [];
// Get disk usage using df
exec('df -B1 -x tmpfs -x devtmpfs -x squashfs -x overlay 2>/dev/null', $output);
foreach ($output as $i => $line) {
if ($i === 0) continue; // Skip header
$parts = preg_split('/\s+/', $line);
if (count($parts) < 6) continue;
$filesystem = $parts[0];
$total = (int) $parts[1];
$used = (int) $parts[2];
$available = (int) $parts[3];
$mount = $parts[5];
// Skip small filesystems and special mounts
if ($total < 1024 * 1024 * 100) continue; // Less than 100MB
if (strpos($mount, '/snap') === 0) continue;
if (strpos($mount, '/boot/efi') === 0) continue;
$disks[] = [
'filesystem' => $filesystem,
'mount' => $mount,
'total' => $total,
'used' => $used,
'available' => $available,
'usage_percent' => $total > 0 ? round(($used / $total) * 100, 1) : 0,
'total_human' => formatBytes($total),
'used_human' => formatBytes($used),
'available_human' => formatBytes($available),
];
}
// Get disk I/O stats
$io = getDiskIoStats();
return [
'success' => true,
'data' => [
'partitions' => $disks,
'io' => $io,
],
];
}
function getDiskIoStats(): array
{
$stats = [];
if (!file_exists('/proc/diskstats')) {
return $stats;
}
$lines = file('/proc/diskstats', FILE_IGNORE_NEW_LINES);
foreach ($lines as $line) {
$parts = preg_split('/\s+/', trim($line));
if (count($parts) < 14) continue;
$device = $parts[2];
// Only include main disks (sda, nvme0n1, vda, etc.)
if (!preg_match('/^(sd[a-z]|nvme\d+n\d+|vd[a-z]|xvd[a-z])$/', $device)) {
continue;
}
$stats[$device] = [
'reads' => (int) $parts[3],
'read_sectors' => (int) $parts[5],
'read_time_ms' => (int) $parts[6],
'writes' => (int) $parts[7],
'write_sectors' => (int) $parts[9],
'write_time_ms' => (int) $parts[10],
'io_in_progress' => (int) $parts[11],
'io_time_ms' => (int) $parts[12],
];
}
return $stats;
}
function formatBytes(int $bytes, int $precision = 2): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= pow(1024, $pow);
return round($bytes, $precision) . ' ' . $units[$pow];
}
function metricsNetwork(array $params): array
{
$interfaces = [];
// Read network interface stats
$lines = file('/proc/net/dev', FILE_IGNORE_NEW_LINES);
foreach ($lines as $line) {
if (strpos($line, ':') === false) continue;
list($iface, $stats) = explode(':', $line, 2);
$iface = trim($iface);
// Skip loopback
if ($iface === 'lo') continue;
$parts = preg_split('/\s+/', trim($stats));
$interfaces[$iface] = [
'rx_bytes' => (int) $parts[0],
'rx_packets' => (int) $parts[1],
'rx_errors' => (int) $parts[2],
'tx_bytes' => (int) $parts[8],
'tx_packets' => (int) $parts[9],
'tx_errors' => (int) $parts[10],
'rx_human' => formatBytes((int) $parts[0]),
'tx_human' => formatBytes((int) $parts[8]),
];
// Get IP address
exec("ip addr show $iface 2>/dev/null | grep 'inet ' | awk '{print \$2}'", $ipOutput);
$interfaces[$iface]['ip'] = $ipOutput[0] ?? '';
}
// Get active connections count
exec("ss -tun | wc -l", $connOutput);
$connections = max(0, ((int) ($connOutput[0] ?? 0)) - 1);
return [
'success' => true,
'data' => [
'interfaces' => $interfaces,
'connections' => $connections,
],
];
}
function metricsProcesses(array $params): array
{
$limit = $params['limit'] ?? 15;
$sortBy = $params['sort'] ?? 'cpu'; // cpu, memory
$processes = [];
// Get top processes
$sortFlag = $sortBy === 'memory' ? '-m' : '';
exec("ps aux --sort=-%cpu 2>/dev/null | head -" . ($limit + 1), $output);
foreach ($output as $i => $line) {
if ($i === 0) continue; // Skip header
$parts = preg_split('/\s+/', $line, 11);
if (count($parts) < 11) continue;
$processes[] = [
'user' => $parts[0],
'pid' => (int) $parts[1],
'cpu' => (float) $parts[2],
'memory' => (float) $parts[3],
'vsz' => (int) $parts[4],
'rss' => (int) $parts[5],
'stat' => $parts[7],
'started' => $parts[8],
'time' => $parts[9],
'command' => $parts[10],
];
}
// Get process counts
exec("ps aux | wc -l", $totalOutput);
$total = max(0, ((int) ($totalOutput[0] ?? 0)) - 1);
exec("ps aux | awk '\$8 ~ /R/ {count++} END {print count+0}'", $runningOutput);
$running = (int) ($runningOutput[0] ?? 0);
return [
'success' => true,
'data' => [
'total' => $total,
'running' => $running,
'top' => $processes,
],
];
}
function systemKillProcess(array $params): array
{
$pid = $params['pid'] ?? 0;
$signal = $params['signal'] ?? 15; // SIGTERM default
if (!$pid || !is_numeric($pid) || $pid <= 0) {
return ['success' => false, 'error' => 'Invalid PID'];
}
$pid = (int) $pid;
$signal = (int) $signal;
// Only allow safe signals
$allowedSignals = [1, 9, 15]; // SIGHUP, SIGKILL, SIGTERM
if (!in_array($signal, $allowedSignals, true)) {
return ['success' => false, 'error' => 'Invalid signal. Allowed: 1 (HUP), 9 (KILL), 15 (TERM)'];
}
// Prevent killing critical system processes (PID 1 is init)
if ($pid === 1) {
return ['success' => false, 'error' => 'Cannot kill init process'];
}
// Check if process exists
if (!posix_kill($pid, 0)) {
return ['success' => false, 'error' => 'Process does not exist'];
}
// Get process info for logging
$cmdline = @file_get_contents("/proc/$pid/cmdline");
$cmdline = str_replace("\0", ' ', $cmdline);
// Try to kill the process
if (posix_kill($pid, $signal)) {
logger("Killed process PID=$pid signal=$signal command=$cmdline", 'INFO');
return [
'success' => true,
'message' => "Process $pid terminated with signal $signal",
'pid' => $pid,
'signal' => $signal,
];
}
$error = posix_strerror(posix_get_last_error());
logger("Failed to kill process PID=$pid signal=$signal error=$error", 'ERROR');
return ['success' => false, 'error' => "Failed to kill process: $error"];
}
function getUptime(): array
{
$uptime = (float) trim(file_get_contents('/proc/uptime') ?? '0');
$uptime = (int) $uptime;
$days = floor($uptime / 86400);
$hours = floor(($uptime % 86400) / 3600);
$minutes = floor(($uptime % 3600) / 60);
$seconds = $uptime % 60;
$human = '';
if ($days > 0) $human .= "{$days}d ";
if ($hours > 0) $human .= "{$hours}h ";
if ($minutes > 0) $human .= "{$minutes}m ";
$human .= "{$seconds}s";
return [
'seconds' => $uptime,
'days' => $days,
'hours' => $hours,
'minutes' => $minutes,
'human' => trim($human),
];
}
function getOsInfo(): array
{
$osRelease = [];
if (file_exists('/etc/os-release')) {
$lines = file('/etc/os-release', FILE_IGNORE_NEW_LINES);
foreach ($lines as $line) {
if (strpos($line, '=') !== false) {
list($key, $value) = explode('=', $line, 2);
$osRelease[$key] = trim($value, '"');
}
}
}
return [
'name' => $osRelease['NAME'] ?? 'Linux',
'version' => $osRelease['VERSION'] ?? '',
'id' => $osRelease['ID'] ?? 'linux',
'pretty_name' => $osRelease['PRETTY_NAME'] ?? 'Linux',
];
}
// ============ DISK QUOTA MANAGEMENT ============
/**
* Check if quota system is enabled on the filesystem
*/
function quotaStatus(array $params): array
{
$mountPoint = $params['mount'] ?? '/home';
// Check if quota tools are installed
$quotaCheck = trim(shell_exec('which quota 2>/dev/null') ?? '');
if (empty($quotaCheck)) {
return [
'success' => true,
'enabled' => false,
'available' => false,
'reason' => 'Quota tools not installed. Install with: apt install quota'
];
}
// Find the actual mount point for /home
$findMount = trim(shell_exec("df --output=target " . escapeshellarg($mountPoint) . " 2>/dev/null | tail -1") ?? '');
if (empty($findMount)) {
$findMount = '/';
}
// Check if quota is enabled in fstab
$fstab = file_get_contents('/etc/fstab');
$quotaInFstab = preg_match('/\s+' . preg_quote($findMount, '/') . '\s+\S+\s+\S*usrquota/', $fstab);
// Check if quota is active
exec("quotaon -p " . escapeshellarg($findMount) . " 2>&1", $output, $exitCode);
$quotaActive = $exitCode === 0 && strpos(implode(' ', $output), 'is on') !== false;
return [
'success' => true,
'enabled' => $quotaActive,
'available' => true,
'configured' => $quotaInFstab,
'mount_point' => $findMount,
'message' => $quotaActive ? 'Quota system is active' : ($quotaInFstab ? 'Quota configured but not active' : 'Quota not configured')
];
}
/**
* Enable quota system on filesystem
*/
function quotaEnable(array $params): array
{
$mountPoint = $params['mount'] ?? '/home';
// Check if running as root
if (posix_getuid() !== 0) {
return ['success' => false, 'error' => 'Must run as root to enable quotas'];
}
// Check if quota tools are installed
$quotaCheck = trim(shell_exec('which quota 2>/dev/null') ?? '');
if (empty($quotaCheck)) {
// Try to install quota tools
exec('apt-get update && apt-get install -y quota 2>&1', $output, $exitCode);
if ($exitCode !== 0) {
return ['success' => false, 'error' => 'Failed to install quota tools: ' . implode("\n", $output)];
}
}
// Find the actual mount point
$findMount = trim(shell_exec("df --output=target " . escapeshellarg($mountPoint) . " 2>/dev/null | tail -1") ?? '');
if (empty($findMount)) {
$findMount = '/';
}
// Check current fstab entry
$fstab = file_get_contents('/etc/fstab');
$lines = explode("\n", $fstab);
$modified = false;
foreach ($lines as &$line) {
// Skip comments and empty lines
if (empty(trim($line)) || strpos(trim($line), '#') === 0) {
continue;
}
$parts = preg_split('/\s+/', $line);
if (count($parts) >= 4 && $parts[1] === $findMount) {
// Check if usrquota is already in options
if (strpos($parts[3], 'usrquota') === false) {
$parts[3] .= ',usrquota,grpquota';
$line = implode("\t", $parts);
$modified = true;
}
break;
}
}
if ($modified) {
// Backup and update fstab
copy('/etc/fstab', '/etc/fstab.bak.' . time());
file_put_contents('/etc/fstab', implode("\n", $lines));
// Remount filesystem
exec("mount -o remount " . escapeshellarg($findMount) . " 2>&1", $output, $exitCode);
if ($exitCode !== 0) {
return ['success' => false, 'error' => 'Failed to remount filesystem. A reboot may be required.'];
}
}
// Create quota files
exec("quotacheck -cugm " . escapeshellarg($findMount) . " 2>&1", $output, $exitCode);
// Turn on quotas
exec("quotaon " . escapeshellarg($findMount) . " 2>&1", $output, $exitCode);
return [
'success' => true,
'mount_point' => $findMount,
'message' => 'Quota system enabled successfully'
];
}
/**
* Set disk quota for a user
*/
function quotaSet(array $params): array
{
$username = $params['username'] ?? '';
$softLimit = $params['soft_mb'] ?? 0; // Soft limit in MB
$hardLimit = $params['hard_mb'] ?? 0; // Hard limit in MB (usually same as soft or slightly higher)
$mountPoint = $params['mount'] ?? '/home';
if (empty($username)) {
return ['success' => false, 'error' => 'Username is required'];
}
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username format'];
}
// Check if user exists
exec("id " . escapeshellarg($username) . " 2>/dev/null", $output, $exitCode);
if ($exitCode !== 0) {
return ['success' => false, 'error' => 'User does not exist'];
}
// Find the actual mount point
$findMount = trim(shell_exec("df --output=target " . escapeshellarg($mountPoint) . " 2>/dev/null | tail -1") ?? '');
if (empty($findMount)) {
$findMount = '/';
}
// Convert MB to KB (setquota uses KB for blocks)
$softBlocks = $softLimit * 1024;
$hardBlocks = $hardLimit * 1024;
// Set soft and hard limits to 0 to remove quota
if ($softLimit == 0 && $hardLimit == 0) {
$cmd = sprintf(
'/usr/sbin/setquota -u %s 0 0 0 0 %s 2>&1',
escapeshellarg($username),
escapeshellarg($findMount)
);
} else {
// If only one limit provided, use it for both
if ($hardLimit == 0) {
$hardBlocks = $softBlocks;
}
if ($softLimit == 0) {
$softBlocks = $hardBlocks;
}
// Set quota: soft block, hard block, soft inode (0=unlimited), hard inode (0=unlimited)
$cmd = sprintf(
'/usr/sbin/setquota -u %s %d %d 0 0 %s 2>&1',
escapeshellarg($username),
$softBlocks,
$hardBlocks,
escapeshellarg($findMount)
);
}
exec($cmd, $output, $exitCode);
if ($exitCode !== 0) {
return ['success' => false, 'error' => 'Failed to set quota: ' . implode("\n", $output)];
}
return [
'success' => true,
'username' => $username,
'soft_mb' => $softLimit,
'hard_mb' => $hardLimit ?: $softLimit,
'mount_point' => $findMount,
'message' => $softLimit > 0 ? "Quota set to {$softLimit} MB" : 'Quota removed'
];
}
/**
* Get disk quota for a user
*/
function quotaGet(array $params): array
{
$username = $params['username'] ?? '';
$mountPoint = $params['mount'] ?? '/home';
if (empty($username)) {
return ['success' => false, 'error' => 'Username is required'];
}
// Find the actual mount point
$findMount = trim(shell_exec("df --output=target " . escapeshellarg($mountPoint) . " 2>/dev/null | tail -1") ?? '');
if (empty($findMount)) {
$findMount = '/';
}
// Get quota info
$cmd = sprintf('quota -u %s -w 2>&1', escapeshellarg($username));
exec($cmd, $output, $exitCode);
$outputStr = implode("\n", $output);
// Check if user has no quota
if (strpos($outputStr, 'none') !== false || $exitCode !== 0) {
// Try repquota for more detailed info
$cmd2 = sprintf('repquota -u %s 2>/dev/null | grep "^%s"',
escapeshellarg($findMount),
escapeshellarg($username)
);
$repOutput = trim(shell_exec($cmd2) ?? '');
if (empty($repOutput)) {
// Quota not enabled - use du to calculate actual disk usage
$homeDir = "/home/{$username}";
$usedMb = 0;
if (is_dir($homeDir)) {
// Use du to get actual disk usage in KB
$duOutput = trim(shell_exec("du -sk " . escapeshellarg($homeDir) . " 2>/dev/null | cut -f1") ?? '0');
$usedKb = (int)$duOutput;
$usedMb = round($usedKb / 1024, 2);
}
return [
'success' => true,
'username' => $username,
'has_quota' => false,
'used_mb' => $usedMb,
'soft_mb' => 0,
'hard_mb' => 0,
'usage_percent' => 0,
'quota_source' => 'du' // Indicate that we used du fallback
];
}
// Parse repquota output
// Format: username -- used soft hard grace used soft hard grace
$parts = preg_split('/\s+/', $repOutput);
if (count($parts) >= 5) {
$usedKb = (int)$parts[2];
$softKb = (int)$parts[3];
$hardKb = (int)$parts[4];
return [
'success' => true,
'username' => $username,
'has_quota' => $softKb > 0 || $hardKb > 0,
'used_mb' => round($usedKb / 1024, 2),
'soft_mb' => round($softKb / 1024, 2),
'hard_mb' => round($hardKb / 1024, 2),
'usage_percent' => $hardKb > 0 ? round(($usedKb / $hardKb) * 100, 1) : 0
];
}
}
// Parse standard quota output
// Look for lines with filesystem info
$usedKb = 0;
$softKb = 0;
$hardKb = 0;
foreach ($output as $line) {
if (preg_match('/^\s*(\S+)\s+(\d+)\s+(\d+)\s+(\d+)/', $line, $matches)) {
$usedKb = (int)$matches[2];
$softKb = (int)$matches[3];
$hardKb = (int)$matches[4];
break;
}
}
return [
'success' => true,
'username' => $username,
'has_quota' => $softKb > 0 || $hardKb > 0,
'used_mb' => round($usedKb / 1024, 2),
'soft_mb' => round($softKb / 1024, 2),
'hard_mb' => round($hardKb / 1024, 2),
'usage_percent' => $hardKb > 0 ? round(($usedKb / $hardKb) * 100, 1) : 0
];
}
/**
* Get quota report for all users
*/
function quotaReport(array $params): array
{
$mountPoint = $params['mount'] ?? '/home';
// Find the actual mount point
$findMount = trim(shell_exec("df --output=target " . escapeshellarg($mountPoint) . " 2>/dev/null | tail -1") ?? '');
if (empty($findMount)) {
$findMount = '/';
}
// Get repquota output
exec("repquota -u " . escapeshellarg($findMount) . " 2>/dev/null", $output, $exitCode);
if ($exitCode !== 0) {
return ['success' => false, 'error' => 'Failed to get quota report. Quotas may not be enabled.'];
}
$users = [];
$inUserSection = false;
foreach ($output as $line) {
// Skip header lines
if (strpos($line, '---') !== false) {
$inUserSection = true;
continue;
}
if (!$inUserSection) {
continue;
}
// Parse user line: username -- used soft hard grace used soft hard grace
// Or: username +- used soft hard grace used soft hard grace (+ means over soft, - means under)
if (preg_match('/^(\S+)\s+([+-]{2})\s+(\d+)\s+(\d+)\s+(\d+)/', $line, $matches)) {
$username = $matches[1];
$usedKb = (int)$matches[3];
$softKb = (int)$matches[4];
$hardKb = (int)$matches[5];
// Skip system users and users without quota
if (in_array($username, ['root', 'nobody', 'www-data', 'mysql', 'redis'])) {
continue;
}
$users[] = [
'username' => $username,
'used_mb' => round($usedKb / 1024, 2),
'soft_mb' => round($softKb / 1024, 2),
'hard_mb' => round($hardKb / 1024, 2),
'usage_percent' => $hardKb > 0 ? round(($usedKb / $hardKb) * 100, 1) : 0,
'over_quota' => $matches[2][0] === '+'
];
}
}
return [
'success' => true,
'mount_point' => $findMount,
'users' => $users
];
}
// ============ IP ADDRESS MANAGEMENT ============
function ipList(array $params): array
{
$addresses = [];
// Get all IP addresses using ip command
exec('ip -4 addr show 2>/dev/null', $ipv4Output, $ipv4Code);
exec('ip -6 addr show 2>/dev/null', $ipv6Output, $ipv6Code);
$currentInterface = '';
$currentMac = '';
$currentState = '';
// Parse IPv4 addresses
foreach ($ipv4Output as $line) {
// Match interface line: 2: eth0: <BROADCAST,...> ...
if (preg_match('/^\d+:\s+(\S+):\s+<([^>]+)>/', $line, $m)) {
$currentInterface = rtrim($m[1], ':');
$flags = explode(',', $m[2]);
$currentState = in_array('UP', $flags) ? 'up' : 'down';
}
// Match MAC address
if (preg_match('/link\/ether\s+([0-9a-f:]+)/i', $line, $m)) {
$currentMac = $m[1];
}
// Match inet address
if (preg_match('/inet\s+(\d+\.\d+\.\d+\.\d+)\/(\d+)\s+(?:brd\s+(\S+)\s+)?scope\s+(\S+)/', $line, $m)) {
$ip = $m[1];
$cidr = (int)$m[2];
$broadcast = $m[3] ?? '';
$scope = $m[4];
// Calculate netmask from CIDR
$netmask = long2ip(-1 << (32 - $cidr));
// Skip loopback
if ($currentInterface === 'lo') {
continue;
}
$addresses[] = [
'ip' => $ip,
'version' => 4,
'cidr' => $cidr,
'netmask' => $netmask,
'broadcast' => $broadcast,
'interface' => $currentInterface,
'mac' => $currentMac,
'scope' => $scope,
'state' => $currentState,
'is_primary' => $scope === 'global' && strpos($ip, '192.168.') !== 0 && strpos($ip, '10.') !== 0 && strpos($ip, '172.') !== 0,
];
}
}
// Parse IPv6 addresses (non-link-local)
$currentInterface = '';
foreach ($ipv6Output as $line) {
if (preg_match('/^\d+:\s+(\S+):\s+<([^>]+)>/', $line, $m)) {
$currentInterface = rtrim($m[1], ':');
$flags = explode(',', $m[2]);
$currentState = in_array('UP', $flags) ? 'up' : 'down';
}
if (preg_match('/link\/ether\s+([0-9a-f:]+)/i', $line, $m)) {
$currentMac = $m[1];
}
// Match inet6 address (skip link-local fe80:: and loopback ::1)
if (preg_match('/inet6\s+([0-9a-f:]+)\/(\d+)\s+scope\s+(\S+)/', $line, $m)) {
$ip = $m[1];
$cidr = (int)$m[2];
$scope = $m[3];
if ($currentInterface === 'lo' || $scope === 'link') {
continue;
}
$addresses[] = [
'ip' => $ip,
'version' => 6,
'cidr' => $cidr,
'netmask' => '',
'broadcast' => '',
'interface' => $currentInterface,
'mac' => $currentMac,
'scope' => $scope,
'state' => $currentState,
'is_primary' => false,
];
}
}
// Get default gateway
$gateway = '';
exec('ip route | grep default 2>/dev/null', $routeOutput);
if (!empty($routeOutput[0]) && preg_match('/default via (\S+)/', $routeOutput[0], $m)) {
$gateway = $m[1];
}
// Get DNS servers
$dns = [];
if (file_exists('/etc/resolv.conf')) {
$resolv = file_get_contents('/etc/resolv.conf');
if (preg_match_all('/nameserver\s+(\S+)/', $resolv, $matches)) {
$dns = $matches[1];
}
}
// Get available interfaces
$interfaces = [];
exec('ls /sys/class/net 2>/dev/null', $ifaceOutput);
foreach ($ifaceOutput as $iface) {
if ($iface !== 'lo') {
$interfaces[] = $iface;
}
}
return [
'success' => true,
'addresses' => $addresses,
'gateway' => $gateway,
'dns' => $dns,
'interfaces' => $interfaces,
];
}
function ipAdd(array $params): array
{
$ip = $params['ip'] ?? '';
$cidr = $params['cidr'] ?? 24;
$interface = $params['interface'] ?? '';
if (empty($ip)) {
return ['success' => false, 'error' => 'IP address required'];
}
if (empty($interface)) {
return ['success' => false, 'error' => 'Interface required'];
}
// Validate IP address
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
return ['success' => false, 'error' => 'Invalid IP address format'];
}
// Validate CIDR
$cidr = (int)$cidr;
$maxCidr = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? 128 : 32;
if ($cidr < 1 || $cidr > $maxCidr) {
return ['success' => false, 'error' => 'Invalid CIDR notation'];
}
// Validate interface exists
if (!file_exists("/sys/class/net/$interface")) {
return ['success' => false, 'error' => "Interface $interface does not exist"];
}
// Add the IP address
$cmd = sprintf(
'ip addr add %s/%d dev %s 2>&1',
escapeshellarg($ip),
$cidr,
escapeshellarg($interface)
);
exec($cmd, $output, $code);
if ($code !== 0) {
$error = implode("\n", $output);
if (strpos($error, 'File exists') !== false) {
return ['success' => false, 'error' => 'IP address already exists on this interface'];
}
return ['success' => false, 'error' => 'Failed to add IP: ' . $error];
}
// Make persistent by adding to netplan or interfaces file
$persistResult = ipPersist($ip, $cidr, $interface, 'add');
return [
'success' => true,
'message' => "IP address $ip/$cidr added to $interface",
'persistent' => $persistResult,
];
}
function ipRemove(array $params): array
{
$ip = $params['ip'] ?? '';
$cidr = $params['cidr'] ?? 24;
$interface = $params['interface'] ?? '';
if (empty($ip)) {
return ['success' => false, 'error' => 'IP address required'];
}
if (empty($interface)) {
return ['success' => false, 'error' => 'Interface required'];
}
// Validate IP address
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
return ['success' => false, 'error' => 'Invalid IP address format'];
}
// Remove the IP address
$cmd = sprintf(
'ip addr del %s/%d dev %s 2>&1',
escapeshellarg($ip),
(int)$cidr,
escapeshellarg($interface)
);
exec($cmd, $output, $code);
if ($code !== 0) {
$error = implode("\n", $output);
if (strpos($error, 'Cannot assign') !== false) {
return ['success' => false, 'error' => 'IP address not found on this interface'];
}
return ['success' => false, 'error' => 'Failed to remove IP: ' . $error];
}
// Remove from persistent config
$persistResult = ipPersist($ip, $cidr, $interface, 'remove');
return [
'success' => true,
'message' => "IP address $ip removed from $interface",
'persistent' => $persistResult,
];
}
function ipPersist(string $ip, int $cidr, string $interface, string $action): bool
{
$isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
$inetType = $isIpv6 ? 'inet6' : 'inet';
// Check for netplan (Ubuntu 18.04+)
$netplanDir = '/etc/netplan';
if (is_dir($netplanDir)) {
$files = glob("$netplanDir/*.yaml");
if (!empty($files)) {
// For now, just return a note that manual persistence may be needed
// Full netplan editing would require YAML parsing
return false;
}
}
// Check for /etc/network/interfaces (Debian style)
$interfacesFile = '/etc/network/interfaces';
if (file_exists($interfacesFile)) {
// For secondary IPs, we'd typically use interfaces.d
$interfacesDDir = '/etc/network/interfaces.d';
if (!is_dir($interfacesDDir)) {
@mkdir($interfacesDDir, 0755);
}
$aliasFile = "$interfacesDDir/$interface-aliases";
if ($action === 'add') {
// Count existing aliases to determine next number
$aliasNum = 0;
if (file_exists($aliasFile)) {
$content = file_get_contents($aliasFile);
preg_match_all('/iface\s+' . preg_quote($interface, '/') . ':(\d+)/', $content, $m);
if (!empty($m[1])) {
$aliasNum = max($m[1]) + 1;
}
}
$aliasConfig = "\n# Added by Jabali Panel\nauto $interface:$aliasNum\niface $interface:$aliasNum {$inetType} static\n address $ip/$cidr\n";
file_put_contents($aliasFile, $aliasConfig, FILE_APPEND);
return true;
} elseif ($action === 'remove' && file_exists($aliasFile)) {
// Remove the IP from aliases file
$content = file_get_contents($aliasFile);
// Match and remove the block for this IP
$pattern = '/\n?#[^\n]*\nauto\s+' . preg_quote($interface, '/') . ':\d+\niface\s+' . preg_quote($interface, '/') . ':\d+\s+' . $inetType . '\s+static\s+address\s+' . preg_quote("$ip/$cidr", '/') . '\s*/';
$content = preg_replace($pattern, '', $content);
file_put_contents($aliasFile, $content);
return true;
}
}
return false;
}
function ipInfo(array $params): array
{
$ip = $params['ip'] ?? '';
if (empty($ip)) {
return ['success' => false, 'error' => 'IP address required'];
}
// Check if it's a public or private IP
$isPrivate = false;
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
// Check private ranges
$isPrivate = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE) === false;
}
// Get reverse DNS
$rdns = @gethostbyaddr($ip);
if ($rdns === $ip) {
$rdns = '';
}
// Check if IP is reachable (quick ping)
exec("ping -c 1 -W 1 " . escapeshellarg($ip) . " 2>&1", $pingOutput, $pingCode);
$reachable = $pingCode === 0;
// Get whois info for public IPs (basic)
$whois = [];
if (!$isPrivate && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
exec("whois " . escapeshellarg($ip) . " 2>/dev/null | head -50", $whoisOutput);
foreach ($whoisOutput as $line) {
if (preg_match('/^(NetName|OrgName|Country|descr|netname|org-name|country):\s*(.+)/i', $line, $m)) {
$key = strtolower(trim($m[1]));
$whois[$key] = trim($m[2]);
}
}
}
return [
'success' => true,
'ip' => $ip,
'is_private' => $isPrivate,
'reverse_dns' => $rdns,
'reachable' => $reachable,
'whois' => $whois,
];
}
// ============ SECURITY SCANNER TOOLS ============
function scannerInstall(array $params): array
{
$tool = $params['tool'] ?? '';
if (empty($tool)) {
return ['success' => false, 'error' => 'Tool name required'];
}
$validTools = ['lynis', 'wpscan', 'nikto'];
if (!in_array($tool, $validTools)) {
return ['success' => false, 'error' => "Invalid tool: $tool"];
}
$output = [];
$code = 0;
switch ($tool) {
case 'lynis':
exec('apt-get update 2>&1', $output);
exec('apt-get install -y lynis 2>&1', $output, $code);
break;
case 'wpscan':
// Check if Ruby is installed
exec('which ruby 2>/dev/null', $rubyCheck, $rubyCode);
if ($rubyCode !== 0) {
exec('apt-get update 2>&1', $output);
exec('apt-get install -y ruby ruby-dev build-essential libcurl4-openssl-dev libxml2 libxml2-dev libxslt1-dev zlib1g-dev 2>&1', $output, $code);
if ($code !== 0) {
return ['success' => false, 'error' => 'Failed to install Ruby dependencies', 'output' => implode("\n", $output)];
}
}
exec('gem install wpscan 2>&1', $output, $code);
if ($code === 0) {
// Update WPScan database
exec('wpscan --update 2>&1', $updateOutput);
$output = array_merge($output, $updateOutput);
}
break;
case 'nikto':
$output = [];
$code = 0;
// First try apt
exec('apt-get update 2>&1', $updateOutput);
exec('apt-cache show nikto 2>&1', $cacheOutput, $cacheCode);
if ($cacheCode === 0) {
exec('DEBIAN_FRONTEND=noninteractive apt-get install -y nikto 2>&1', $output, $code);
} else {
// Install from GitHub if not in repos
exec('apt-get install -y perl libnet-ssleay-perl openssl git 2>&1', $depOutput, $depCode);
if ($depCode !== 0) {
return ['success' => false, 'error' => 'Failed to install Nikto dependencies', 'output' => implode("\n", $depOutput)];
}
// Clone or update Nikto
$niktoDir = '/opt/nikto';
if (is_dir($niktoDir)) {
exec("cd $niktoDir && git pull 2>&1", $gitOutput, $gitCode);
} else {
exec("git clone https://github.com/sullo/nikto.git $niktoDir 2>&1", $gitOutput, $gitCode);
}
if ($gitCode !== 0) {
return ['success' => false, 'error' => 'Failed to download Nikto', 'output' => implode("\n", $gitOutput)];
}
// Create symlink
exec("ln -sf $niktoDir/program/nikto.pl /usr/local/bin/nikto 2>&1", $linkOutput, $linkCode);
exec("chmod +x $niktoDir/program/nikto.pl 2>&1");
$output = array_merge($depOutput ?? [], $gitOutput ?? [], $linkOutput ?? []);
$code = 0; // Success
}
break;
}
if ($code !== 0) {
return [
'success' => false,
'error' => "Failed to install $tool",
'output' => implode("\n", array_slice($output, -10)),
];
}
// Verify installation
$status = scannerStatus(['tool' => $tool]);
return [
'success' => true,
'message' => "$tool installed successfully",
'installed' => $status['installed'] ?? false,
'version' => $status['version'] ?? '',
'output' => implode("\n", array_slice($output, -5)),
];
}
function scannerUninstall(array $params): array
{
$tool = $params['tool'] ?? '';
if (empty($tool)) {
return ['success' => false, 'error' => 'Tool name required'];
}
$validTools = ['lynis', 'wpscan', 'nikto'];
if (!in_array($tool, $validTools)) {
return ['success' => false, 'error' => "Invalid tool: $tool"];
}
$output = [];
$code = 0;
switch ($tool) {
case 'lynis':
exec('apt-get remove -y lynis 2>&1', $output, $code);
exec('apt-get autoremove -y 2>&1', $autoOutput);
$output = array_merge($output, $autoOutput);
break;
case 'wpscan':
exec('gem uninstall wpscan -x 2>&1', $output, $code);
break;
case 'nikto':
// Remove apt package if installed
exec('dpkg -l nikto 2>/dev/null | grep -q "^ii"', $dpkgOutput, $dpkgCode);
if ($dpkgCode === 0) {
exec('apt-get remove -y nikto 2>&1', $output, $code);
exec('apt-get autoremove -y 2>&1', $autoOutput);
$output = array_merge($output, $autoOutput);
}
// Remove GitHub installation
if (is_dir('/opt/nikto')) {
exec('rm -rf /opt/nikto 2>&1', $rmOutput);
$output = array_merge($output, $rmOutput);
}
if (file_exists('/usr/local/bin/nikto')) {
exec('rm -f /usr/local/bin/nikto 2>&1', $rmOutput);
$output = array_merge($output, $rmOutput);
}
$code = 0; // Success if we got here
break;
}
// Verify uninstallation
$status = scannerStatus(['tool' => $tool]);
if ($status['installed'] ?? false) {
return [
'success' => false,
'error' => "Failed to uninstall $tool",
'output' => implode("\n", array_slice($output, -10)),
];
}
return [
'success' => true,
'message' => "$tool uninstalled successfully",
'output' => implode("\n", array_slice($output, -5)),
];
}
function scannerStatus(array $params): array
{
$tool = $params['tool'] ?? '';
if (!empty($tool)) {
// Check specific tool
return checkScannerTool($tool);
}
// Check all tools
return [
'success' => true,
'lynis' => checkScannerTool('lynis'),
'wpscan' => checkScannerTool('wpscan'),
'nikto' => checkScannerTool('nikto'),
];
}
function checkScannerTool(string $tool): array
{
$result = [
'installed' => false,
'version' => '',
];
switch ($tool) {
case 'lynis':
exec('which lynis 2>/dev/null', $output, $code);
$result['installed'] = $code === 0;
if ($result['installed']) {
exec('lynis --version 2>/dev/null | head -1', $versionOutput);
$result['version'] = trim($versionOutput[0] ?? 'Unknown');
}
break;
case 'wpscan':
exec('which wpscan 2>/dev/null', $output, $code);
$result['installed'] = $code === 0;
if ($result['installed']) {
exec('wpscan --version 2>/dev/null | head -1', $versionOutput);
$result['version'] = trim($versionOutput[0] ?? 'Unknown');
}
break;
case 'nikto':
exec('which nikto 2>/dev/null', $output, $code);
$result['installed'] = $code === 0;
if ($result['installed']) {
exec('nikto -Version 2>/dev/null | grep -i version | head -1', $versionOutput);
$result['version'] = trim($versionOutput[0] ?? 'Unknown');
}
break;
}
return $result;
}
function scannerRunLynis(array $params): array
{
$background = $params['background'] ?? false;
// Check if lynis is installed
exec('which lynis 2>/dev/null', $output, $code);
if ($code !== 0) {
return ['success' => false, 'error' => 'Lynis is not installed'];
}
// Create scan output directory
$scanDir = '/var/www/jabali/storage/app/security-scans';
if (!is_dir($scanDir)) {
mkdir($scanDir, 0755, true);
}
$statusFile = "$scanDir/lynis-status.json";
$liveLogFile = "$scanDir/lynis-live.log";
// Initialize status file
$status = [
'status' => 'running',
'started_at' => date('Y-m-d H:i:s'),
'output' => '',
];
file_put_contents($statusFile, json_encode($status));
chown($statusFile, 'www-data');
// Clear live log
file_put_contents($liveLogFile, '');
chown($liveLogFile, 'www-data');
// Run Lynis with real-time output capture
$tempLog = tempnam('/tmp', 'lynis_');
$tempReport = tempnam('/tmp', 'lynis_report_');
$cmd = sprintf(
'lynis audit system --no-colors --quick --logfile %s --report-file %s 2>&1',
escapeshellarg($tempLog),
escapeshellarg($tempReport)
);
// Use proc_open for real-time output
$descriptors = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
$process = proc_open($cmd, $descriptors, $pipes);
if (!is_resource($process)) {
return ['success' => false, 'error' => 'Failed to start Lynis'];
}
fclose($pipes[0]);
stream_set_blocking($pipes[1], false);
stream_set_blocking($pipes[2], false);
$rawOutput = '';
while (true) {
$stdout = fgets($pipes[1]);
$stderr = fgets($pipes[2]);
if ($stdout !== false) {
$rawOutput .= $stdout;
file_put_contents($liveLogFile, $stdout, FILE_APPEND);
}
if ($stderr !== false) {
$rawOutput .= $stderr;
file_put_contents($liveLogFile, $stderr, FILE_APPEND);
}
$procStatus = proc_get_status($process);
if (!$procStatus['running'] && $stdout === false && $stderr === false) {
break;
}
usleep(10000); // 10ms
}
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
// Parse results
$results = [
'hardening_index' => 0,
'warnings' => [],
'suggestions' => [],
'tests_performed' => 0,
];
// Extract hardening index
if (preg_match('/Hardening index\s*:\s*(\d+)/i', $rawOutput, $matches)) {
$results['hardening_index'] = (int) $matches[1];
}
// Extract tests performed
if (preg_match('/Tests performed\s*:\s*(\d+)/i', $rawOutput, $matches)) {
$results['tests_performed'] = (int) $matches[1];
}
// Extract warnings
preg_match_all('/\[WARNING\]\s*(.+)$/m', $rawOutput, $warningMatches);
$results['warnings'] = $warningMatches[1] ?? [];
// Extract suggestions
preg_match_all('/\[SUGGESTION\]\s*(.+)$/m', $rawOutput, $suggestionMatches);
$results['suggestions'] = $suggestionMatches[1] ?? [];
$results['scan_time'] = date('Y-m-d H:i:s');
$results['raw_output'] = $rawOutput;
// Save results
file_put_contents("$scanDir/lynis-latest.json", json_encode($results, JSON_PRETTY_PRINT));
chown("$scanDir/lynis-latest.json", 'www-data');
// Update status to completed
$status['status'] = 'completed';
$status['completed_at'] = date('Y-m-d H:i:s');
file_put_contents($statusFile, json_encode($status));
// Cleanup temp files
@unlink($tempLog);
@unlink($tempReport);
return [
'success' => true,
'results' => $results,
'warnings_count' => count($results['warnings']),
'suggestions_count' => count($results['suggestions']),
'hardening_index' => $results['hardening_index'],
];
}
function scannerRunNikto(array $params): array
{
$target = $params['target'] ?? '';
if (empty($target)) {
return ['success' => false, 'error' => 'Target URL/host required'];
}
// Check if nikto is installed
exec('which nikto 2>/dev/null', $checkOutput, $code);
if ($code !== 0) {
return ['success' => false, 'error' => 'Nikto is not installed'];
}
// Create scan output directory
$scanDir = '/var/www/jabali/storage/app/security-scans';
if (!is_dir($scanDir)) {
mkdir($scanDir, 0755, true);
}
$statusFile = "$scanDir/nikto-status.json";
$liveLogFile = "$scanDir/nikto-live.log";
// Initialize status file
$status = [
'status' => 'running',
'started_at' => date('Y-m-d H:i:s'),
'target' => $target,
];
file_put_contents($statusFile, json_encode($status));
chown($statusFile, 'www-data');
// Clear live log
file_put_contents($liveLogFile, '');
chown($liveLogFile, 'www-data');
// Run Nikto with timeout - use full path since timeout has restricted PATH
$niktoPath = file_exists('/usr/bin/nikto') ? '/usr/bin/nikto' : '/usr/local/bin/nikto';
$cmd = sprintf(
'timeout 300 %s -h %s 2>&1',
$niktoPath,
escapeshellarg($target)
);
// Use proc_open for real-time output
$descriptors = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
$process = proc_open($cmd, $descriptors, $pipes);
if (!is_resource($process)) {
return ['success' => false, 'error' => 'Failed to start Nikto'];
}
fclose($pipes[0]);
stream_set_blocking($pipes[1], false);
stream_set_blocking($pipes[2], false);
$rawOutput = '';
$output = [];
while (true) {
$stdout = fgets($pipes[1]);
$stderr = fgets($pipes[2]);
if ($stdout !== false) {
$rawOutput .= $stdout;
$output[] = rtrim($stdout);
file_put_contents($liveLogFile, $stdout, FILE_APPEND);
}
if ($stderr !== false) {
$rawOutput .= $stderr;
file_put_contents($liveLogFile, $stderr, FILE_APPEND);
}
$procStatus = proc_get_status($process);
if (!$procStatus['running'] && $stdout === false && $stderr === false) {
break;
}
usleep(10000); // 10ms
}
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
// Parse text output
$results = [
'vulnerabilities' => [],
'info' => [],
];
foreach ($output as $line) {
if (preg_match('/^\+\s*OSVDB-\d+:\s*(.+)/', $line, $matches)) {
$results['vulnerabilities'][] = trim($matches[1]);
} elseif (preg_match('/^\+\s*(.+)/', $line, $matches)) {
$results['info'][] = trim($matches[1]);
}
}
$results['scan_time'] = date('Y-m-d H:i:s');
$results['target'] = $target;
$results['raw_output'] = $rawOutput;
// Save results
file_put_contents("$scanDir/nikto-latest.json", json_encode($results, JSON_PRETTY_PRINT));
chown("$scanDir/nikto-latest.json", 'www-data');
// Update status to completed
$status['status'] = 'completed';
$status['completed_at'] = date('Y-m-d H:i:s');
file_put_contents($statusFile, json_encode($status));
return [
'success' => true,
'results' => $results,
'vulnerabilities_count' => count($results['vulnerabilities'] ?? []),
'info_count' => count($results['info'] ?? []),
];
}
function scannerStartLynis(array $params): array
{
// Check if lynis is installed
exec('which lynis 2>/dev/null', $output, $code);
if ($code !== 0) {
return ['success' => false, 'error' => 'Lynis is not installed'];
}
// Create scan output directory
$scanDir = '/var/www/jabali/storage/app/security-scans';
if (!is_dir($scanDir)) {
mkdir($scanDir, 0755, true);
}
$statusFile = "$scanDir/lynis-status.json";
$liveLogFile = "$scanDir/lynis-live.log";
$pidFile = "$scanDir/lynis.pid";
// Check if scan already running
if (file_exists($pidFile)) {
$pid = trim(file_get_contents($pidFile));
if (posix_kill((int)$pid, 0)) {
return ['success' => false, 'error' => 'Lynis scan already running'];
}
}
// Initialize status file
$status = [
'status' => 'running',
'started_at' => date('Y-m-d H:i:s'),
];
file_put_contents($statusFile, json_encode($status));
chown($statusFile, 'www-data');
// Clear live log
file_put_contents($liveLogFile, '');
chown($liveLogFile, 'www-data');
// Create wrapper script for background execution
$wrapperScript = "$scanDir/lynis-wrapper.sh";
$tempLog = "/tmp/lynis_" . uniqid() . ".log";
$tempReport = "/tmp/lynis_report_" . uniqid() . ".dat";
$resultsFile = "$scanDir/lynis-latest.json";
$scriptContent = <<<BASH
#!/bin/bash
lynis audit system --no-colors --quick --logfile "$tempLog" --report-file "$tempReport" 2>&1 | while IFS= read -r line; do
echo "\$line" >> "$liveLogFile"
done
# Parse results and save
RAW_OUTPUT=\$(cat "$liveLogFile")
HARDENING_INDEX=\$(echo "\$RAW_OUTPUT" | grep -oP 'Hardening index\s*:\s*\K\d+' || echo "0")
TESTS_PERFORMED=\$(echo "\$RAW_OUTPUT" | grep -oP 'Tests performed\s*:\s*\K\d+' || echo "0")
# Extract warnings and suggestions
WARNINGS=\$(echo "\$RAW_OUTPUT" | grep -oP '\[WARNING\]\s*\K.+' | head -50 | jq -R -s -c 'split("\n") | map(select(length > 0))')
SUGGESTIONS=\$(echo "\$RAW_OUTPUT" | grep -oP '\[SUGGESTION\]\s*\K.+' | head -100 | jq -R -s -c 'split("\n") | map(select(length > 0))')
# Escape raw output for JSON
RAW_ESCAPED=\$(echo "\$RAW_OUTPUT" | jq -R -s '.')
# Save results JSON
cat > "$resultsFile" << JSONEOF
{
"hardening_index": \$HARDENING_INDEX,
"tests_performed": \$TESTS_PERFORMED,
"warnings": \$WARNINGS,
"suggestions": \$SUGGESTIONS,
"scan_time": "\$(date '+%Y-%m-%d %H:%M:%S')",
"raw_output": \$RAW_ESCAPED
}
JSONEOF
chown www-data:www-data "$resultsFile"
# Update status to completed
cat > "$statusFile" << EOF
{
"status": "completed",
"started_at": "{$status['started_at']}",
"completed_at": "\$(date '+%Y-%m-%d %H:%M:%S')",
"hardening_index": \$HARDENING_INDEX,
"tests_performed": \$TESTS_PERFORMED
}
EOF
chown www-data:www-data "$statusFile"
# Cleanup
rm -f "$tempLog" "$tempReport" "$pidFile" "$wrapperScript"
BASH;
file_put_contents($wrapperScript, $scriptContent);
chmod($wrapperScript, 0755);
// Start in background
$cmd = "nohup $wrapperScript > /dev/null 2>&1 & echo \$!";
$pid = trim(shell_exec($cmd));
file_put_contents($pidFile, $pid);
return [
'success' => true,
'message' => 'Lynis scan started',
'pid' => $pid,
];
}
function scannerGetScanStatus(array $params): array
{
$scanner = $params['scanner'] ?? 'lynis';
$scanDir = '/var/www/jabali/storage/app/security-scans';
$statusFile = "$scanDir/{$scanner}-status.json";
$liveLogFile = "$scanDir/{$scanner}-live.log";
$resultsFile = "$scanDir/{$scanner}-latest.json";
$status = [
'status' => 'idle',
'output' => '',
];
if (file_exists($statusFile)) {
$statusData = json_decode(file_get_contents($statusFile), true) ?? [];
$status = array_merge($status, $statusData);
}
if (file_exists($liveLogFile)) {
$status['output'] = file_get_contents($liveLogFile);
}
// If completed, load full results
if ($status['status'] === 'completed' && file_exists($resultsFile)) {
$status['results'] = json_decode(file_get_contents($resultsFile), true);
}
return $status;
}
// ============ LOG ANALYSIS ============
function logsTail(array $params): array
{
$username = $params['username'] ?? '';
$domain = $params['domain'] ?? '';
$logType = $params['type'] ?? 'access'; // access or error
$lines = min(intval($params['lines'] ?? 100), 500); // Max 500 lines
if (empty($username) || empty($domain)) {
return ['success' => false, 'error' => 'Username and domain are required'];
}
// Validate username
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
// Validate domain
if (!preg_match('/^[a-zA-Z0-9][a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/', $domain)) {
return ['success' => false, 'error' => 'Invalid domain'];
}
// Validate log type
if (!in_array($logType, ['access', 'error'])) {
return ['success' => false, 'error' => 'Invalid log type'];
}
$logFile = "/home/$username/domains/$domain/logs/{$logType}.log";
if (!file_exists($logFile)) {
return ['success' => false, 'error' => 'Log file not found'];
}
// Use tail to get last N lines, then reverse with tac (newest first)
$cmd = sprintf('tail -n %d %s 2>&1 | tac', $lines, escapeshellarg($logFile));
$output = shell_exec($cmd);
// Get file size and last modified time
$stat = stat($logFile);
return [
'success' => true,
'domain' => $domain,
'type' => $logType,
'lines' => $lines,
'content' => $output ?? '',
'file_size' => $stat['size'] ?? 0,
'last_modified' => date('Y-m-d H:i:s', $stat['mtime'] ?? time()),
];
}
function isPathWithinUserHome(string $path, string $username): bool
{
$home = "/home/$username/";
$resolved = realpath($path);
if ($resolved !== false) {
return str_starts_with($resolved, $home);
}
$normalized = rtrim($path, '/').'/';
return str_starts_with($normalized, $home);
}
function getDomainDocumentRoot(string $username, string $domain): string
{
$domainListFile = "/home/$username/.domains";
if (file_exists($domainListFile)) {
$domains = json_decode(file_get_contents($domainListFile), true);
if (is_array($domains)) {
$docRoot = $domains[$domain]['document_root'] ?? null;
if (is_string($docRoot) && $docRoot !== '') {
return $docRoot;
}
}
}
return "/home/$username/domains/$domain/public_html";
}
function resolveStatsDirectory(string $username, string $domain, string $documentRoot): string
{
$statsDir = rtrim($documentRoot, '/').'/stats';
$nginxConfig = "/etc/nginx/sites-available/$domain.conf";
if (file_exists($nginxConfig)) {
$config = file_get_contents($nginxConfig);
if ($config !== false && preg_match('/location\s+\/stats\/\s*\{[^}]*?\balias\s+([^;]+);/s', $config, $matches)) {
$aliasPath = trim($matches[1]);
if ($aliasPath !== '') {
$statsDir = rtrim($aliasPath, '/');
}
}
}
return $statsDir;
}
function logsGoaccess(array $params): array
{
$username = $params['username'] ?? '';
$domain = $params['domain'] ?? '';
$period = $params['period'] ?? 'today'; // today, week, month, all
if (empty($username) || empty($domain)) {
return ['success' => false, 'error' => 'Username and domain are required'];
}
// Validate username
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
// Validate domain
if (!preg_match('/^[a-zA-Z0-9][a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/', $domain)) {
return ['success' => false, 'error' => 'Invalid domain'];
}
// Check if goaccess is installed
if (!file_exists('/usr/bin/goaccess')) {
return ['success' => false, 'error' => 'GoAccess is not installed'];
}
$accessLog = "/home/$username/domains/$domain/logs/access.log";
if (!file_exists($accessLog)) {
return ['success' => false, 'error' => 'Access log not found'];
}
$documentRoot = getDomainDocumentRoot($username, $domain);
$statsDir = resolveStatsDirectory($username, $domain, $documentRoot);
$statsDir = rtrim($statsDir, '/');
if (!isPathWithinUserHome($statsDir, $username)) {
return ['success' => false, 'error' => 'Invalid stats directory'];
}
if (!is_dir($statsDir)) {
mkdir($statsDir, 0755, true);
chown($statsDir, $username);
chgrp($statsDir, $username);
}
$outputFile = "$statsDir/report.html";
// Ensure /stats location exists in nginx config to bypass WordPress
$nginxConfig = "/etc/nginx/sites-available/$domain.conf";
if (file_exists($nginxConfig)) {
$config = file_get_contents($nginxConfig);
if (strpos($config, 'location /stats/') === false) {
$statsAlias = $statsDir.'/';
// Add /stats location in the HTTPS (443) server block before the main location / block
// Find the 443 server block and insert the /stats location
$pattern = '/(listen 443 ssl;.*?)( location \/ \{)/s';
$replacement = '$1 # Stats directory - serve static files directly' . "\n" .
' location /stats/ {' . "\n" .
' alias ' . $statsAlias . ';' . "\n" .
' index report.html;' . "\n" .
' try_files $uri $uri/ =404;' . "\n" .
' }' . "\n\n" .
'$2';
$config = preg_replace($pattern, $replacement, $config, 1);
file_put_contents($nginxConfig, $config);
// Reload nginx
exec('systemctl reload nginx 2>&1');
}
}
// Build date filter based on period
$dateFilter = '';
switch ($period) {
case 'today':
$dateFilter = date('d/M/Y');
break;
case 'week':
// Last 7 days - no filter needed, goaccess will show all
break;
case 'month':
// Current month
$dateFilter = date('/M/Y');
break;
case 'all':
default:
// No filter
break;
}
// Generate HTML report with GoAccess
// Using COMBINED log format (nginx default)
$cmd = sprintf(
'/usr/bin/goaccess %s -o %s --log-format=COMBINED --no-query-string --anonymize-ip --html-report-title=%s 2>&1',
escapeshellarg($accessLog),
escapeshellarg($outputFile),
escapeshellarg("$domain Statistics")
);
exec($cmd, $output, $exitCode);
if ($exitCode !== 0 || !file_exists($outputFile)) {
return [
'success' => false,
'error' => 'Failed to generate report: ' . implode("\n", $output),
];
}
// Set proper ownership
chown($outputFile, $username);
chgrp($outputFile, $username);
// Get basic stats from the log file
$lineCount = intval(trim(shell_exec("wc -l < " . escapeshellarg($accessLog))));
$fileSize = filesize($accessLog);
return [
'success' => true,
'domain' => $domain,
'period' => $period,
'report_path' => $outputFile,
'report_url' => "/stats/report.html", // Relative URL for domain
'log_lines' => $lineCount,
'log_size' => $fileSize,
'generated_at' => date('Y-m-d H:i:s'),
];
}
// ============ JABALI SYSTEM SSH KEY ============
/**
* Get the Jabali system SSH public key
*/
function jabaliSshGetPublicKey(array $params): array
{
$keyDir = '/etc/jabali/ssh';
$keyName = 'jabali_rsa';
$keyComment = 'jabali-system-key';
$pubKeyPath = "$keyDir/$keyName.pub";
if (!file_exists($pubKeyPath)) {
return [
'success' => true,
'exists' => false,
'public_key' => null,
'key_name' => $keyComment,
];
}
$publicKey = trim(file_get_contents($pubKeyPath));
return [
'success' => true,
'exists' => true,
'public_key' => $publicKey,
'key_name' => $keyComment,
];
}
/**
* Ensure the Jabali system SSH key exists, generate if needed
*/
function jabaliSshEnsureExists(array $params): array
{
$keyDir = '/etc/jabali/ssh';
$keyName = 'jabali_rsa';
$keyComment = 'jabali-system-key';
$keyPath = "$keyDir/$keyName";
$pubKeyPath = "$keyPath.pub";
// Check if key already exists
if (file_exists($keyPath) && file_exists($pubKeyPath)) {
$publicKey = trim(file_get_contents($pubKeyPath));
return [
'success' => true,
'created' => false,
'public_key' => $publicKey,
'key_name' => $keyComment,
'message' => 'SSH key already exists',
];
}
// Create directory if it doesn't exist
if (!is_dir($keyDir)) {
if (!mkdir($keyDir, 0700, true)) {
return ['success' => false, 'error' => 'Failed to create SSH key directory'];
}
}
// Generate new SSH key
$command = sprintf(
'ssh-keygen -t rsa -b 4096 -f %s -N "" -C %s 2>&1',
escapeshellarg($keyPath),
escapeshellarg($keyComment)
);
exec($command, $output, $returnCode);
if ($returnCode !== 0) {
logger("Failed to generate SSH key: " . implode("\n", $output));
return ['success' => false, 'error' => 'Failed to generate SSH key: ' . implode("\n", $output)];
}
// Set proper permissions
chmod($keyPath, 0600);
chmod($pubKeyPath, 0600);
chmod($keyDir, 0700);
$publicKey = trim(file_get_contents($pubKeyPath));
logger("Generated Jabali system SSH key");
return [
'success' => true,
'created' => true,
'public_key' => $publicKey,
'key_name' => $keyComment,
'message' => 'SSH key generated successfully',
];
}
/**
* Get the Jabali system SSH private key (for importing to cPanel)
*/
function jabaliSshGetPrivateKey(array $params): array
{
$keyDir = '/etc/jabali/ssh';
$keyName = 'jabali_rsa';
$keyComment = 'jabali-system-key';
$keyPath = "$keyDir/$keyName";
if (!file_exists($keyPath)) {
return [
'success' => true,
'exists' => false,
'private_key' => null,
'key_name' => $keyComment,
];
}
$privateKey = file_get_contents($keyPath);
return [
'success' => true,
'exists' => true,
'private_key' => $privateKey,
'key_name' => $keyComment,
];
}
/**
* Add a public key to /root/.ssh/authorized_keys
* This allows incoming SSH connections using the corresponding private key
*/
function jabaliSshAddToAuthorizedKeys(array $params): array
{
$publicKey = $params['public_key'] ?? '';
$comment = $params['comment'] ?? 'cpanel-migration';
if (empty($publicKey)) {
return ['success' => false, 'error' => 'Public key is required'];
}
$sshDir = '/root/.ssh';
$authKeysPath = "$sshDir/authorized_keys";
// Create .ssh directory if it doesn't exist
if (!is_dir($sshDir)) {
if (!mkdir($sshDir, 0700, true)) {
return ['success' => false, 'error' => 'Failed to create .ssh directory'];
}
}
// Ensure proper permissions on .ssh directory
chmod($sshDir, 0700);
// Check if key already exists in authorized_keys
$existingKeys = '';
if (file_exists($authKeysPath)) {
$existingKeys = file_get_contents($authKeysPath);
// Extract the key part (without comment) for comparison
$keyParts = explode(' ', trim($publicKey));
$keyToFind = isset($keyParts[1]) ? $keyParts[1] : $publicKey;
if (strpos($existingKeys, $keyToFind) !== false) {
return [
'success' => true,
'already_exists' => true,
'message' => 'Key already exists in authorized_keys',
];
}
}
// Append the key with comment
$keyLine = trim($publicKey);
if (!empty($comment) && strpos($keyLine, $comment) === false) {
// Add comment if not already present
$keyParts = explode(' ', $keyLine);
if (count($keyParts) >= 2) {
$keyLine = $keyParts[0] . ' ' . $keyParts[1] . ' ' . $comment;
}
}
// Add newline before if file exists and doesn't end with newline
$prefix = '';
if (!empty($existingKeys) && substr($existingKeys, -1) !== "\n") {
$prefix = "\n";
}
if (file_put_contents($authKeysPath, $prefix . $keyLine . "\n", FILE_APPEND | LOCK_EX) === false) {
return ['success' => false, 'error' => 'Failed to write to authorized_keys'];
}
// Ensure proper permissions
chmod($authKeysPath, 0600);
logger("Added public key to /root/.ssh/authorized_keys: $comment");
return [
'success' => true,
'already_exists' => false,
'message' => 'Key added to authorized_keys',
];
}
// ============ CPANEL MIGRATION ============
/**
* Analyze a cPanel backup tar.gz file to extract information about its contents.
* Returns details about domains, databases, mailboxes, and SSL certificates.
*/
function cpanelAnalyzeBackup(array $params): array
{
$backupPath = $params['backup_path'] ?? '';
if (empty($backupPath) || !file_exists($backupPath)) {
return ['success' => false, 'error' => 'Backup file not found: ' . $backupPath];
}
logger("Analyzing cPanel backup: $backupPath");
// Create temp directory for extraction
$tempDir = sys_get_temp_dir() . '/cpanel_analyze_' . uniqid();
if (!mkdir($tempDir, 0755, true)) {
return ['success' => false, 'error' => 'Failed to create temp directory'];
}
try {
// Extract backup to temp directory (only metadata and structure, not full files)
// List contents first to understand structure
$listOutput = [];
// List all files - need to see SSL certs which may be near end of archive
exec("tar -I pigz -tf " . escapeshellarg($backupPath) . " 2>&1", $listOutput, $code);
if ($code !== 0) {
throw new Exception('Failed to list backup contents');
}
$data = [
'domains' => [],
'databases' => [],
'mailboxes' => [],
'forwarders' => [],
'ssl_certificates' => [],
'total_size' => filesize($backupPath),
'cpanel_username' => '',
];
// Find cPanel username from backup structure
$cpanelUser = '';
foreach ($listOutput as $file) {
// Pattern: backup-date_time_username/ or username/
if (preg_match('/^([^\/]+)\//', $file, $matches)) {
$firstDir = $matches[1];
if (preg_match('/^backup-[\d.]+_[\d-]+_(.+)$/', $firstDir, $userMatch)) {
$cpanelUser = $userMatch[1];
} else {
$cpanelUser = $firstDir;
}
break;
}
}
$data['cpanel_username'] = $cpanelUser;
// Parse file list to identify content types
$domainSet = [];
$dbSet = [];
$mailboxSet = [];
$forwarderSet = [];
$sslSet = [];
foreach ($listOutput as $file) {
// Skip directory entries
if (substr($file, -1) === '/') {
continue;
}
// Detect MySQL databases (mysql/*.sql or mysql/*.sql.gz)
if (preg_match('/mysql\/([^\/]+)\.(sql|sql\.gz)$/', $file, $matches)) {
$dbName = $matches[1];
if (!isset($dbSet[$dbName])) {
$dbSet[$dbName] = [
'name' => $dbName,
'file' => $file,
'size' => 0, // Will be determined during extraction
];
}
}
// Detect email data (mail/domain.com/user/ structure)
// Skip system folders and hidden directories
if (preg_match('/mail\/([^\/]+)\/([^\/]+)\//', $file, $matches)) {
$domain = $matches[1];
$localPart = $matches[2];
// Skip invalid domains (must contain a dot and not start with dot)
if (strpos($domain, '.') === false || strpos($domain, '.') === 0) {
continue;
}
// Skip system/hidden folders that aren't mailboxes
$skipFolders = ['cur', 'new', 'tmp', '.spam', '.Trash', '.Drafts', '.Sent', '.Junk', 'courierimapkeywords', 'maildirfolder'];
if (in_array($localPart, $skipFolders) || strpos($localPart, '.') === 0) {
continue;
}
$email = "$localPart@$domain";
if (!isset($mailboxSet[$email])) {
$mailboxSet[$email] = [
'email' => $email,
'local_part' => $localPart,
'domain' => $domain,
];
}
}
// Detect email forwarders - cPanel stores them in multiple formats:
// Format 1: etc/domainname/va/localpart - each file contains destination
// Format 2: va/domain - file contains lines like "localpart@domain: destination"
if (preg_match('/(?:homedir\/)?etc\/([^\/]+)\/va\/([^\/]+)$/', $file, $matches)) {
$domain = $matches[1];
$localPart = $matches[2];
// Skip invalid domains (must contain a dot)
if (strpos($domain, '.') === false) {
continue;
}
// Skip system files
if (in_array($localPart, ['.', '..', 'shadow', 'passwd', 'quota'])) {
continue;
}
$email = "$localPart@$domain";
if (!isset($forwarderSet[$email])) {
$forwarderSet[$email] = [
'email' => $email,
'local_part' => $localPart,
'domain' => $domain,
'file' => $file,
'format' => 'etc_va',
];
}
}
// Format 2: va/domain files (common in newer cPanel backups)
// These are files like "va/example.com" containing "user@example.com: dest@other.com"
elseif (preg_match('/^[^\/]+\/va\/([^\/]+)$/', $file, $matches)) {
$domain = $matches[1];
// Must be a valid domain (contains dot, not a system file)
if (strpos($domain, '.') !== false && !in_array($domain, ['.', '..'])) {
// Mark this as a va file to parse during restore
// We use a special key to track va files for later parsing
$vaFileKey = "va_file:$domain";
if (!isset($forwarderSet[$vaFileKey])) {
$forwarderSet[$vaFileKey] = [
'email' => '', // Will be populated during content parsing
'local_part' => '',
'domain' => $domain,
'file' => $file,
'format' => 'va_file', // Mark as needing content parsing
];
}
}
}
// Detect SSL certificates - cPanel stores them in homedir/ssl/certs/ and homedir/ssl/keys/
// Cert filename: domain_name_keyid_timestamp_hash.crt (e.g., laundry_sh_co_il_e3914_8256f_1758985760_abc123.crt)
// Key filename: keyid_hash.key (e.g., e3914_8256f_abc123.key)
// Match paths like: homedir/ssl/certs/, ssl/certs/, etc.
if (preg_match('/(?:homedir\/)?ssl\/certs\/([^\/]+)\.(crt|pem|cert)$/i', $file, $matches)) {
$certFilename = $matches[1];
// Skip cache files
if (str_ends_with($certFilename, '.cache') || str_ends_with($certFilename, '.crt.cache')) {
continue;
}
// Extract domain and keyid from cert filename
if (preg_match('/^(.+)_([a-f0-9]+_[a-f0-9]+)_\d+_[a-f0-9]+$/i', $certFilename, $certParts)) {
$domain = str_replace('_', '.', $certParts[1]);
$keyId = $certParts[2];
if (!isset($sslSet[$keyId])) {
$sslSet[$keyId] = ['domain' => $domain, 'keyid' => $keyId, 'has_key' => false, 'has_cert' => true];
} else {
$sslSet[$keyId]['has_cert'] = true;
$sslSet[$keyId]['domain'] = $domain;
}
}
} elseif (preg_match('/(?:homedir\/)?ssl\/keys\/([^\/]+)\.key$/i', $file, $matches)) {
$keyFilename = $matches[1];
// Extract keyid from key filename
if (preg_match('/^([a-f0-9]+_[a-f0-9]+)_[a-f0-9]+$/i', $keyFilename, $keyParts)) {
$keyId = $keyParts[1];
if (!isset($sslSet[$keyId])) {
$sslSet[$keyId] = ['domain' => '', 'keyid' => $keyId, 'has_key' => true, 'has_cert' => false];
} else {
$sslSet[$keyId]['has_key'] = true;
}
}
} elseif (preg_match('/(ssl|sslkeys|sslcerts)\/([^\/]+)\.(crt|key|pem)$/', $file, $matches)) {
// Fallback for simpler SSL file formats
$domain = str_replace('_', '.', $matches[2]);
if (!isset($sslSet[$domain])) {
$sslSet[$domain] = ['domain' => $domain, 'has_key' => false, 'has_cert' => false];
}
if ($matches[3] === 'key') {
$sslSet[$domain]['has_key'] = true;
} elseif (in_array($matches[3], ['crt', 'pem'])) {
$sslSet[$domain]['has_cert'] = true;
}
}
// Detect domains from homedir structure or userdata
// homedir/public_html = main domain
// homedir/domains/domain.com = addon domain
if (preg_match('/homedir\/public_html\//', $file)) {
$domainSet['main'] = true; // Main domain exists
}
if (preg_match('/homedir\/([^\/]+)\/domains\/([^\/]+)\//', $file, $matches)) {
$domainSet[$matches[2]] = ['name' => $matches[2], 'type' => 'addon'];
}
// Check userdata for domain info
if (preg_match('/(?:cp\/)?userdata\/([^\/]+)$/', $file, $matches)) {
$domainName = $matches[1];
// Skip config files - only real domains
if ($domainName === 'main' || $domainName === 'cache' ||
preg_match('/\.(yaml|yaml\.transferred|json)$/', $domainName) ||
preg_match('/_SSL$/', $domainName) ||
preg_match('/^_/', $domainName)) {
continue;
}
if (strpos($domainName, '.') !== false && !isset($domainSet[$domainName])) {
$domainSet[$domainName] = ['name' => $domainName, 'type' => 'addon'];
}
}
}
// Extract userdata if exists to get proper domain info
// Try multiple patterns - cPanel backups can have different structures
// Note: backup dirs often use format: backup-DATE_TIME_USERNAME so we use wildcards
$userdataPatterns = [
"*/userdata/*", // Most common: backup-xxx_username/userdata/*
"*/cp/userdata/*", // Alternative with cp subdir
$cpanelUser ? "*{$cpanelUser}*/userdata/*" : null, // Match backup dir containing username
$cpanelUser ? "*{$cpanelUser}*/cp/userdata/*" : null, // Match backup dir with cp subdir
"cp/userdata/*", // Direct cp/userdata
"userdata/*", // Direct userdata
];
$userdataPatterns = array_filter($userdataPatterns); // Remove nulls
foreach ($userdataPatterns as $userdataPattern) {
exec("tar -I pigz -xf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($tempDir) . " --wildcards " . escapeshellarg($userdataPattern) . " 2>/dev/null");
}
// Look for extracted userdata in multiple possible locations
// Note: glob() can return false on error, so we use ?: [] to ensure arrays
$userdataDirs = array_merge(
glob($tempDir . '/*/cp/userdata') ?: [],
glob($tempDir . '/*/userdata') ?: [],
glob($tempDir . '/*/*/userdata') ?: [], // For nested backup dirs
glob($tempDir . '/cp/userdata') ?: [],
glob($tempDir . '/userdata') ?: []
);
logger("Searching for userdata in tempDir: $tempDir");
logger("Userdata directories found: " . json_encode($userdataDirs));
// If no userdata dirs found, list what's actually in tempDir
if (empty($userdataDirs)) {
$tempContents = [];
exec("find " . escapeshellarg($tempDir) . " -type d -name 'userdata' 2>/dev/null", $tempContents);
logger("Find userdata results: " . json_encode($tempContents));
}
$mainDomainFound = false;
foreach ($userdataDirs as $userdataPath) {
if (!is_dir($userdataPath)) {
continue;
}
logger("Found userdata directory: $userdataPath");
// Read main domain from 'main' file
$mainFile = $userdataPath . '/main';
if (file_exists($mainFile)) {
$mainContent = file_get_contents($mainFile);
logger("Reading main userdata file, content length: " . strlen($mainContent));
// Parse YAML-like format - try multiple patterns
if (preg_match('/^main_domain:\s*(.+)$/m', $mainContent, $matches)) {
$mainDomain = trim($matches[1]);
logger("Found main_domain in userdata/main: $mainDomain");
$data['domains'][] = ['name' => $mainDomain, 'type' => 'main'];
$mainDomainFound = true;
unset($domainSet['main']);
} else {
logger("No main_domain found in userdata/main file");
// Try to find it in different format
if (preg_match('/documentroot:\s*\/home\/[^\/]+\/public_html$/m', $mainContent)) {
// This is the main domain file for a domain using public_html
logger("Found documentroot pointing to public_html in main file");
}
}
// Parse addon_domains section
if (preg_match('/addon_domains:\s*\n((?:\s+.+:\s*.+\n?)+)/', $mainContent, $matches)) {
// Parse addon domains from the block
$addonBlock = $matches[1];
if (preg_match_all('/^\s+([^:]+):\s*(.+)$/m', $addonBlock, $addonMatches, PREG_SET_ORDER)) {
foreach ($addonMatches as $addonMatch) {
$addonDomain = trim($addonMatch[1]);
if (!empty($addonDomain) && strpos($addonDomain, '.') !== false) {
$existingDomains = array_column($data['domains'], 'name');
if (!in_array($addonDomain, $existingDomains)) {
$data['domains'][] = ['name' => $addonDomain, 'type' => 'addon'];
logger("Found addon domain in main file: $addonDomain");
}
}
}
}
}
}
// Read individual domain files
foreach (scandir($userdataPath) as $file) {
if ($file === '.' || $file === '..' || $file === 'main' || $file === 'cache') {
continue;
}
// Skip cPanel config files (not actual domains)
if (preg_match('/\.(yaml|yaml\.transferred|json)$/', $file) || // Config files
preg_match('/_SSL$/', $file) || // SSL config files
preg_match('/^_/', $file)) { // Internal files
continue;
}
if (strpos($file, '.') !== false) {
$domainName = preg_replace('/\.cache$/', '', $file);
$existingDomains = array_column($data['domains'], 'name');
// Read the domain userdata file to check documentroot
$domainFile = $userdataPath . '/' . $file;
$isMainDomain = false;
if (file_exists($domainFile)) {
$domainContent = file_get_contents($domainFile);
// If documentroot is /home/user/public_html, this is the main domain
if (preg_match('/documentroot:\s*\/home\/[^\/]+\/public_html\s*$/m', $domainContent)) {
$isMainDomain = true;
logger("Domain $domainName has public_html as documentroot - marking as main");
}
}
// Check if this is an addon domain file (addon.maindomain format)
// e.g., shilatlaser.co.il.laundry-sh.co.il means shilatlaser.co.il is addon
$extractedAddon = null;
foreach ($existingDomains as $existing) {
if (preg_match('/^(.+)\.' . preg_quote($existing, '/') . '$/', $domainName, $addonMatch)) {
$extractedAddon = $addonMatch[1];
break;
}
}
if ($extractedAddon) {
// This is an addon domain - add the extracted name
if (!in_array($extractedAddon, $existingDomains)) {
$data['domains'][] = ['name' => $extractedAddon, 'type' => 'addon'];
logger("Found addon domain file: $extractedAddon (from $domainName)");
}
} elseif (!in_array($domainName, $existingDomains)) {
// Regular domain file - check if main or addon
if ($isMainDomain && !$mainDomainFound) {
$data['domains'][] = ['name' => $domainName, 'type' => 'main'];
$mainDomainFound = true;
logger("Found main domain from documentroot: $domainName");
} else {
$data['domains'][] = ['name' => $domainName, 'type' => 'addon'];
logger("Found domain file: $domainName (type: addon)");
}
}
}
}
// If we found a main domain, no need to check other userdata dirs
if ($mainDomainFound) {
break;
}
}
// Fallback: if no main domain found but public_html exists, try to identify it
if (!$mainDomainFound && isset($domainSet['main'])) {
logger("Main domain not found in userdata, attempting fallback detection");
// Method 1: Look for a domain with same name as cPanel username
$existingDomains = array_column($data['domains'], 'name');
foreach ($existingDomains as $idx => $domain) {
// Check if any existing domain contains the cPanel username
if (!empty($cpanelUser) && strpos($domain, $cpanelUser) !== false) {
// Update this domain to be the main domain
foreach ($data['domains'] as &$d) {
if ($d['name'] === $domain) {
$d['type'] = 'main';
$mainDomainFound = true;
logger("Fallback: Set $domain as main domain (matches username)");
break 2;
}
}
unset($d);
}
}
// Method 2: If still not found, the first non-subdomain might be the main
if (!$mainDomainFound) {
foreach ($data['domains'] as &$d) {
// Check if this looks like a main domain (not a subdomain)
$parts = explode('.', $d['name']);
if (count($parts) <= 3) { // e.g., example.co.il or example.com
$d['type'] = 'main';
$mainDomainFound = true;
logger("Fallback: Set {$d['name']} as main domain (first valid domain)");
break;
}
}
unset($d);
}
}
// Add remaining detected domains (with filtering and addon extraction)
foreach ($domainSet as $key => $domain) {
if ($key === 'main' || !is_array($domain)) {
continue;
}
$domainName = $domain['name'];
// Skip config files
if (preg_match('/\.(yaml|yaml\.transferred|json)$/', $domainName) ||
preg_match('/_SSL$/', $domainName)) {
continue;
}
$existingDomains = array_column($data['domains'], 'name');
// Check if this is an addon domain (addon.maindomain format)
$extractedAddon = null;
foreach ($existingDomains as $existing) {
if (preg_match('/^(.+)\.' . preg_quote($existing, '/') . '$/', $domainName, $addonMatch)) {
$extractedAddon = $addonMatch[1];
break;
}
}
if ($extractedAddon) {
// This is an addon domain
if (!in_array($extractedAddon, $existingDomains)) {
$data['domains'][] = ['name' => $extractedAddon, 'type' => 'addon'];
}
} elseif (!in_array($domainName, $existingDomains)) {
$data['domains'][] = $domain;
}
}
// Parse va_file format entries to extract actual forwarders
// These are files like "va/domain.com" containing "user@domain.com: dest@other.com"
$parsedForwarders = [];
foreach ($forwarderSet as $key => $entry) {
if (($entry['format'] ?? '') === 'va_file') {
// Extract and parse the va file
$vaFile = $entry['file'];
$output = [];
exec('tar -I pigz -xOf ' . escapeshellarg($backupPath) . ' ' . escapeshellarg($vaFile) . ' 2>/dev/null', $output);
foreach ($output as $line) {
$line = trim($line);
if (empty($line) || $line[0] === '#') continue;
// Parse format: "localpart@domain: destination1, destination2"
// or "localpart: destination" (domain implied from filename)
// or "*: catchall" (catchall forwarder)
if (preg_match('/^([^:]+):\s*(.+)$/', $line, $fwdMatch)) {
$source = trim($fwdMatch[1]);
$destinations = trim($fwdMatch[2]);
// Skip catchall entries (*)
if ($source === '*') continue;
// Determine email address
if (strpos($source, '@') !== false) {
$email = $source;
[$localPart, $domain] = explode('@', $source, 2);
} else {
$localPart = $source;
$domain = $entry['domain'];
$email = "$localPart@$domain";
}
// Add to parsed forwarders
if (!isset($parsedForwarders[$email])) {
$parsedForwarders[$email] = [
'email' => $email,
'local_part' => $localPart,
'domain' => $domain,
'destinations' => $destinations,
'file' => $vaFile,
];
}
}
}
} else {
// Keep non-va_file entries as-is
$parsedForwarders[$key] = $entry;
}
}
$forwarderSet = $parsedForwarders;
// Convert sets to arrays
$data['databases'] = array_values($dbSet);
$data['mailboxes'] = array_values($mailboxSet);
$data['forwarders'] = array_values($forwarderSet);
// Filter SSL certs that have both key and cert, and have a valid domain
$data['ssl_certificates'] = array_values(array_filter($sslSet, fn($s) =>
$s['has_key'] && $s['has_cert'] && !empty($s['domain'])
));
// Clean up temp directory
exec("rm -rf " . escapeshellarg($tempDir));
logger("cPanel backup analyzed: " . count($data['domains']) . " domains, " .
count($data['databases']) . " databases, " . count($data['mailboxes']) . " mailboxes, " .
count($data['forwarders']) . " forwarders");
return [
'success' => true,
'data' => $data,
];
} catch (Exception $e) {
exec("rm -rf " . escapeshellarg($tempDir));
logger("cPanel backup analysis failed: " . $e->getMessage(), 'ERROR');
return ['success' => false, 'error' => $e->getMessage()];
}
}
function cpanelAppendMigrationLog(?string $logPath, string $message, string $status): void
{
if (empty($logPath)) {
return;
}
$entry = [
'message' => $message,
'status' => $status,
'time' => date('H:i:s'),
];
file_put_contents($logPath, json_encode($entry) . PHP_EOL, FILE_APPEND | LOCK_EX);
@chmod($logPath, 0644);
}
function cpanelAddMigrationEntry(array &$log, ?string $logPath, string $message, string $status): void
{
$log[] = [
'message' => $message,
'status' => $status,
'time' => date('H:i:s'),
];
cpanelAppendMigrationLog($logPath, $message, $status);
}
/**
* Restore a cPanel backup to a Jabali user account.
* Handles files, databases, emails, and SSL certificates.
*/
function cpanelRestoreBackup(array $params): array
{
$backupPath = $params['backup_path'] ?? '';
$username = $params['username'] ?? '';
$restoreFiles = $params['restore_files'] ?? true;
$restoreDatabases = $params['restore_databases'] ?? true;
$restoreEmails = $params['restore_emails'] ?? true;
$restoreSsl = $params['restore_ssl'] ?? true;
$logPath = $params['log_path'] ?? null;
// Pre-discovered data from API - if provided, skip expensive backup analysis
$discoveredData = $params['discovered_data'] ?? null;
if (empty($backupPath) || !file_exists($backupPath)) {
return ['success' => false, 'error' => 'Backup file not found'];
}
if (empty($username) || !validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
// Verify user exists
$userInfo = posix_getpwnam($username);
if (!$userInfo) {
return ['success' => false, 'error' => 'User does not exist'];
}
logger("Starting cPanel backup restore for user: $username from $backupPath");
$log = [];
$homeDir = "/home/$username";
$domainsDir = "$homeDir/domains";
cpanelAddMigrationEntry($log, $logPath, "Starting cPanel backup restoration for user: $username", 'pending');
// Create temp directory for extraction
$tempDir = sys_get_temp_dir() . '/cpanel_restore_' . uniqid();
if (!mkdir($tempDir, 0755, true)) {
return ['success' => false, 'error' => 'Failed to create temp directory'];
}
try {
// Full extraction
cpanelAddMigrationEntry($log, $logPath, 'Extracting backup archive...', 'pending');
exec("tar -I pigz -xf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($tempDir) . " 2>&1", $output, $code);
if ($code !== 0) {
throw new Exception('Failed to extract backup: ' . implode("\n", $output));
}
cpanelAddMigrationEntry($log, $logPath, 'Backup archive extracted', 'success');
// Find extracted directory (could be username or backup-date_username)
$extractedDirs = glob($tempDir . '/*', GLOB_ONLYDIR);
if (empty($extractedDirs)) {
throw new Exception('No directory found in backup archive');
}
$backupRoot = $extractedDirs[0];
// Use pre-discovered data if available (from API), otherwise analyze already-extracted content
if ($discoveredData !== null) {
logger("Using pre-discovered data from API (skipping backup analysis)");
$domains = $discoveredData['domains'] ?? [];
$databases = $discoveredData['databases'] ?? [];
$mailboxes = $discoveredData['mailboxes'] ?? [];
$forwarders = $discoveredData['forwarders'] ?? [];
} else {
// Analyze the already-extracted content directly (don't re-extract via cpanelAnalyzeBackup)
logger("No pre-discovered data, analyzing extracted content in $backupRoot...");
$domains = [];
$databases = [];
$mailboxes = [];
$forwarders = [];
// Find userdata directory in extracted backup
$userdataDirs = array_merge(
glob($backupRoot . '/cp/userdata') ?: [],
glob($backupRoot . '/userdata') ?: []
);
$mainDomainFound = false;
foreach ($userdataDirs as $userdataPath) {
if (!is_dir($userdataPath)) continue;
// Read main domain from 'main' file
$mainFile = $userdataPath . '/main';
if (file_exists($mainFile)) {
$mainContent = file_get_contents($mainFile);
if (preg_match('/^main_domain:\s*(.+)$/m', $mainContent, $matches)) {
$mainDomain = trim($matches[1]);
$domains[] = ['name' => $mainDomain, 'type' => 'main'];
$mainDomainFound = true;
logger("Found main domain in extracted userdata: $mainDomain");
}
}
// Read domain files
foreach (scandir($userdataPath) as $file) {
if ($file === '.' || $file === '..' || $file === 'main' || $file === 'cache') continue;
if (preg_match('/\.(yaml|json)$/', $file) || preg_match('/_SSL$/', $file)) continue;
if (strpos($file, '.') !== false) {
$domainName = preg_replace('/\.cache$/', '', $file);
$existingNames = array_column($domains, 'name');
// Check for addon.maindomain format
$extractedAddon = null;
foreach ($existingNames as $existing) {
if (preg_match('/^(.+)\.' . preg_quote($existing, '/') . '$/', $domainName, $m)) {
$extractedAddon = $m[1];
break;
}
}
if ($extractedAddon && !in_array($extractedAddon, $existingNames)) {
$domains[] = ['name' => $extractedAddon, 'type' => 'addon'];
logger("Found addon domain: $extractedAddon");
} elseif (!in_array($domainName, $existingNames)) {
$domains[] = ['name' => $domainName, 'type' => 'addon'];
logger("Found domain: $domainName");
}
}
}
if ($mainDomainFound) break;
}
// Find databases from mysql directory
$mysqlDirs = array_merge(
glob($backupRoot . '/mysql') ?: [],
glob($backupRoot . '/homedir/mysql') ?: []
);
foreach ($mysqlDirs as $mysqlDir) {
if (!is_dir($mysqlDir)) continue;
foreach (glob($mysqlDir . '/*.sql*') as $sqlFile) {
$dbName = basename($sqlFile);
$dbName = preg_replace('/\.(sql|sql\.gz)$/', '', $dbName);
$databases[] = ['name' => $dbName, 'file' => $sqlFile];
logger("Found database: $dbName");
}
}
// Find mailboxes from mail directory
$mailDirs = array_merge(
glob($backupRoot . '/homedir/mail/*', GLOB_ONLYDIR) ?: [],
glob($backupRoot . '/mail/*', GLOB_ONLYDIR) ?: []
);
foreach ($mailDirs as $domainDir) {
$domain = basename($domainDir);
if (strpos($domain, '.') === false) continue;
foreach (glob($domainDir . '/*', GLOB_ONLYDIR) as $userDir) {
$localPart = basename($userDir);
if (in_array($localPart, ['cur', 'new', 'tmp', '.spam', '.Trash']) || strpos($localPart, '.') === 0) continue;
$mailboxes[] = ['email' => "$localPart@$domain", 'local_part' => $localPart, 'domain' => $domain];
logger("Found mailbox: $localPart@$domain");
}
}
// Find email forwarders from etc/domain/va/ directory
$vaDirs = array_merge(
glob($backupRoot . '/homedir/etc/*/va', GLOB_ONLYDIR) ?: [],
glob($backupRoot . '/etc/*/va', GLOB_ONLYDIR) ?: []
);
foreach ($vaDirs as $vaDir) {
$domain = basename(dirname($vaDir));
if (strpos($domain, '.') === false) continue;
foreach (glob($vaDir . '/*') as $vaFile) {
if (is_dir($vaFile)) continue;
$localPart = basename($vaFile);
if (in_array($localPart, ['.', '..', 'shadow', 'passwd', 'quota'])) continue;
$forwarders[] = [
'email' => "$localPart@$domain",
'local_part' => $localPart,
'domain' => $domain,
'file' => $vaFile,
];
logger("Found forwarder: $localPart@$domain");
}
}
logger("Analysis from extracted content: " . count($domains) . " domains, " . count($databases) . " databases, " . count($mailboxes) . " mailboxes, " . count($forwarders) . " forwarders");
}
// Restore website files
if ($restoreFiles) {
cpanelAddMigrationEntry($log, $logPath, 'Restoring website files...', 'pending');
// Ensure domains directory exists
if (!is_dir($domainsDir)) {
mkdir($domainsDir, 0755, true);
chown($domainsDir, $username);
chgrp($domainsDir, $username);
}
foreach ($domains as $domain) {
$domainName = $domain['name'];
$domainDir = "$domainsDir/$domainName";
$publicHtml = "$domainDir/public_html";
// Create domain directory structure
if (!is_dir($publicHtml)) {
mkdir($publicHtml, 0755, true);
}
// Find source files - check multiple possible locations
$sourceDir = null;
$possiblePaths = [];
// Build comprehensive list of possible paths - start with type-specific paths
if ($domain['type'] === 'main') {
// Main domain files can be in several locations
$possiblePaths = [
"$backupRoot/homedir/public_html",
"$backupRoot/public_html",
"$backupRoot/homedir",
];
} else {
// Addon/sub domains might be in various locations
$possiblePaths = [
"$backupRoot/homedir/domains/$domainName/public_html",
"$backupRoot/homedir/$domainName/public_html",
"$backupRoot/homedir/public_html/$domainName",
"$backupRoot/domains/$domainName/public_html",
"$backupRoot/$domainName/public_html",
];
}
// Add fallback paths that check both main and addon locations regardless of type
// This handles cases where domain type detection might be incorrect
$fallbackPaths = [
"$backupRoot/homedir/public_html", // Main domain location
"$backupRoot/public_html",
"$backupRoot/homedir/domains/$domainName/public_html", // Addon domain locations
"$backupRoot/homedir/$domainName/public_html",
"$backupRoot/domains/$domainName/public_html",
];
// Merge paths, keeping type-specific paths first
$possiblePaths = array_unique(array_merge($possiblePaths, $fallbackPaths));
// Log available directories for debugging
$availableDirs = [];
if (is_dir("$backupRoot/homedir")) {
$availableDirs[] = "homedir: " . implode(', ', array_diff(scandir("$backupRoot/homedir"), ['.', '..']));
}
if (is_dir("$backupRoot/homedir/domains")) {
$availableDirs[] = "homedir/domains: " . implode(', ', array_diff(scandir("$backupRoot/homedir/domains"), ['.', '..']));
}
if (!empty($availableDirs)) {
logger("Backup structure for $domainName: " . implode('; ', $availableDirs));
}
// Log what paths we're checking
logger("Checking paths for $domainName ({$domain['type']}): " . implode(', ', array_map(fn($p) => str_replace($backupRoot, '', $p), $possiblePaths)));
foreach ($possiblePaths as $path) {
if (is_dir($path)) {
// Check if directory has any content
$contents = array_diff(scandir($path), ['.', '..']);
if (!empty($contents)) {
$sourceDir = $path;
logger("Found source for $domainName: $path (" . count($contents) . " items)");
break;
} else {
logger("Path $path exists but is empty");
}
}
}
if (!$sourceDir) {
logger("No source found for domain $domainName ({$domain['type']})");
// List all directories found in backup for debugging
exec("find " . escapeshellarg($backupRoot) . " -type d -maxdepth 4 2>/dev/null | head -50", $allDirs);
if (!empty($allDirs)) {
logger("Available directories in backup: " . implode(', ', array_map(fn($d) => str_replace($backupRoot, '', $d), $allDirs)));
}
}
if ($sourceDir && is_dir($sourceDir)) {
// For main domain, we need to exclude addon domain subdirectories
$excludeOptions = '';
if ($domain['type'] === 'main') {
// Find addon domain document roots from cPanel userdata
$excludeDirs = [];
$mainPublicHtml = "$backupRoot/homedir/public_html";
// Check for addon domain directories under main domain's public_html
$addonDomains = array_filter($domains, fn($d) => $d['type'] !== 'main');
$addonNames = array_map(fn($d) => $d['name'], $addonDomains);
logger("Looking for addon domains to exclude: " . implode(', ', $addonNames));
logger("Scanning source directory: $sourceDir");
// Scan the source public_html for subdirectories
if (is_dir($sourceDir)) {
$subDirs = scandir($sourceDir);
foreach ($subDirs as $subDir) {
if ($subDir === '.' || $subDir === '..') continue;
$fullPath = "$sourceDir/$subDir";
if (!is_dir($fullPath)) continue;
// Check if this subdirectory matches any addon domain name
foreach ($addonNames as $addonName) {
if ($subDir === $addonName) {
$excludeDirs[] = $subDir;
logger("Excluding addon domain directory: $subDir");
break;
}
}
}
}
// Also check userdata for addon domains with custom paths
foreach ($addonDomains as $addon) {
$addonName = $addon['name'];
// Skip if already found
if (in_array($addonName, $excludeDirs)) continue;
// Try userdata patterns
$mainDomainName = $domainName;
$userdataPatterns = [
"$backupRoot/userdata/$addonName.$mainDomainName",
"$backupRoot/userdata/$addonName",
];
foreach ($userdataPatterns as $pattern) {
if (file_exists($pattern) && is_file($pattern)) {
$userdataContent = file_get_contents($pattern);
if (preg_match('/^documentroot:\s*(.+)$/m', $userdataContent, $docRootMatch)) {
$docRoot = trim($docRootMatch[1]);
if (preg_match('/\/public_html\/(.+)$/', $docRoot, $subDirMatch)) {
$subPath = $subDirMatch[1];
if (!in_array($subPath, $excludeDirs)) {
$excludeDirs[] = $subPath;
logger("Found addon from userdata: $addonName at $subPath");
}
}
}
break;
}
}
}
// Remove duplicates
$excludeDirs = array_unique($excludeDirs);
if (!empty($excludeDirs)) {
// Use rsync for selective copy with exclusions
$excludeArgs = array_map(fn($d) => "--exclude=" . escapeshellarg($d), $excludeDirs);
$rsyncCmd = "rsync -a " . implode(' ', $excludeArgs) . " " . escapeshellarg($sourceDir . '/') . " " . escapeshellarg($publicHtml . '/') . " 2>&1";
exec($rsyncCmd, $cpOutput, $cpCode);
logger("Main domain rsync with exclusions: " . implode(', ', $excludeDirs));
} else {
// No exclusions needed, simple copy
exec("cp -rp " . escapeshellarg($sourceDir) . "/* " . escapeshellarg($publicHtml) . "/ 2>&1", $cpOutput, $cpCode);
}
} else {
// Addon/sub domains - simple copy
exec("cp -rp " . escapeshellarg($sourceDir) . "/* " . escapeshellarg($publicHtml) . "/ 2>&1", $cpOutput, $cpCode);
}
// Fix ownership
exec("chown -R " . escapeshellarg($username) . ":" . escapeshellarg($username) . " " . escapeshellarg($domainDir));
cpanelAddMigrationEntry($log, $logPath, "Restored files for domain: $domainName", 'success');
// Register domain in Jabali if not exists
cpanelRegisterDomain($username, $domainName);
} else {
cpanelAddMigrationEntry($log, $logPath, "No files found for domain: $domainName", 'warning');
// Still register the domain
cpanelRegisterDomain($username, $domainName);
}
}
cpanelAddMigrationEntry($log, $logPath, 'Website files restoration completed', 'success');
}
// Restore databases
if ($restoreDatabases && !empty($databases)) {
cpanelAddMigrationEntry($log, $logPath, 'Restoring databases...', 'pending');
// Track old-to-new database name mapping for user grants
$dbNameMapping = [];
// Track used new database names to avoid duplicates
$usedDbNames = [];
foreach ($databases as $db) {
$dbName = $db['name'];
// Create new database name with user prefix
$baseName = preg_replace('/^[^_]+_/', '', $dbName);
$newDbName = $username . '_' . $baseName;
$newDbName = substr($newDbName, 0, 64); // MySQL limit
// Check for duplicate and make unique if needed
if (in_array($newDbName, $usedDbNames)) {
// Try appending the original prefix initial letters to make unique
$origPrefix = '';
if (preg_match('/^([^_]+)_/', $dbName, $prefixMatch)) {
$origPrefix = substr($prefixMatch[1], 0, 4); // First 4 chars of original prefix
}
$uniqueName = $username . '_' . $origPrefix . '_' . $baseName;
$uniqueName = substr($uniqueName, 0, 64);
// If still duplicate, append a number
$counter = 2;
while (in_array($uniqueName, $usedDbNames)) {
$uniqueName = $username . '_' . $baseName . '_' . $counter;
$uniqueName = substr($uniqueName, 0, 64);
$counter++;
}
$newDbName = $uniqueName;
}
$usedDbNames[] = $newDbName;
$dbNameMapping[$dbName] = $newDbName;
// Find SQL file
$sqlFile = null;
$possiblePaths = [
"$backupRoot/mysql/$dbName.sql",
"$backupRoot/mysql/$dbName.sql.gz",
];
foreach ($possiblePaths as $path) {
if (file_exists($path)) {
$sqlFile = $path;
break;
}
}
if ($sqlFile) {
try {
// Create database
cpanelCreateDatabase($username, $newDbName);
// Import SQL
$importCmd = str_ends_with($sqlFile, '.gz')
? "pigz -dc " . escapeshellarg($sqlFile) . " | mysql " . escapeshellarg($newDbName)
: "mysql " . escapeshellarg($newDbName) . " < " . escapeshellarg($sqlFile);
exec($importCmd . " 2>&1", $importOutput, $importCode);
if ($importCode === 0) {
cpanelAddMigrationEntry($log, $logPath, "Restored database: $newDbName (from $dbName)", 'success');
} else {
cpanelAddMigrationEntry($log, $logPath, "Failed to import database $newDbName: " . implode("\n", $importOutput), 'error');
}
} catch (Exception $e) {
cpanelAddMigrationEntry($log, $logPath, "Database restore error for $dbName: " . $e->getMessage(), 'error');
}
} else {
cpanelAddMigrationEntry($log, $logPath, "SQL file not found for database: $dbName", 'warning');
}
}
// Restore MySQL users from cPanel backup
cpanelAddMigrationEntry($log, $logPath, 'Restoring database users...', 'pending');
$usersRestored = 0;
// Track old-to-new user name mapping for wp-config updates
$userNameMapping = [];
// Track used new user names to avoid duplicates
$usedUserNames = [];
// Look for mysql.sql file in cPanel backup
$mysqlFile = "$backupRoot/mysql.sql";
if (file_exists($mysqlFile)) {
$content = file_get_contents($mysqlFile);
if (!empty($content)) {
logger("Parsing mysql.sql for user grants...");
// cPanel format: GRANT USAGE ON *.* TO 'username'@'host' IDENTIFIED BY PASSWORD '*hash';
// We only want localhost users to avoid cPanel-specific hosts
preg_match_all("/GRANT USAGE ON \*\.\* TO '([^']+)'@'localhost' IDENTIFIED BY PASSWORD '([^']+)'/", $content, $usageMatches, PREG_SET_ORDER);
// Also get GRANT ALL PRIVILEGES ON `database`.* TO 'username'@'localhost'
// Note: cPanel escapes underscores in database names with backslash
preg_match_all("/GRANT ALL PRIVILEGES ON `([^`]+)`\.\* TO '([^']+)'@'localhost'/", $content, $privMatches, PREG_SET_ORDER);
$usersToCreate = [];
// Process GRANT USAGE statements to get users
foreach ($usageMatches as $match) {
$oldMysqlUser = $match[1];
$passwordHash = $match[2];
// Skip if user name matches cPanel account name (it's the main account, not a DB user)
if ($oldCpanelUser && $oldMysqlUser === $oldCpanelUser) {
continue;
}
// Skip if we already processed this user
if (isset($usersToCreate[$oldMysqlUser])) {
continue;
}
// Rename user with new prefix
$baseName = preg_replace('/^[^_]+_/', '', $oldMysqlUser);
$newMysqlUser = $username . '_' . $baseName;
$newMysqlUser = substr($newMysqlUser, 0, 32); // MySQL username limit
// Check for duplicate and make unique if needed (same logic as database names)
if (in_array($newMysqlUser, $usedUserNames)) {
// Try appending the original prefix initial letters to make unique
$origPrefix = '';
if (preg_match('/^([^_]+)_/', $oldMysqlUser, $prefixMatch)) {
$origPrefix = substr($prefixMatch[1], 0, 4); // First 4 chars of original prefix
}
$uniqueName = $username . '_' . $origPrefix . '_' . $baseName;
$uniqueName = substr($uniqueName, 0, 32);
// If still duplicate, append a number
$counter = 2;
while (in_array($uniqueName, $usedUserNames)) {
$uniqueName = $username . '_' . $baseName . '_' . $counter;
$uniqueName = substr($uniqueName, 0, 32);
$counter++;
}
$newMysqlUser = $uniqueName;
}
$usedUserNames[] = $newMysqlUser;
$userNameMapping[$oldMysqlUser] = $newMysqlUser;
$usersToCreate[$oldMysqlUser] = [
'new_name' => $newMysqlUser,
'password_hash' => $passwordHash,
'grants' => [],
];
}
// Process GRANT ALL PRIVILEGES statements to get database permissions
foreach ($privMatches as $match) {
// Remove backslash escaping from database name
$oldDbName = str_replace('\\', '', $match[1]);
$oldMysqlUser = $match[2];
// Skip if not a user we're tracking
if (!isset($usersToCreate[$oldMysqlUser])) {
continue;
}
// Map old database name to new database name
$newDbName = $dbNameMapping[$oldDbName] ?? null;
if ($newDbName) {
// Avoid duplicate grants
$alreadyHasGrant = false;
foreach ($usersToCreate[$oldMysqlUser]['grants'] as $g) {
if ($g['database'] === $newDbName) {
$alreadyHasGrant = true;
break;
}
}
if (!$alreadyHasGrant) {
$usersToCreate[$oldMysqlUser]['grants'][] = [
'privileges' => 'ALL PRIVILEGES',
'database' => $newDbName,
];
}
}
}
logger("Found " . count($usersToCreate) . " MySQL users to restore");
// Create users and apply grants
foreach ($usersToCreate as $oldUser => $userInfo) {
$newMysqlUser = $userInfo['new_name'];
$passwordHash = $userInfo['password_hash'] ?? '';
$newPassword = null; // Will be set only if we generate a new one
try {
// Use the original password hash from cPanel backup
// MariaDB/MySQL can import hashes with mysql_native_password
if (!empty($passwordHash) && str_starts_with($passwordHash, '*')) {
// Use the original hash - works with mysql_native_password
// Password stays same as in cPanel, no need to store it
$createSql = "CREATE USER IF NOT EXISTS '$newMysqlUser'@'localhost' IDENTIFIED WITH mysql_native_password AS '$passwordHash'";
} else {
// Fallback: generate new password if no valid hash
$newPassword = bin2hex(random_bytes(12));
$createSql = "CREATE USER IF NOT EXISTS '$newMysqlUser'@'localhost' IDENTIFIED BY '$newPassword'";
logger("No valid password hash for $oldUser, generated new password");
}
exec("mysql -e " . escapeshellarg($createSql) . " 2>&1", $createOutput, $createCode);
if ($createCode !== 0) {
cpanelAddMigrationEntry($log, $logPath, "Failed to create MySQL user $newMysqlUser: " . implode(' ', $createOutput), 'warning');
continue;
}
// Apply grants for each database
$grantedDbs = [];
foreach ($userInfo['grants'] as $grant) {
$grantSql = "GRANT ALL PRIVILEGES ON `{$grant['database']}`.* TO '$newMysqlUser'@'localhost'";
exec("mysql -e " . escapeshellarg($grantSql) . " 2>&1", $grantOutput, $grantCode);
if ($grantCode === 0) {
$grantedDbs[] = $grant['database'];
}
}
// Flush privileges
exec("mysql -e 'FLUSH PRIVILEGES' 2>&1");
// Store credentials in Jabali (only if we generated a new password)
if ($newPassword !== null) {
cpanelStoreMysqlCredential($username, $newMysqlUser, $newPassword);
}
$dbList = !empty($grantedDbs) ? ' (' . implode(', ', $grantedDbs) . ')' : '';
cpanelAddMigrationEntry($log, $logPath, "Created MySQL user: $newMysqlUser$dbList", 'success');
$usersRestored++;
} catch (Exception $e) {
cpanelAddMigrationEntry($log, $logPath, "Error creating MySQL user $newMysqlUser: " . $e->getMessage(), 'warning');
}
}
}
}
if ($usersRestored === 0) {
cpanelAddMigrationEntry($log, $logPath, 'No MySQL users found in backup', 'info');
}
// Update wp-config.php files with new database names and users
// Only for domains that were imported in this restore
if (!empty($dbNameMapping) && !empty($domains)) {
cpanelAddMigrationEntry($log, $logPath, 'Updating WordPress configuration files...', 'pending');
$wpConfigsUpdated = 0;
// Only check wp-config.php in the domains being restored
$wpConfigFiles = [];
foreach ($domains as $domain) {
$domainName = $domain['name'] ?? '';
if (empty($domainName)) continue;
$domainDir = "/home/$username/domains/$domainName/public_html";
// Check common WordPress locations
$possiblePaths = [
"$domainDir/wp-config.php",
"$domainDir/wordpress/wp-config.php",
"$domainDir/wp/wp-config.php",
"$domainDir/blog/wp-config.php",
];
foreach ($possiblePaths as $path) {
if (file_exists($path)) {
$wpConfigFiles[] = $path;
}
}
}
foreach ($wpConfigFiles as $wpConfigPath) {
if (!file_exists($wpConfigPath) || !is_readable($wpConfigPath)) {
continue;
}
$wpConfig = file_get_contents($wpConfigPath);
$originalConfig = $wpConfig;
$changes = [];
// Update DB_NAME - extract current value first and do ONE replacement only
// This prevents chain replacements (e.g., A→B then B→C in same file)
if (preg_match("/define\s*\(\s*['\"]DB_NAME['\"]\s*,\s*['\"]([^'\"]+)['\"]\s*\)/", $wpConfig, $currentDbMatch)) {
$currentDbName = $currentDbMatch[1];
// Look up this specific database name in our mapping
if (isset($dbNameMapping[$currentDbName])) {
$newDbName = $dbNameMapping[$currentDbName];
$wpConfig = preg_replace(
"/define\s*\(\s*['\"]DB_NAME['\"]\s*,\s*['\"]" . preg_quote($currentDbName, '/') . "['\"]\s*\)/",
"define('DB_NAME', '$newDbName')",
$wpConfig
);
$changes[] = "DB_NAME: $currentDbName$newDbName";
}
}
// Update DB_USER - use mapping if available, otherwise do prefix replacement
if (preg_match("/define\s*\(\s*['\"]DB_USER['\"]\s*,\s*['\"]([^'\"]+)['\"]\s*\)/", $wpConfig, $dbUserMatch)) {
$oldDbUser = $dbUserMatch[1];
$newDbUser = null;
// First, check if we have a mapping for this user (from mysql.sql)
if (isset($userNameMapping[$oldDbUser])) {
$newDbUser = $userNameMapping[$oldDbUser];
} else {
// Fallback: Replace the prefix (everything before first underscore) with new username
$newDbUser = $username . '_' . preg_replace('/^[^_]+_/', '', $oldDbUser);
$newDbUser = substr($newDbUser, 0, 32); // MySQL user limit
}
if ($newDbUser && $oldDbUser !== $newDbUser) {
$wpConfig = preg_replace(
"/define\s*\(\s*['\"]DB_USER['\"]\s*,\s*['\"]" . preg_quote($oldDbUser, '/') . "['\"]\s*\)/",
"define('DB_USER', '$newDbUser')",
$wpConfig
);
$changes[] = "DB_USER: $oldDbUser$newDbUser";
}
}
// Save if changed
if ($wpConfig !== $originalConfig) {
file_put_contents($wpConfigPath, $wpConfig);
// Preserve ownership
$userInfo = posix_getpwnam($username);
if ($userInfo) {
chown($wpConfigPath, $userInfo['uid']);
chgrp($wpConfigPath, $userInfo['gid']);
}
$relativePath = str_replace("/home/$username/domains/", '', $wpConfigPath);
cpanelAddMigrationEntry($log, $logPath, "Updated wp-config.php: $relativePath (" . implode(', ', $changes) . ")", 'success');
$wpConfigsUpdated++;
}
}
if ($wpConfigsUpdated > 0) {
cpanelAddMigrationEntry($log, $logPath, "Updated $wpConfigsUpdated WordPress configuration file(s)", 'success');
}
}
}
// Restore emails
if ($restoreEmails && !empty($mailboxes)) {
cpanelAddMigrationEntry($log, $logPath, 'Restoring email mailboxes...', 'pending');
$pdo = getJabaliPdo();
$userId = null;
if ($pdo) {
$userId = cpanelGetUserId($pdo, $username);
if (!$userId) {
logger("User not found in Jabali DB during email restore: $username", 'WARNING');
}
}
// Group mailboxes by domain
$mailboxesByDomain = [];
foreach ($mailboxes as $mailbox) {
$domain = $mailbox['domain'];
if (!isset($mailboxesByDomain[$domain])) {
$mailboxesByDomain[$domain] = [];
}
$mailboxesByDomain[$domain][] = $mailbox;
}
foreach ($mailboxesByDomain as $domain => $domainMailboxes) {
$rawDomain = $domain;
$domain = strtolower(preg_replace('/[\x00-\x1F\x7F]/', '', trim($domain, ". \t\n\r\0\x0B")));
// Skip invalid domains
if (empty($domain) || strpos($domain, '.') === false || strpos($domain, '.') === 0) {
cpanelAddMigrationEntry($log, $logPath, "Skipping invalid email domain: $domain", 'warning');
continue;
}
// Enable email for domain if not already
cpanelEnableEmailDomain($username, $domain);
$emailDomainId = null;
if ($pdo && $userId) {
$domainId = cpanelGetDomainId($pdo, $domain);
if (!$domainId) {
cpanelRegisterDomain($username, $domain);
$domainId = cpanelGetDomainId($pdo, $domain);
}
if ($domainId) {
$emailDomainId = cpanelEnsureEmailDomainRecord($pdo, $domainId);
}
}
foreach ($domainMailboxes as $mailbox) {
$localPart = preg_replace('/[\x00-\x1F\x7F]/', '', trim($mailbox['local_part'] ?? ''));
$email = $localPart . '@' . $domain;
// Skip invalid local parts (system folders, hidden folders)
$skipFolders = ['cur', 'new', 'tmp', '.spam', '.Trash', '.Drafts', '.Sent', '.Junk', 'courierimapkeywords', 'maildirfolder'];
if (empty($localPart) || in_array($localPart, $skipFolders) || strpos($localPart, '.') === 0) {
continue;
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
cpanelAddMigrationEntry($log, $logPath, "Skipping mailbox with invalid email: $email", 'warning');
continue;
}
// Create mailbox with random password
$password = bin2hex(random_bytes(12));
$result = cpanelCreateMailbox($username, $domain, $localPart, $password);
if ($result['success'] ?? false) {
$backupHash = cpanelFindMailboxPasswordHash($backupRoot, $rawDomain, $localPart);
$passwordHash = $backupHash ?: ($result['password_hash'] ?? '');
if ($pdo && $userId && $emailDomainId && $passwordHash !== '') {
cpanelEnsureMailboxRecord(
$pdo,
$emailDomainId,
$userId,
$localPart,
$passwordHash,
$result['maildir_path'] ?? null,
$result['uid'] ?? null,
$result['gid'] ?? null,
$localPart
);
}
// Copy mail data
$mailSourceDir = "$backupRoot/mail/$rawDomain/$localPart";
if (is_dir($mailSourceDir)) {
$mailDestDir = "/var/vmail/$domain/$localPart";
if (!is_dir($mailDestDir)) {
mkdir($mailDestDir, 0700, true);
}
exec("cp -rp " . escapeshellarg($mailSourceDir) . "/* " . escapeshellarg($mailDestDir) . "/ 2>&1");
exec("chown -R " . VMAIL_UID . ":" . VMAIL_GID . " " . escapeshellarg($mailDestDir));
cpanelAddMigrationEntry($log, $logPath, "Restored mailbox: $email (with messages)", 'success');
} else {
cpanelAddMigrationEntry($log, $logPath, "Created mailbox: $email (no messages found)", 'success');
}
if (!$backupHash) {
cpanelAddMigrationEntry($log, $logPath, "Password not found in backup for mailbox: $email (reset required)", 'warning');
}
} else {
cpanelAddMigrationEntry($log, $logPath, "Failed to create mailbox: $email - " . ($result['error'] ?? 'Unknown error'), 'error');
}
}
}
// Restore email forwarders
if (!empty($forwarders)) {
cpanelAddMigrationEntry($log, $logPath, 'Restoring email forwarders...', 'pending');
// Group forwarders by domain
$forwardersByDomain = [];
foreach ($forwarders as $forwarder) {
$domain = $forwarder['domain'];
if (!isset($forwardersByDomain[$domain])) {
$forwardersByDomain[$domain] = [];
}
$forwardersByDomain[$domain][] = $forwarder;
}
$forwardersRestored = 0;
foreach ($forwardersByDomain as $domain => $domainForwarders) {
$rawDomain = $domain;
$domain = strtolower(preg_replace('/[\x00-\x1F\x7F]/', '', trim($domain, ". \t\n\r\0\x0B")));
// Enable email for domain if not already
cpanelEnableEmailDomain($username, $domain);
foreach ($domainForwarders as $forwarder) {
$localPart = preg_replace('/[\x00-\x1F\x7F]/', '', trim($forwarder['local_part'] ?? ''));
$email = $localPart . '@' . $domain;
$file = $forwarder['file'] ?? null;
// Get destinations - either from API data or from backup file
$destinations = '';
// First, check if destinations were provided directly (from API)
if (!empty($forwarder['destinations'])) {
$destinations = is_array($forwarder['destinations'])
? implode(', ', $forwarder['destinations'])
: trim($forwarder['destinations']);
}
// If not, try to read from file
if (empty($destinations)) {
if ($file && file_exists($file)) {
$destinations = trim(file_get_contents($file));
} else {
// Try to find the file in extracted backup
$possiblePaths = [
"$backupRoot/homedir/etc/$rawDomain/va/$localPart",
"$backupRoot/etc/$rawDomain/va/$localPart",
];
foreach ($possiblePaths as $path) {
if (file_exists($path)) {
$destinations = trim(file_get_contents($path));
break;
}
}
}
}
if (empty($destinations)) {
cpanelAddMigrationEntry($log, $logPath, "Skipping forwarder $email - no destinations found", 'warning');
continue;
}
// Parse destinations - can be comma or newline separated
$destArray = preg_split('/[\s,]+/', $destinations, -1, PREG_SPLIT_NO_EMPTY);
$destArray = array_filter($destArray, fn($d) => filter_var($d, FILTER_VALIDATE_EMAIL));
if (empty($destArray)) {
cpanelAddMigrationEntry($log, $logPath, "Skipping forwarder $email - no valid destinations", 'warning');
continue;
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
cpanelAddMigrationEntry($log, $logPath, "Skipping forwarder with invalid email: $email", 'warning');
continue;
}
// Create the forwarder
$result = emailForwarderCreate([
'username' => $username,
'email' => $email,
'destinations' => $destArray,
]);
if ($result['success'] ?? false) {
cpanelAddMigrationEntry($log, $logPath, "Restored forwarder: $email -> $destinations", 'success');
$forwardersRestored++;
} else {
$error = $result['error'] ?? 'Unknown error';
if ($error === 'Forwarder already exists') {
$update = emailForwarderUpdate([
'username' => $username,
'email' => $email,
'destinations' => $destArray,
]);
if ($update['success'] ?? false) {
cpanelAddMigrationEntry($log, $logPath, "Updated forwarder: $email -> $destinations", 'success');
$forwardersRestored++;
continue;
}
}
cpanelAddMigrationEntry($log, $logPath, "Failed to create forwarder: $email - " . $error, 'error');
}
}
}
if ($forwardersRestored > 0) {
logger("Restored $forwardersRestored email forwarders");
}
}
}
// Restore SSL certificates
if ($restoreSsl) {
cpanelAddMigrationEntry($log, $logPath, 'Checking for SSL certificates...', 'pending');
$sslCerts = [];
// cPanel stores SSL certificates in various locations - check all of them
$keyPaths = [
"$backupRoot/homedir/ssl/keys",
"$backupRoot/ssl/keys",
"$backupRoot/sslkeys",
"$backupRoot/homedir/ssl",
"$backupRoot/ssl",
];
$certPaths = [
"$backupRoot/homedir/ssl/certs",
"$backupRoot/ssl/certs",
"$backupRoot/sslcerts",
"$backupRoot/homedir/ssl",
"$backupRoot/ssl",
];
// cPanel SSL naming convention:
// Cert: domain_name_keyid_timestamp_hash.crt (e.g., laundry_sh_co_il_e3914_8256f_1758985760_abc123.crt)
// Key: keyid_hash.key (e.g., e3914_8256f_abc123.key)
// The keyid links certs to keys
$keysByKeyId = []; // keyid => path
$certsByKeyId = []; // keyid => ['path' => path, 'domain' => domain]
// Scan for private keys first - extract keyid from filename
// Key format: {keyid}_{hash}.key where keyid is like e3914_8256f
foreach ($keyPaths as $keyDir) {
if (is_dir($keyDir)) {
foreach (scandir($keyDir) as $file) {
if (substr($file, -4) === '.key' && $file !== '.' && $file !== '..') {
// Key filename: e3914_8256f_abc123.key -> keyid is e3914_8256f
// Pattern: two hex groups separated by underscore, followed by underscore and hash
if (preg_match('/^([a-f0-9]+_[a-f0-9]+)_[a-f0-9]+\.key$/i', $file, $matches)) {
$keyId = $matches[1];
if (!isset($keysByKeyId[$keyId])) {
$keysByKeyId[$keyId] = "$keyDir/$file";
logger("Found SSL key with keyid $keyId: $file");
}
} else {
// Try simpler format: domain.key
$domain = str_replace('_', '.', substr($file, 0, -4));
if (!isset($sslCerts[$domain])) {
$sslCerts[$domain] = [];
}
if (empty($sslCerts[$domain]['key'])) {
$sslCerts[$domain]['key'] = "$keyDir/$file";
}
}
}
}
}
}
// Scan for certificates - extract domain and keyid from filename
// Cert format: {domain_with_underscores}_{keyid}_{timestamp}_{hash}.crt
foreach ($certPaths as $certDir) {
if (is_dir($certDir)) {
foreach (scandir($certDir) as $file) {
if (preg_match('/\.(crt|pem|cert)$/i', $file) && $file !== '.' && $file !== '..') {
// Try to match cPanel format: domain_keyid_timestamp_hash.crt
// e.g., laundry_sh_co_il_e3914_8256f_1758985760_abc123.crt
if (preg_match('/^(.+)_([a-f0-9]+_[a-f0-9]+)_\d+_[a-f0-9]+\.(crt|pem|cert)$/i', $file, $matches)) {
$domainWithUnderscores = $matches[1];
$keyId = $matches[2];
$domain = str_replace('_', '.', $domainWithUnderscores);
if (!isset($certsByKeyId[$keyId])) {
$certsByKeyId[$keyId] = [
'path' => "$certDir/$file",
'domain' => $domain
];
logger("Found SSL cert for $domain with keyid $keyId: $file");
}
} else {
// Try simpler format: domain.crt
$basename = preg_replace('/\.(crt|pem|cert)$/i', '', $file);
$domain = str_replace('_', '.', $basename);
if (!isset($sslCerts[$domain])) {
$sslCerts[$domain] = [];
}
if (empty($sslCerts[$domain]['crt'])) {
$sslCerts[$domain]['crt'] = "$certDir/$file";
}
}
}
}
}
}
// Match certificates to keys by keyid
foreach ($certsByKeyId as $keyId => $certInfo) {
$domain = $certInfo['domain'];
if (isset($keysByKeyId[$keyId])) {
// Found matching key for this cert
if (!isset($sslCerts[$domain])) {
$sslCerts[$domain] = [];
}
$sslCerts[$domain]['crt'] = $certInfo['path'];
$sslCerts[$domain]['key'] = $keysByKeyId[$keyId];
logger("Matched SSL cert and key for $domain using keyid $keyId");
} else {
// No matching key found
logger("No matching key found for cert $domain (keyid: $keyId)", 'WARNING');
if (!isset($sslCerts[$domain])) {
$sslCerts[$domain] = [];
}
$sslCerts[$domain]['crt'] = $certInfo['path'];
}
}
// Log found certificates for debugging
if (!empty($sslCerts)) {
logger("Found SSL certificates for domains: " . implode(', ', array_keys($sslCerts)));
}
// Build list of actual domain names being restored for matching
// cPanel encodes both dots and hyphens as underscores in SSL filenames
$actualDomains = array_column($domains, 'name');
$domainMapping = []; // underscore_form => actual_domain
foreach ($actualDomains as $actualDomain) {
// Convert actual domain to underscore form for comparison
$underscoreForm = str_replace(['.', '-'], '_', $actualDomain);
$domainMapping[$underscoreForm] = $actualDomain;
// Also add www variant
$wwwForm = 'www_' . $underscoreForm;
$domainMapping[$wwwForm] = $actualDomain;
}
logger("Domain mapping for SSL: " . json_encode($domainMapping));
// Install certificates - try to match against actual domains
$installedCount = 0;
foreach ($sslCerts as $sslDomain => $files) {
if (!empty($files['key']) && !empty($files['crt'])) {
// Convert parsed SSL domain back to underscore form for matching
$sslUnderscoreForm = str_replace('.', '_', $sslDomain);
// Try to find the actual domain this certificate belongs to
$targetDomain = null;
// Direct match against actual domains
if (in_array($sslDomain, $actualDomains)) {
$targetDomain = $sslDomain;
}
// Match via underscore form
elseif (isset($domainMapping[$sslUnderscoreForm])) {
$targetDomain = $domainMapping[$sslUnderscoreForm];
logger("Mapped SSL domain $sslDomain to actual domain $targetDomain via underscore form");
}
// Try partial matching (for addon domains with main domain suffix)
// cPanel addon SSL certs may have format: www.addon.main => www_addon_co_il_main_co_il
// We need to find the domain that appears EARLIEST in the SSL name (excluding www_)
else {
$bestMatch = null;
$bestPosition = PHP_INT_MAX;
foreach ($actualDomains as $actualDomain) {
$actualUnderscoreForm = str_replace(['.', '-'], '_', $actualDomain);
// Check for match starting at position 0 (domain.xxx.crt)
if (strpos($sslUnderscoreForm, $actualUnderscoreForm) === 0) {
$bestMatch = $actualDomain;
$bestPosition = 0;
break;
}
// Check for www_ prefix match (www.domain.xxx.crt)
if (strpos($sslUnderscoreForm, 'www_' . $actualUnderscoreForm) === 0) {
$bestMatch = $actualDomain;
$bestPosition = 0;
break;
}
// Find earliest occurrence of this domain in SSL name
$pos = strpos($sslUnderscoreForm, $actualUnderscoreForm);
if ($pos !== false && $pos < $bestPosition) {
$bestMatch = $actualDomain;
$bestPosition = $pos;
}
}
if ($bestMatch) {
$targetDomain = $bestMatch;
logger("Mapped SSL domain $sslDomain to actual domain $targetDomain via partial match (pos: $bestPosition)");
}
}
if (!$targetDomain) {
logger("No matching actual domain found for SSL domain: $sslDomain");
continue;
}
// Verify the key and cert files are readable
$keyContent = @file_get_contents($files['key']);
$certContent = @file_get_contents($files['crt']);
if ($keyContent && $certContent) {
try {
cpanelInstallSsl($username, $targetDomain, $files['crt'], $files['key']);
cpanelAddMigrationEntry($log, $logPath, "Installed SSL certificate for: $targetDomain", 'success');
$installedCount++;
} catch (Exception $e) {
cpanelAddMigrationEntry($log, $logPath, "Failed to install SSL for $targetDomain: " . $e->getMessage(), 'warning');
}
} else {
cpanelAddMigrationEntry($log, $logPath, "SSL files unreadable for $targetDomain", 'warning');
}
} else {
$missing = [];
if (empty($files['key'])) $missing[] = 'key';
if (empty($files['crt'])) $missing[] = 'certificate';
cpanelAddMigrationEntry($log, $logPath, "Incomplete SSL for $sslDomain (missing: " . implode(', ', $missing) . ")", 'warning');
}
}
if ($installedCount === 0) {
cpanelAddMigrationEntry($log, $logPath, 'No complete SSL certificates found in backup', 'info');
}
}
// Sync panel database records (domains, DNS, and optionally email)
try {
$panelMailboxes = $restoreEmails ? $mailboxes : [];
$panelForwarders = $restoreEmails ? $forwarders : [];
$panelData = cpanelBuildPanelEmailData($username, $domains, $panelMailboxes, $panelForwarders, $backupRoot);
$hasPanelData = !empty($panelData['domains'])
|| !empty($panelData['email_domains'])
|| !empty($panelData['mailboxes'])
|| !empty($panelData['email_forwarders'])
|| !empty($panelData['dns_records']);
if ($hasPanelData) {
$importResult = importUserPanelData($username, $panelData);
if ($importResult['success'] ?? false) {
cpanelAddMigrationEntry($log, $logPath, 'Synced panel records (domains, DNS, email)', 'success');
} else {
cpanelAddMigrationEntry($log, $logPath, 'Failed to sync panel records: ' . ($importResult['error'] ?? 'Unknown error'), 'warning');
}
}
} catch (Exception $e) {
cpanelAddMigrationEntry($log, $logPath, 'Failed to sync panel records: ' . $e->getMessage(), 'warning');
}
// Clean up temp directory
exec("rm -rf " . escapeshellarg($tempDir));
logger("cPanel backup restore completed for user: $username");
return [
'success' => true,
'log' => $log,
];
} catch (Exception $e) {
exec("rm -rf " . escapeshellarg($tempDir));
logger("cPanel backup restore failed: " . $e->getMessage(), 'ERROR');
return [
'success' => false,
'error' => $e->getMessage(),
'log' => $log,
];
}
}
/**
* Fix permissions on a cPanel migration backup file so www-data can read it.
* This is needed because SCP transfers create files owned by root.
*/
function cpanelFixBackupPermissions(array $params): array
{
$backupPath = $params['backup_path'] ?? '';
if (empty($backupPath)) {
return ['success' => false, 'error' => 'Backup path is required'];
}
// Validate the path is within the allowed cpanel-migrations directory
$allowedDir = '/var/backups/jabali/cpanel-migrations';
$realPath = realpath($backupPath);
// For new files that may not exist yet, check the parent directory
if ($realPath === false) {
$parentDir = dirname($backupPath);
$realParent = realpath($parentDir);
if ($realParent === false || strpos($realParent, $allowedDir) !== 0) {
return ['success' => false, 'error' => 'Invalid backup path: must be within cpanel-migrations directory'];
}
} else {
if (strpos($realPath, $allowedDir) !== 0) {
return ['success' => false, 'error' => 'Invalid backup path: must be within cpanel-migrations directory'];
}
}
if (!file_exists($backupPath)) {
return ['success' => false, 'error' => 'Backup file not found: ' . $backupPath];
}
// Set permissions to 644 (owner read/write, group read, others read)
if (!chmod($backupPath, 0644)) {
return ['success' => false, 'error' => 'Failed to set permissions on backup file'];
}
// Optionally chown to www-data if needed
$wwwData = posix_getpwnam('www-data');
if ($wwwData) {
chown($backupPath, $wwwData['uid']);
chgrp($backupPath, $wwwData['gid']);
}
logger("Fixed permissions on cPanel backup: $backupPath");
return ['success' => true];
}
/**
* Download a backup from a WHM server via SCP.
* Used for WHM migrations when HTTP download is not available.
*/
function whmDownloadBackupScp(array $params): array
{
$remoteHost = $params['host'] ?? '';
$remoteUser = $params['user'] ?? 'root';
$remotePort = (int)($params['port'] ?? 22);
$remotePath = $params['remote_path'] ?? '';
$localPath = $params['local_path'] ?? '';
$sshKeyPath = $params['key_path'] ?? '/root/.ssh/jabali_system_key';
if (empty($remoteHost)) {
return ['success' => false, 'error' => 'Remote host is required'];
}
if (empty($remotePath)) {
return ['success' => false, 'error' => 'Remote path is required'];
}
if (empty($localPath)) {
return ['success' => false, 'error' => 'Local path is required'];
}
// Validate local path is within allowed directories
$allowedDirs = ['/var/backups/jabali/whm-migrations', '/var/backups/jabali/cpanel-migrations'];
$realLocalDir = realpath(dirname($localPath));
$isAllowed = false;
foreach ($allowedDirs as $dir) {
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$realDir = realpath($dir);
if ($realLocalDir !== false && $realDir !== false && strpos($realLocalDir, $realDir) === 0) {
$isAllowed = true;
break;
}
}
if (!$isAllowed) {
return ['success' => false, 'error' => 'Invalid local path: must be within allowed backup directories'];
}
// Check if SSH key exists
if (!file_exists($sshKeyPath)) {
return ['success' => false, 'error' => 'SSH key not found: ' . $sshKeyPath];
}
logger("WHM: Downloading backup via SCP from $remoteUser@$remoteHost:$remotePath to $localPath");
// Build SCP command
$scpCmd = sprintf(
'scp -o StrictHostKeyChecking=no -o BatchMode=yes -o ConnectTimeout=30 -P %d -i %s %s@%s:%s %s 2>&1',
$remotePort,
escapeshellarg($sshKeyPath),
escapeshellarg($remoteUser),
escapeshellarg($remoteHost),
escapeshellarg($remotePath),
escapeshellarg($localPath)
);
exec($scpCmd, $output, $returnCode);
if ($returnCode !== 0) {
$errorMsg = implode("\n", $output);
logger("WHM: SCP download failed: $errorMsg", 'ERROR');
return ['success' => false, 'error' => 'SCP download failed: ' . $errorMsg];
}
// Verify file was downloaded
if (!file_exists($localPath)) {
return ['success' => false, 'error' => 'Download failed - file not found after SCP'];
}
$fileSize = filesize($localPath);
if ($fileSize === 0) {
@unlink($localPath);
return ['success' => false, 'error' => 'Downloaded file is empty'];
}
// Verify it's a valid gzip file
$handle = fopen($localPath, 'rb');
$magic = $handle ? fread($handle, 2) : '';
if ($handle) {
fclose($handle);
}
if ($magic !== "\x1f\x8b") {
@unlink($localPath);
return ['success' => false, 'error' => 'Downloaded file is not a valid gzip archive'];
}
// Fix permissions
chmod($localPath, 0644);
$wwwData = posix_getpwnam('www-data');
if ($wwwData) {
chown($localPath, $wwwData['uid']);
chgrp($localPath, $wwwData['gid']);
}
logger("WHM: Backup downloaded successfully: $localPath (" . formatBytes($fileSize) . ")");
return [
'success' => true,
'path' => $localPath,
'size' => $fileSize,
];
}
/**
* Register a domain in Jabali's database for the given user.
*/
function cpanelRegisterDomain(string $username, string $domain): bool
{
$pdo = getJabaliPdo();
if (!$pdo) {
logger("Failed to connect to Jabali database", 'ERROR');
return false;
}
try {
// Get user ID
$stmt = $pdo->prepare("SELECT id FROM users WHERE username = ?");
$stmt->execute([$username]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$user) {
logger("User not found in database: $username", 'ERROR');
return false;
}
// Check if domain already exists
$stmt = $pdo->prepare("SELECT id FROM domains WHERE domain = ?");
$stmt->execute([$domain]);
if ($stmt->fetch()) {
logger("Domain already exists in database: $domain");
return true;
}
// Create domain record
$stmt = $pdo->prepare("
INSERT INTO domains (user_id, domain, document_root, is_active, ssl_enabled, created_at, updated_at)
VALUES (?, ?, ?, 1, 0, datetime('now'), datetime('now'))
");
$stmt->execute([
$user['id'],
$domain,
"/home/$username/domains/$domain/public_html",
]);
logger("Domain registered in Jabali: $domain for user $username");
// Create nginx vhost config
$result = domainCreate(['username' => $username, 'domain' => $domain]);
if (!($result['success'] ?? false)) {
logger("Warning: Failed to create vhost for $domain: " . ($result['error'] ?? 'Unknown error'), 'WARNING');
}
return true;
} catch (Exception $e) {
logger("Error registering domain: " . $e->getMessage(), 'ERROR');
return false;
}
}
/**
* Create a MySQL database for cPanel migration.
*/
function cpanelCreateDatabase(string $username, string $dbName): bool
{
// Use existing mysql.create_database function
$result = mysqlCreateDatabase([
'username' => $username,
'database' => $dbName,
]);
return $result['success'] ?? false;
}
/**
* Store MySQL credentials in Jabali's database for the user.
*/
function cpanelStoreMysqlCredential(string $username, string $mysqlUsername, string $password): bool
{
try {
$pdo = getJabaliPdo();
if (!$pdo) {
return false;
}
// Get user ID
$stmt = $pdo->prepare("SELECT id FROM users WHERE username = ?");
$stmt->execute([$username]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$user) {
logger("User not found for MySQL credential storage: $username", 'ERROR');
return false;
}
// Check if credential already exists
$stmt = $pdo->prepare("SELECT id FROM mysql_credentials WHERE user_id = ? AND mysql_username = ?");
$stmt->execute([$user['id'], $mysqlUsername]);
if ($stmt->fetch()) {
// Update existing
$stmt = $pdo->prepare("UPDATE mysql_credentials SET mysql_password_encrypted = ?, updated_at = datetime('now') WHERE user_id = ? AND mysql_username = ?");
$stmt->execute([base64_encode($password), $user['id'], $mysqlUsername]);
} else {
// Insert new
$stmt = $pdo->prepare("INSERT INTO mysql_credentials (user_id, mysql_username, mysql_password_encrypted, created_at, updated_at) VALUES (?, ?, ?, datetime('now'), datetime('now'))");
$stmt->execute([$user['id'], $mysqlUsername, base64_encode($password)]);
}
logger("Stored MySQL credential for user $username: $mysqlUsername");
return true;
} catch (Exception $e) {
logger("Error storing MySQL credential: " . $e->getMessage(), 'ERROR');
return false;
}
}
function cpanelStripZoneComments(string $line): string
{
$result = '';
$inQuotes = false;
$length = strlen($line);
for ($i = 0; $i < $length; $i++) {
$char = $line[$i];
if ($char === '"') {
$inQuotes = ! $inQuotes;
$result .= $char;
continue;
}
if ($char === ';' && ! $inQuotes) {
break;
}
$result .= $char;
}
return trim($result);
}
function cpanelNormalizeTxtContent(string $content): string
{
$content = trim($content);
if ($content === '') {
return $content;
}
if (str_starts_with($content, '(') && str_ends_with($content, ')')) {
$content = trim(substr($content, 1, -1));
}
if (preg_match_all('/"([^"]*)"/', $content, $matches) && ! empty($matches[1])) {
return implode('', $matches[1]);
}
return trim($content, '"');
}
function cpanelNormalizeDnsName(string $name, string $domain): string
{
$name = trim($name);
if ($name === '') {
return '@';
}
if ($name === '@') {
return '@';
}
$name = rtrim($name, '.');
$domain = rtrim($domain, '.');
if ($name === $domain) {
return '@';
}
$suffix = '.' . $domain;
if (str_ends_with($name, $suffix)) {
return substr($name, 0, -strlen($suffix));
}
return $name;
}
function cpanelFindZoneFile(string $backupRoot, string $domain): ?string
{
$candidates = [
"{$backupRoot}/dnszones/{$domain}.db",
"{$backupRoot}/dnszones/{$domain}",
"{$backupRoot}/dns/{$domain}.db",
"{$backupRoot}/dns/{$domain}",
"{$backupRoot}/var/named/{$domain}.db",
"{$backupRoot}/var/named/{$domain}",
"{$backupRoot}/named/{$domain}.db",
"{$backupRoot}/named/{$domain}",
];
foreach ($candidates as $path) {
if (is_file($path)) {
return $path;
}
}
return null;
}
function cpanelParseZoneFileRecords(string $zoneFile, string $domain): array
{
$records = [];
$origin = rtrim($domain, '.') . '.';
$currentName = '@';
$currentTtl = null;
$lines = file($zoneFile, FILE_IGNORE_NEW_LINES);
$buffer = '';
$parenLevel = 0;
foreach ($lines as $line) {
$clean = cpanelStripZoneComments($line);
if ($clean === '') {
continue;
}
$parenLevel += substr_count($clean, '(') - substr_count($clean, ')');
$buffer = trim($buffer . ' ' . $clean);
if ($parenLevel > 0) {
continue;
}
$statement = trim($buffer);
$buffer = '';
if (stripos($statement, '$ORIGIN') === 0) {
$parts = preg_split('/\s+/', $statement);
if (! empty($parts[1])) {
$origin = rtrim($parts[1], '.') . '.';
}
continue;
}
if (stripos($statement, '$TTL') === 0) {
$parts = preg_split('/\s+/', $statement);
if (! empty($parts[1]) && is_numeric($parts[1])) {
$currentTtl = (int) $parts[1];
}
continue;
}
if (! preg_match('/^(?:(\S+)\s+)?(?:(\d+)\s+)?(?:IN\s+)?(A|AAAA|CNAME|MX|TXT|SRV|CAA)\s+(.+)$/i', $statement, $matches)) {
continue;
}
$name = $matches[1] ?? $currentName;
$ttl = $matches[2] ?? null;
$type = strtoupper($matches[3]);
$content = trim($matches[4]);
if ($name !== null && is_numeric($name) && $ttl === null) {
$ttl = $name;
$name = $currentName;
}
if ($name !== null && strtoupper($name) === 'IN') {
$name = $currentName;
}
$currentName = $name ?: $currentName;
$priority = null;
if ($type === 'TXT') {
$content = cpanelNormalizeTxtContent($content);
} elseif (in_array($type, ['MX', 'SRV'], true)) {
$parts = preg_split('/\s+/', $content);
if (! empty($parts[0]) && is_numeric($parts[0])) {
$priority = (int) $parts[0];
$parts = array_slice($parts, 1);
}
if ($type === 'SRV' && ! empty($parts)) {
$last = array_pop($parts);
$last = rtrim($last, '.');
$parts[] = $last;
}
$content = trim(implode(' ', $parts));
} elseif (in_array($type, ['CNAME', 'MX', 'CAA'], true)) {
$content = rtrim($content, '.');
}
$name = cpanelNormalizeDnsName($name ?: '@', $origin);
$recordTtl = $ttl !== null ? (int) $ttl : ($currentTtl ?? 3600);
$records[] = [
'name' => $name,
'type' => $type,
'content' => $content,
'ttl' => $recordTtl,
'priority' => $priority,
];
}
return $records;
}
function cpanelIsDefaultDnsRecord(string $domain, array $record): bool
{
$name = strtolower($record['name'] ?? '');
$type = strtoupper($record['type'] ?? '');
$content = strtolower($record['content'] ?? '');
if ($type === 'SOA' || $type === 'NS') {
return true;
}
if ($name === '_dmarc' || str_contains($name, '_domainkey')) {
return true;
}
if ($type === 'TXT') {
if (str_contains($content, 'v=spf1')
|| str_contains($content, 'v=spf2.0')
|| str_contains($content, 'v=dmarc1')
|| str_contains($content, 'v=dkim1')) {
return true;
}
}
$defaultNames = [
'www',
'mail',
'ftp',
'cpanel',
'whm',
'webmail',
'webdisk',
'cpcontacts',
'cpcalendars',
'autoconfig',
'autodiscover',
'ns1',
'ns2',
];
if ($name === '@' && in_array($type, ['A', 'AAAA'], true)) {
return true;
}
if (in_array($name, $defaultNames, true) && in_array($type, ['A', 'AAAA', 'CNAME'], true)) {
return true;
}
if ($name === '@' && $type === 'MX') {
$domain = rtrim(strtolower($domain), '.');
$target = rtrim($content, '.');
if ($target === "mail.{$domain}") {
return true;
}
}
return false;
}
function cpanelExtractCustomDnsRecords(string $backupRoot, array $domains): array
{
$customRecords = [];
foreach ($domains as $domain) {
$domainName = $domain['name'] ?? $domain['domain'] ?? '';
$domainName = strtolower(trim($domainName));
if (empty($domainName)) {
continue;
}
$zoneFile = cpanelFindZoneFile($backupRoot, $domainName);
if (! $zoneFile) {
continue;
}
$records = cpanelParseZoneFileRecords($zoneFile, $domainName);
foreach ($records as $record) {
if (cpanelIsDefaultDnsRecord($domainName, $record)) {
continue;
}
$customRecords[$domainName][] = $record;
}
}
return $customRecords;
}
function cpanelBuildPanelEmailData(string $username, array $domains, array $mailboxes, array $forwarders, string $backupRoot): array
{
$panelData = [
'username' => $username,
'domains' => [],
'email_domains' => [],
'mailboxes' => [],
'email_forwarders' => [],
'dns_records' => [],
];
$domainIdMap = [];
$domainId = 1;
foreach ($domains as $domain) {
$domainName = $domain['name'] ?? $domain['domain'] ?? '';
$domainName = strtolower(preg_replace('/[\x00-\x1F\x7F]/', '', trim($domainName)));
if (empty($domainName)) {
continue;
}
if (!isset($domainIdMap[$domainName])) {
$domainIdMap[$domainName] = $domainId;
$panelData['domains'][] = [
'id' => $domainId,
'domain' => $domainName,
'document_root' => "/home/$username/domains/$domainName/public_html",
'is_active' => 1,
'ssl_enabled' => 0,
'directory_index' => 'index.php index.html',
'page_cache_enabled' => 0,
];
$domainId++;
}
}
$dnsRecordsByDomain = cpanelExtractCustomDnsRecords($backupRoot, $domains);
if (! empty($dnsRecordsByDomain)) {
foreach ($dnsRecordsByDomain as $domainName => $records) {
$domainId = $domainIdMap[$domainName] ?? null;
if (! $domainId) {
continue;
}
foreach ($records as $record) {
$panelData['dns_records'][] = array_merge(['domain_id' => $domainId], $record);
}
}
}
$emailDomainIdMap = [];
$emailDomainId = 1;
$emailDomains = [];
foreach ($mailboxes as $mailbox) {
$emailDomains[] = $mailbox['domain'] ?? '';
}
foreach ($forwarders as $forwarder) {
$emailDomains[] = $forwarder['domain'] ?? '';
}
foreach (array_unique($emailDomains) as $domainName) {
$domainName = strtolower(preg_replace('/[\x00-\x1F\x7F]/', '', trim($domainName)));
if (empty($domainName)) {
continue;
}
$emailDomainIdMap[$domainName] = $emailDomainId;
$panelData['email_domains'][] = [
'id' => $emailDomainId,
'domain_id' => $domainIdMap[$domainName] ?? null,
'domain_name' => $domainName,
'is_active' => 1,
'dkim_selector' => 'default',
'catch_all_enabled' => 0,
'max_mailboxes' => 10,
'max_quota_bytes' => 5368709120,
];
$emailDomainId++;
}
foreach ($mailboxes as $mailbox) {
$localPart = preg_replace('/[\x00-\x1F\x7F]/', '', trim($mailbox['local_part'] ?? ''));
$domainName = strtolower(preg_replace('/[\x00-\x1F\x7F]/', '', trim($mailbox['domain'] ?? '')));
if (empty($localPart) || empty($domainName)) {
continue;
}
$email = "{$localPart}@{$domainName}";
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
continue;
}
$emailDomainId = $emailDomainIdMap[$domainName] ?? null;
if (!$emailDomainId) {
continue;
}
$panelData['mailboxes'][] = [
'email_domain_id' => $emailDomainId,
'local_part' => $localPart,
'domain_name' => $domainName,
'name' => $localPart,
'quota_bytes' => 1073741824,
'maildir_path' => "/home/$username/mail/$domainName/$localPart/",
'is_active' => 1,
];
}
foreach ($forwarders as $forwarder) {
$localPart = preg_replace('/[\x00-\x1F\x7F]/', '', trim($forwarder['local_part'] ?? ''));
$domainName = strtolower(preg_replace('/[\x00-\x1F\x7F]/', '', trim($forwarder['domain'] ?? '')));
if (empty($localPart) || empty($domainName)) {
continue;
}
$email = "{$localPart}@{$domainName}";
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
continue;
}
$emailDomainId = $emailDomainIdMap[$domainName] ?? null;
if (!$emailDomainId) {
continue;
}
$destinations = cpanelParseForwarderDestinations($forwarder, $backupRoot);
if (empty($destinations)) {
continue;
}
$panelData['email_forwarders'][] = [
'email_domain_id' => $emailDomainId,
'local_part' => $localPart,
'destinations' => json_encode($destinations),
'is_active' => 1,
];
}
return $panelData;
}
function cpanelParseForwarderDestinations(array $forwarder, string $backupRoot): array
{
$destinations = '';
if (!empty($forwarder['destinations'])) {
$destinations = is_array($forwarder['destinations'])
? implode(', ', $forwarder['destinations'])
: trim($forwarder['destinations']);
}
if (empty($destinations)) {
$file = $forwarder['file'] ?? null;
$domain = $forwarder['domain'] ?? '';
$localPart = $forwarder['local_part'] ?? '';
if ($file && file_exists($file)) {
$destinations = trim(file_get_contents($file));
} else {
$possiblePaths = [
"$backupRoot/homedir/etc/$domain/va/$localPart",
"$backupRoot/etc/$domain/va/$localPart",
];
foreach ($possiblePaths as $path) {
if (file_exists($path)) {
$destinations = trim(file_get_contents($path));
break;
}
}
}
}
$destArray = preg_split('/[\s,]+/', (string) $destinations, -1, PREG_SPLIT_NO_EMPTY);
$destArray = array_filter($destArray, fn($d) => filter_var($d, FILTER_VALIDATE_EMAIL));
return array_values($destArray);
}
function loadJabaliEnv(): void
{
static $loaded = false;
if ($loaded) {
return;
}
$envPath = '/var/www/jabali/.env';
if (!file_exists($envPath)) {
$loaded = true;
return;
}
$lines = file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) {
continue;
}
[$key, $value] = explode('=', $line, 2);
$key = trim($key);
$value = trim($value);
if ($key === '') {
continue;
}
if ((str_starts_with($value, '"') && str_ends_with($value, '"')) ||
(str_starts_with($value, "'") && str_ends_with($value, "'"))) {
$value = substr($value, 1, -1);
}
if (!array_key_exists($key, $_ENV)) {
$_ENV[$key] = $value;
putenv("{$key}={$value}");
}
}
$loaded = true;
}
function getJabaliPdo(): ?PDO
{
loadJabaliEnv();
$dbDriver = $_ENV['DB_CONNECTION'] ?? 'sqlite';
$dbPath = $_ENV['DB_DATABASE'] ?? '/var/www/jabali/database/database.sqlite';
try {
if ($dbDriver === 'sqlite') {
if (!file_exists($dbPath)) {
return null;
}
$pdo = new PDO("sqlite:$dbPath");
} else {
$mysqlHost = $_ENV['DB_HOST'] ?? 'localhost';
$mysqlPort = $_ENV['DB_PORT'] ?? '3306';
$mysqlDb = $_ENV['DB_DATABASE'] ?? 'jabali';
$mysqlUser = $_ENV['DB_USERNAME'] ?? 'root';
$mysqlPass = $_ENV['DB_PASSWORD'] ?? '';
$pdo = new PDO(
"mysql:host={$mysqlHost};port={$mysqlPort};dbname={$mysqlDb};charset=utf8mb4",
$mysqlUser,
$mysqlPass
);
}
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
return $pdo;
} catch (Exception $e) {
logger("Failed to connect to Jabali DB: " . $e->getMessage(), 'ERROR');
return null;
}
}
function cpanelGetUserId(PDO $pdo, string $username): ?int
{
$stmt = $pdo->prepare("SELECT id FROM users WHERE username = ?");
$stmt->execute([$username]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$user) {
return null;
}
return (int) $user['id'];
}
function cpanelGetDomainId(PDO $pdo, string $domain): ?int
{
$stmt = $pdo->prepare("SELECT id FROM domains WHERE domain = ?");
$stmt->execute([$domain]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
return null;
}
return (int) $row['id'];
}
function cpanelEnsureEmailDomainRecord(PDO $pdo, int $domainId): ?int
{
$stmt = $pdo->prepare("SELECT id FROM email_domains WHERE domain_id = ?");
$stmt->execute([$domainId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row) {
return (int) $row['id'];
}
$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 (?, 1, 'default', 0, 10, 5368709120, datetime('now'), datetime('now'))
");
$stmt->execute([$domainId]);
return (int) $pdo->lastInsertId();
}
function cpanelEnsureMailboxRecord(
PDO $pdo,
int $emailDomainId,
int $userId,
string $localPart,
string $passwordHash,
?string $maildirPath,
?int $systemUid,
?int $systemGid,
?string $name
): bool {
if ($passwordHash === '') {
return false;
}
$stmt = $pdo->prepare("SELECT id FROM mailboxes WHERE email_domain_id = ? AND local_part = ?");
$stmt->execute([$emailDomainId, $localPart]);
$existing = $stmt->fetch(PDO::FETCH_ASSOC);
if ($existing) {
$stmt = $pdo->prepare("
UPDATE mailboxes
SET password_hash = ?,
maildir_path = ?,
system_uid = ?,
system_gid = ?,
name = COALESCE(?, name),
updated_at = datetime('now')
WHERE id = ?
");
$stmt->execute([
$passwordHash,
$maildirPath,
$systemUid,
$systemGid,
$name,
$existing['id'],
]);
return true;
}
$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
(?, ?, ?, ?, ?, 1073741824, 0, 1, 1, 1, 1, ?, ?, ?, datetime('now'), datetime('now'))
");
$stmt->execute([
$emailDomainId,
$userId,
$localPart,
$passwordHash,
$name,
$maildirPath,
$systemUid,
$systemGid,
]);
return true;
}
function cpanelNormalizeMailboxHash(?string $hash): ?string
{
$hash = trim((string) $hash);
if ($hash === '' || $hash === '!' || $hash === '!!' || $hash === '*') {
return null;
}
if (str_starts_with($hash, '{')) {
return $hash;
}
if (str_starts_with($hash, '$')) {
return '{CRYPT}' . $hash;
}
return $hash;
}
function cpanelFindMailboxPasswordHash(string $backupRoot, string $domain, string $localPart): ?string
{
$domain = trim($domain);
$localPart = trim($localPart);
if ($domain === '' || $localPart === '') {
return null;
}
$paths = [
"{$backupRoot}/homedir/etc/{$domain}/shadow",
"{$backupRoot}/etc/{$domain}/shadow",
"{$backupRoot}/homedir/etc/{$domain}/passwd",
"{$backupRoot}/etc/{$domain}/passwd",
];
foreach ($paths as $path) {
if (!is_file($path)) {
continue;
}
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '#')) {
continue;
}
$parts = explode(':', $line);
if (count($parts) < 2) {
continue;
}
if ($parts[0] !== $localPart) {
continue;
}
return cpanelNormalizeMailboxHash($parts[1]);
}
}
return null;
}
/**
* Enable email for a domain during cPanel migration.
*/
function cpanelEnableEmailDomain(string $username, string $domain): bool
{
try {
$result = emailEnableDomain(['username' => $username, 'domain' => $domain]);
if (!($result['success'] ?? false)) {
return false;
}
$pdo = getJabaliPdo();
if (!$pdo) {
return true;
}
$domainId = cpanelGetDomainId($pdo, $domain);
if (!$domainId) {
cpanelRegisterDomain($username, $domain);
$domainId = cpanelGetDomainId($pdo, $domain);
}
if (!$domainId) {
return true;
}
cpanelEnsureEmailDomainRecord($pdo, $domainId);
return true;
} catch (Exception $e) {
logger("Error enabling email domain: " . $e->getMessage(), 'ERROR');
return false;
}
}
/**
* Create a mailbox during cPanel migration.
*/
function cpanelCreateMailbox(string $username, string $domain, string $localPart, string $password): array
{
// Use existing email mailbox create function
return emailMailboxCreate([
'username' => $username,
'email' => "{$localPart}@{$domain}",
'password' => $password,
'quota_bytes' => 1073741824, // 1GB
'name' => $localPart,
]);
}
/**
* Install SSL certificate during cPanel migration.
*/
function cpanelInstallSsl(string $username, string $domain, string $certFile, string $keyFile): bool
{
// Read certificate and key files
$cert = file_get_contents($certFile);
$key = file_get_contents($keyFile);
if (empty($cert) || empty($key)) {
logger("SSL install failed for $domain: empty cert or key");
return false;
}
// Create SSL directory for this domain
$sslDir = "/etc/letsencrypt/live/$domain";
if (!is_dir($sslDir)) {
mkdir($sslDir, 0755, true);
}
// Save certificate and key files
$certPath = "$sslDir/fullchain.pem";
$keyPath = "$sslDir/privkey.pem";
file_put_contents($certPath, $cert);
chmod($certPath, 0644);
file_put_contents($keyPath, $key);
chmod($keyPath, 0600);
// Use existing sslInstallCertificate function
$result = sslInstallCertificate($domain, $username, $certPath, $keyPath);
if ($result['success'] ?? false) {
// Update database to mark SSL as active
try {
$pdo = getJabaliPdo();
if ($pdo) {
// Get domain ID
$stmt = $pdo->prepare("SELECT id FROM domains WHERE domain = ?");
$stmt->execute([$domain]);
$domainRow = $stmt->fetch(PDO::FETCH_ASSOC);
if ($domainRow) {
// Update or insert SSL certificate record
$stmt = $pdo->prepare("SELECT id FROM ssl_certificates WHERE domain_id = ?");
$stmt->execute([$domainRow['id']]);
$existing = $stmt->fetch(PDO::FETCH_ASSOC);
$now = date('Y-m-d H:i:s');
// Try to get expiry from certificate
$certInfo = openssl_x509_parse($cert);
$expiresAt = isset($certInfo['validTo_time_t']) ? date('Y-m-d H:i:s', $certInfo['validTo_time_t']) : null;
$issuer = $certInfo['issuer']['O'] ?? 'Unknown';
if ($existing) {
$stmt = $pdo->prepare("UPDATE ssl_certificates SET type = 'custom', status = 'active', certificate = ?, private_key = ?, issuer = ?, expires_at = ?, updated_at = ? WHERE id = ?");
$stmt->execute([$cert, $key, $issuer, $expiresAt, $now, $existing['id']]);
} else {
$stmt = $pdo->prepare("INSERT INTO ssl_certificates (domain_id, type, status, certificate, private_key, issuer, expires_at, created_at, updated_at) VALUES (?, 'custom', 'active', ?, ?, ?, ?, ?, ?)");
$stmt->execute([$domainRow['id'], $cert, $key, $issuer, $expiresAt, $now, $now]);
}
// Update domain ssl_enabled flag
$stmt = $pdo->prepare("UPDATE domains SET ssl_enabled = 1 WHERE id = ?");
$stmt->execute([$domainRow['id']]);
logger("SSL certificate installed and database updated for $domain");
}
}
} catch (Exception $e) {
logger("Failed to update SSL database records: " . $e->getMessage(), 'WARNING');
}
return true;
}
logger("SSL install failed for $domain: " . ($result['error'] ?? 'unknown error'));
return false;
}
function toolExists(string $binary): bool
{
$output = [];
$code = 1;
exec("command -v " . escapeshellarg($binary) . " 2>/dev/null", $output, $code);
return $code === 0;
}
function validateDbIdentifier(string $name): bool
{
return (bool) preg_match('/^[a-zA-Z0-9_]+$/', $name);
}
function gitGenerateKey(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'];
}
$home = $userInfo['dir'];
$sshDir = $home . '/.ssh';
$keyPath = $sshDir . '/jabali_git_deploy';
$pubPath = $keyPath . '.pub';
if (!is_dir($sshDir)) {
mkdir($sshDir, 0700, true);
chown($sshDir, $userInfo['uid']);
chgrp($sshDir, $userInfo['gid']);
}
chmod($sshDir, 0700);
if (!file_exists($keyPath) || !file_exists($pubPath)) {
$cmd = "ssh-keygen -t ed25519 -N '' -f " . escapeshellarg($keyPath) . " -C " . escapeshellarg("jabali-deploy-{$username}") . " 2>&1";
exec($cmd, $output, $code);
if ($code !== 0) {
return ['success' => false, 'error' => 'Failed to generate deploy key: ' . implode("\n", $output)];
}
chmod($keyPath, 0600);
chmod($pubPath, 0644);
chown($keyPath, $userInfo['uid']);
chgrp($keyPath, $userInfo['gid']);
chown($pubPath, $userInfo['uid']);
chgrp($pubPath, $userInfo['gid']);
}
$publicKey = trim((string) @file_get_contents($pubPath));
if ($publicKey === '') {
return ['success' => false, 'error' => 'Public key not found'];
}
return [
'success' => true,
'public_key' => $publicKey,
'private_key_path' => $keyPath,
];
}
function gitDeploy(array $params): array
{
$username = $params['username'] ?? '';
$repoUrl = trim((string) ($params['repo_url'] ?? ''));
$branch = trim((string) ($params['branch'] ?? 'main'));
$deployPath = trim((string) ($params['deploy_path'] ?? ''));
$deployScript = $params['deploy_script'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
if ($repoUrl === '' || $deployPath === '') {
return ['success' => false, 'error' => 'Repository URL and deploy path are required'];
}
$userInfo = posix_getpwnam($username);
if (!$userInfo) {
return ['success' => false, 'error' => 'User not found'];
}
$home = $userInfo['dir'];
if (!str_starts_with($deployPath, $home)) {
return ['success' => false, 'error' => 'Deploy path must be inside user home directory'];
}
if (!is_dir($deployPath)) {
mkdir($deployPath, 0755, true);
chown($deployPath, $userInfo['uid']);
chgrp($deployPath, $userInfo['gid']);
}
$sshKey = $home . '/.ssh/jabali_git_deploy';
$env = 'HOME=' . escapeshellarg($home);
if (file_exists($sshKey)) {
$sshCmd = 'ssh -i ' . escapeshellarg($sshKey) . ' -o StrictHostKeyChecking=accept-new';
$env .= ' GIT_SSH_COMMAND=' . escapeshellarg($sshCmd);
}
$output = [];
$code = 0;
if (!is_dir($deployPath . '/.git')) {
$cmd = "sudo -u " . escapeshellarg($username) . " env {$env} git clone --branch " . escapeshellarg($branch)
. " " . escapeshellarg($repoUrl) . " " . escapeshellarg($deployPath) . " 2>&1";
exec($cmd, $output, $code);
} else {
$cmdFetch = "sudo -u " . escapeshellarg($username) . " env {$env} bash -lc "
. escapeshellarg("cd {$deployPath} && git fetch --all --prune");
exec($cmdFetch . " 2>&1", $fetchOutput, $fetchCode);
$output = array_merge($output, $fetchOutput);
$cmdReset = "sudo -u " . escapeshellarg($username) . " env {$env} bash -lc "
. escapeshellarg("cd {$deployPath} && git checkout " . escapeshellarg($branch) . " && git reset --hard origin/{$branch}");
exec($cmdReset . " 2>&1", $resetOutput, $code);
$output = array_merge($output, $resetOutput);
}
if ($code !== 0) {
return ['success' => false, 'error' => implode("\n", $output)];
}
exec("chown -R {$userInfo['uid']}:{$userInfo['gid']} " . escapeshellarg($deployPath));
$scriptOutput = [];
if (is_string($deployScript) && trim($deployScript) !== '') {
$tmpScript = "/tmp/jabali-deploy-{$username}-" . time() . ".sh";
$scriptBody = "#!/usr/bin/env bash\nset -e\ncd " . escapeshellarg($deployPath) . "\n" . $deployScript . "\n";
file_put_contents($tmpScript, $scriptBody);
chmod($tmpScript, 0700);
$cmdScript = "sudo -u " . escapeshellarg($username) . " env HOME=" . escapeshellarg($home) . " bash " . escapeshellarg($tmpScript) . " 2>&1";
exec($cmdScript, $scriptOutput, $scriptCode);
@unlink($tmpScript);
if ($scriptCode !== 0) {
return ['success' => false, 'error' => implode("\n", $scriptOutput)];
}
}
return [
'success' => true,
'output' => $output,
'script_output' => $scriptOutput,
];
}
function rspamdUserSettings(array $params): array
{
$username = $params['username'] ?? '';
$whitelist = $params['whitelist'] ?? [];
$blacklist = $params['blacklist'] ?? [];
$score = $params['score'] ?? null;
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$whitelist = array_values(array_filter(array_map('trim', (array) $whitelist)));
$blacklist = array_values(array_filter(array_map('trim', (array) $blacklist)));
$confFile = '/etc/rspamd/local.d/users.conf';
$content = file_exists($confFile) ? (string) file_get_contents($confFile) : '';
$pattern = "/# BEGIN JABALI USER {$username}.*?# END JABALI USER {$username}\\n?/s";
$content = preg_replace($pattern, '', $content) ?? $content;
$lines = [];
$lines[] = "# BEGIN JABALI USER {$username}";
$lines[] = "{$username} {";
if (!empty($whitelist)) {
$lines[] = " whitelist_from = [" . implode(', ', array_map(fn ($item) => '\"' . addslashes($item) . '\"', $whitelist)) . "];";
}
if (!empty($blacklist)) {
$lines[] = " blacklist_from = [" . implode(', ', array_map(fn ($item) => '\"' . addslashes($item) . '\"', $blacklist)) . "];";
}
if ($score !== null && $score !== '') {
$lines[] = " score = " . (float) $score . ";";
}
$lines[] = "}";
$lines[] = "# END JABALI USER {$username}";
$content = rtrim($content) . "\n\n" . implode("\n", $lines) . "\n";
if (file_put_contents($confFile, $content) === false) {
return ['success' => false, 'error' => 'Failed to write Rspamd config'];
}
exec("systemctl reload rspamd 2>&1", $reloadOutput, $reloadCode);
if ($reloadCode !== 0) {
return ['success' => false, 'error' => 'Failed to reload Rspamd: ' . implode("\n", $reloadOutput)];
}
return ['success' => true];
}
function usageBandwidthTotal(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'];
$total = 0;
foreach (glob($userHome . '/domains/*/logs/access.log') as $logFile) {
if (!is_readable($logFile)) {
continue;
}
$cmd = "awk '{if ($10 ~ /^[0-9]+$/) sum+=$10} END {print sum}' " . escapeshellarg($logFile);
exec($cmd, $out, $code);
if ($code === 0 && isset($out[0])) {
$total += (int) trim($out[0]);
}
}
return ['success' => true, 'total_bytes' => $total];
}
function usageUserResources(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'];
}
$uid = (int) $userInfo['uid'];
$cpuPercent = 0.0;
$cpuUsageUsec = null;
$memoryBytes = 0;
$diskIoTotal = 0;
$diskRead = 0;
$diskWrite = 0;
if ($cpuUsageUsec === null || $memoryBytes === 0) {
$cpuTotal = 0.0;
$rssTotal = 0;
exec("ps -u " . escapeshellarg($username) . " -o %cpu=,rss= 2>/dev/null", $psOut, $psCode);
if ($psCode === 0) {
foreach ($psOut as $line) {
$parts = preg_split('/\\s+/', trim($line));
if (count($parts) < 2) {
continue;
}
$cpuTotal += (float) $parts[0];
$rssTotal += (int) $parts[1] * 1024;
}
}
$cpuPercent = round($cpuTotal, 2);
if ($memoryBytes === 0) {
$memoryBytes = $rssTotal;
}
}
if ($diskIoTotal === 0) {
foreach (glob('/proc/[0-9]*') as $procPath) {
$statusFile = $procPath . '/status';
if (!is_readable($statusFile)) {
continue;
}
$status = file($statusFile, FILE_IGNORE_NEW_LINES);
if (!$status) {
continue;
}
$matchesUid = false;
foreach ($status as $line) {
if (str_starts_with($line, 'Uid:')) {
$parts = preg_split('/\\s+/', trim($line));
$matchesUid = isset($parts[1]) && (int) $parts[1] === $uid;
break;
}
}
if (!$matchesUid) {
continue;
}
$ioFile = $procPath . '/io';
if (!is_readable($ioFile)) {
continue;
}
$ioLines = file($ioFile, FILE_IGNORE_NEW_LINES);
if (!$ioLines) {
continue;
}
foreach ($ioLines as $line) {
if (str_starts_with($line, 'read_bytes:')) {
$diskRead += (int) trim(substr($line, 11));
} elseif (str_starts_with($line, 'write_bytes:')) {
$diskWrite += (int) trim(substr($line, 12));
}
}
}
$diskIoTotal = $diskRead + $diskWrite;
}
return [
'success' => true,
'cpu_percent' => $cpuPercent,
'cpu_usage_usec_total' => $cpuUsageUsec,
'memory_bytes' => $memoryBytes,
'disk_io_total_bytes' => $diskIoTotal,
'disk_io_read_bytes_total' => $diskRead,
'disk_io_write_bytes_total' => $diskWrite,
];
}
function imageOptimize(array $params): array
{
$username = $params['username'] ?? '';
$path = $params['path'] ?? '';
$convertWebp = (bool) ($params['convert_webp'] ?? false);
$quality = (int) ($params['quality'] ?? 82);
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'];
$path = $path ?: $userHome . '/domains';
if (!str_starts_with($path, $userHome)) {
return ['success' => false, 'error' => 'Path must be inside user home'];
}
if (!is_dir($path)) {
return ['success' => false, 'error' => 'Path not found'];
}
$quality = max(40, min(95, $quality));
$optimized = [
'jpg' => 0,
'png' => 0,
'webp' => 0,
];
if (toolExists('jpegoptim')) {
$cmd = "find " . escapeshellarg($path) . " -type f \\( -iname '*.jpg' -o -iname '*.jpeg' \\) -print0 | xargs -0 -r jpegoptim --strip-all --max={$quality} 2>&1";
exec($cmd, $out, $code);
if ($code === 0) {
$optimized['jpg'] = count($out) > 0 ? count($out) : 0;
}
}
if (toolExists('optipng')) {
$cmd = "find " . escapeshellarg($path) . " -type f -iname '*.png' -print0 | xargs -0 -r optipng -o2 2>&1";
exec($cmd, $out, $code);
if ($code === 0) {
$optimized['png'] = count($out) > 0 ? count($out) : 0;
}
}
if ($convertWebp && toolExists('cwebp')) {
$cmd = "find " . escapeshellarg($path) . " -type f \\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' \\) -print0 | xargs -0 -r -I{} cwebp -q {$quality} {} -o {}.webp 2>&1";
exec($cmd, $out, $code);
if ($code === 0) {
$optimized['webp'] = count($out) > 0 ? count($out) : 0;
}
}
return ['success' => true, 'optimized' => $optimized];
}
function postgresListDatabases(array $params): array
{
$username = $params['username'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$sql = "SELECT datname, pg_database_size(datname) FROM pg_database WHERE datistemplate = false AND datname LIKE '" . addslashes($username) . "\\_%'";
exec("sudo -u postgres psql -At -c " . escapeshellarg($sql) . " 2>&1", $out, $code);
if ($code !== 0) {
return ['success' => false, 'error' => implode("\n", $out)];
}
$databases = [];
foreach ($out as $line) {
if (!str_contains($line, '|')) {
continue;
}
[$name, $size] = array_map('trim', explode('|', $line, 2));
$databases[] = ['name' => $name, 'size_bytes' => (int) $size];
}
return ['success' => true, 'databases' => $databases];
}
function postgresListUsers(array $params): array
{
$username = $params['username'] ?? '';
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$sql = "SELECT usename FROM pg_user WHERE usename LIKE '" . addslashes($username) . "\\_%'";
exec("sudo -u postgres psql -At -c " . escapeshellarg($sql) . " 2>&1", $out, $code);
if ($code !== 0) {
return ['success' => false, 'error' => implode("\n", $out)];
}
$users = [];
foreach ($out as $line) {
$line = trim($line);
if ($line !== '') {
$users[] = ['username' => $line];
}
}
return ['success' => true, 'users' => $users];
}
function postgresCreateUser(array $params): array
{
$username = $params['username'] ?? '';
$dbUser = $params['db_user'] ?? '';
$password = $params['password'] ?? '';
if (!validateUsername($username) || !validateDbIdentifier($dbUser)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$sql = "DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '" . addslashes($dbUser) . "') THEN CREATE ROLE \"" . addslashes($dbUser) . "\" LOGIN PASSWORD '" . addslashes($password) . "'; END IF; END $$;";
exec("sudo -u postgres psql -At -c " . escapeshellarg($sql) . " 2>&1", $out, $code);
if ($code !== 0) {
return ['success' => false, 'error' => implode("\n", $out)];
}
return ['success' => true];
}
function postgresDeleteUser(array $params): array
{
$username = $params['username'] ?? '';
$dbUser = $params['db_user'] ?? '';
if (!validateUsername($username) || !validateDbIdentifier($dbUser)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$sql = "DROP ROLE IF EXISTS \"" . addslashes($dbUser) . "\"";
exec("sudo -u postgres psql -At -c " . escapeshellarg($sql) . " 2>&1", $out, $code);
if ($code !== 0) {
return ['success' => false, 'error' => implode("\n", $out)];
}
return ['success' => true];
}
function postgresChangePassword(array $params): array
{
$username = $params['username'] ?? '';
$dbUser = $params['db_user'] ?? '';
$password = $params['password'] ?? '';
if (!validateUsername($username) || !validateDbIdentifier($dbUser)) {
return ['success' => false, 'error' => 'Invalid username'];
}
$sql = "ALTER ROLE \"" . addslashes($dbUser) . "\" WITH PASSWORD '" . addslashes($password) . "'";
exec("sudo -u postgres psql -At -c " . escapeshellarg($sql) . " 2>&1", $out, $code);
if ($code !== 0) {
return ['success' => false, 'error' => implode("\n", $out)];
}
return ['success' => true];
}
function postgresCreateDatabase(array $params): array
{
$username = $params['username'] ?? '';
$database = $params['database'] ?? '';
$owner = $params['owner'] ?? '';
if (!validateUsername($username) || !validateDbIdentifier($database) || !validateDbIdentifier($owner)) {
return ['success' => false, 'error' => 'Invalid database name'];
}
$sql = "CREATE DATABASE \"" . addslashes($database) . "\" OWNER \"" . addslashes($owner) . "\"";
exec("sudo -u postgres psql -At -c " . escapeshellarg($sql) . " 2>&1", $out, $code);
if ($code !== 0) {
return ['success' => false, 'error' => implode("\n", $out)];
}
return ['success' => true];
}
function postgresDeleteDatabase(array $params): array
{
$username = $params['username'] ?? '';
$database = $params['database'] ?? '';
if (!validateUsername($username) || !validateDbIdentifier($database)) {
return ['success' => false, 'error' => 'Invalid database name'];
}
$sql = "DROP DATABASE IF EXISTS \"" . addslashes($database) . "\"";
exec("sudo -u postgres psql -At -c " . escapeshellarg($sql) . " 2>&1", $out, $code);
if ($code !== 0) {
return ['success' => false, 'error' => implode("\n", $out)];
}
return ['success' => true];
}
function postgresGrantPrivileges(array $params): array
{
$username = $params['username'] ?? '';
$database = $params['database'] ?? '';
$dbUser = $params['db_user'] ?? '';
if (!validateUsername($username) || !validateDbIdentifier($database) || !validateDbIdentifier($dbUser)) {
return ['success' => false, 'error' => 'Invalid database name'];
}
$sql = "GRANT ALL PRIVILEGES ON DATABASE \"" . addslashes($database) . "\" TO \"" . addslashes($dbUser) . "\"";
exec("sudo -u postgres psql -At -c " . escapeshellarg($sql) . " 2>&1", $out, $code);
if ($code !== 0) {
return ['success' => false, 'error' => implode("\n", $out)];
}
return ['success' => true];
}
function mailQueueList(array $params): array
{
$queue = [];
// Try JSON output if available (Postfix 3.1+)
$jsonOutput = [];
exec('postqueue -j 2>/dev/null', $jsonOutput, $jsonCode);
if ($jsonCode === 0 && !empty($jsonOutput)) {
foreach ($jsonOutput as $line) {
$entry = json_decode($line, true);
if (!is_array($entry)) {
continue;
}
$queue[] = [
'id' => $entry['queue_id'] ?? '',
'arrival' => isset($entry['arrival_time']) ? date('Y-m-d H:i:s', (int) $entry['arrival_time']) : '',
'sender' => $entry['sender'] ?? '',
'recipients' => $entry['recipients'] ?? [],
'size' => isset($entry['message_size']) ? formatBytes((int) $entry['message_size']) : '',
'status' => $entry['status'] ?? '',
];
}
return ['success' => true, 'queue' => $queue];
}
$output = [];
exec('postqueue -p 2>/dev/null', $output, $code);
if ($code !== 0) {
return ['success' => false, 'error' => 'Failed to read mail queue'];
}
if (empty($output)) {
return ['success' => true, 'queue' => []];
}
$current = null;
foreach ($output as $line) {
$trim = trim($line);
if ($trim === '' || str_contains($trim, 'Mail queue is empty')) {
if ($current) {
$queue[] = $current;
$current = null;
}
continue;
}
if (preg_match('/^([A-F0-9]{5,}|[A-Za-z0-9]{5,})[\*\!\-]?\s+(\d+)\s+(.+?)\s+([^\s]+)$/', $line, $matches)) {
if ($current) {
$queue[] = $current;
}
$current = [
'id' => $matches[1],
'size' => formatBytes((int) $matches[2]),
'arrival' => trim($matches[3]),
'sender' => trim($matches[4]),
'recipients' => [],
'status' => '',
];
continue;
}
if ($current) {
if ($trim !== '') {
if (preg_match('/^\((.+)\)$/', $trim, $statusMatch)) {
$current['status'] = $statusMatch[1];
} else {
$current['recipients'][] = $trim;
}
}
}
}
if ($current) {
$queue[] = $current;
}
return ['success' => true, 'queue' => $queue];
}
function mailQueueRetry(array $params): array
{
$id = $params['id'] ?? '';
if (empty($id) || !preg_match('/^[A-Za-z0-9]{5,}$/', $id)) {
return ['success' => false, 'error' => 'Invalid queue ID'];
}
exec('postqueue -i ' . escapeshellarg($id) . ' 2>&1', $output, $code);
if ($code !== 0) {
return ['success' => false, 'error' => implode("\n", $output)];
}
return ['success' => true];
}
function mailQueueDelete(array $params): array
{
$id = $params['id'] ?? '';
if (empty($id) || !preg_match('/^[A-Za-z0-9]{5,}$/', $id)) {
return ['success' => false, 'error' => 'Invalid queue ID'];
}
exec('postsuper -d ' . escapeshellarg($id) . ' 2>&1', $output, $code);
if ($code !== 0) {
return ['success' => false, 'error' => implode("\n", $output)];
}
return ['success' => true];
}
function fail2banLogs(array $params): array
{
$limit = (int) ($params['limit'] ?? 200);
$limit = max(20, min(500, $limit));
$path = '/var/log/fail2ban.log';
if (!file_exists($path)) {
return ['success' => true, 'logs' => []];
}
$output = [];
exec('tail -n ' . $limit . ' ' . escapeshellarg($path) . ' 2>/dev/null', $output);
$logs = [];
foreach ($output as $line) {
if (preg_match('/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}),\d+\s+\w+\s+\[([^\]]+)\]\s+(.+)$/', $line, $matches)) {
$time = $matches[1];
$jail = $matches[2];
$message = $matches[3];
$action = '';
$ip = '';
if (preg_match('/\b(Ban|Unban|Found)\s+([0-9a-fA-F:.]+)/', $message, $actionMatch)) {
$action = $actionMatch[1];
$ip = $actionMatch[2];
}
$logs[] = [
'time' => $time,
'jail' => $jail,
'action' => $action ?: 'Info',
'ip' => $ip,
'message' => $message,
];
}
}
return ['success' => true, 'logs' => $logs];
}
function updatesList(array $params): array
{
$output = [];
exec('apt list --upgradable 2>/dev/null', $output, $code);
if ($code !== 0) {
return ['success' => false, 'error' => 'Failed to list updates'];
}
$packages = [];
foreach ($output as $line) {
if (str_starts_with($line, 'Listing') || trim($line) === '') {
continue;
}
if (preg_match('/^([^\/]+)\/\S+\s+(\S+)\s+\S+(?:\s+\S+)?\s+\[upgradable from: ([^\]]+)\]/', $line, $matches)) {
$packages[] = [
'name' => $matches[1],
'new_version' => $matches[2],
'current_version' => $matches[3],
];
}
}
return ['success' => true, 'packages' => $packages];
}
function updatesRun(array $params): array
{
$output = [];
exec('apt-get update -y 2>&1', $output, $codeUpdate);
if ($codeUpdate !== 0) {
return ['success' => false, 'error' => implode("\n", $output)];
}
$upgradeOutput = [];
exec('DEBIAN_FRONTEND=noninteractive apt-get upgrade -y 2>&1', $upgradeOutput, $codeUpgrade);
if ($codeUpgrade !== 0) {
return ['success' => false, 'error' => implode("\n", $upgradeOutput)];
}
return ['success' => true, 'output' => array_merge($output, $upgradeOutput)];
}