24641 lines
868 KiB
PHP
Executable File
24641 lines
868 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),
|
|
'geo.apply_rules' => geoApplyRules($params),
|
|
'geo.update_database' => geoUpdateDatabase($params),
|
|
'geo.upload_database' => geoUploadDatabase($params),
|
|
'database.persist_tuning' => databasePersistTuning($params),
|
|
'database.get_variables' => databaseGetVariables($params),
|
|
'database.set_global' => databaseSetGlobal($params),
|
|
'server.export_config' => serverExportConfig($params),
|
|
'server.import_config' => serverImportConfig($params),
|
|
'server.get_resolvers' => serverGetResolvers($params),
|
|
'server.set_resolvers' => serverSetResolvers($params),
|
|
'php.install' => phpInstall($params),
|
|
'php.uninstall' => phpUninstall($params),
|
|
'php.set_default' => phpSetDefault($params),
|
|
'php.restart_fpm' => phpRestartFpm($params),
|
|
'php.reload_fpm' => phpReloadFpm($params),
|
|
'php.restart_all_fpm' => phpRestartAllFpm($params),
|
|
'php.reload_all_fpm' => phpReloadAllFpm($params),
|
|
'php.list_versions' => phpListVersions($params),
|
|
'php.install_wp_modules' => phpInstallWordPressModules($params),
|
|
'ssh.generate_key' => sshGenerateKey($params),
|
|
'server.set_hostname' => setHostname($params),
|
|
'server.set_upload_limits' => setUploadLimits($params),
|
|
'server.update_bind' => updateBindConfig($params),
|
|
'server.info' => getServerInfo($params),
|
|
'server.create_zone' => createServerZone($params),
|
|
'nginx.enable_compression' => nginxEnableCompression($params),
|
|
'nginx.get_compression_status' => nginxGetCompressionStatus($params),
|
|
// Email operations
|
|
'email.enable_domain' => emailEnableDomain($params),
|
|
'email.disable_domain' => emailDisableDomain($params),
|
|
'email.generate_dkim' => emailGenerateDkim($params),
|
|
'email.domain_info' => emailGetDomainInfo($params),
|
|
'email.mailbox_create' => emailMailboxCreate($params),
|
|
'email.mailbox_delete' => emailMailboxDelete($params),
|
|
'email.mailbox_change_password' => emailMailboxChangePassword($params),
|
|
'email.mailbox_set_quota' => emailMailboxSetQuota($params),
|
|
'email.mailbox_quota_usage' => emailMailboxGetQuotaUsage($params),
|
|
'email.mailbox_toggle' => emailMailboxToggle($params),
|
|
'email.sync_virtual_users' => emailSyncVirtualUsers($params),
|
|
'email.reload_services' => emailReloadServices($params),
|
|
'email.forwarder_create' => emailForwarderCreate($params),
|
|
'email.forwarder_delete' => emailForwarderDelete($params),
|
|
'email.forwarder_update' => emailForwarderUpdate($params),
|
|
'email.forwarder_toggle' => emailForwarderToggle($params),
|
|
'email.catchall_update' => emailCatchallUpdate($params),
|
|
'email.sync_maps' => emailSyncMaps($params),
|
|
'email.get_logs' => emailGetLogs($params),
|
|
'email.autoresponder_set' => emailAutoresponderSet($params),
|
|
'email.autoresponder_toggle' => emailAutoresponderToggle($params),
|
|
'email.autoresponder_delete' => emailAutoresponderDelete($params),
|
|
'email.hash_password' => emailHashPassword($params),
|
|
// Mail queue operations
|
|
'mail.queue_list' => mailQueueList($params),
|
|
'mail.queue_retry' => mailQueueRetry($params),
|
|
'mail.queue_delete' => mailQueueDelete($params),
|
|
'service.list' => serviceList($params),
|
|
'service.start' => serviceStart($params),
|
|
'service.stop' => serviceStop($params),
|
|
'service.restart' => serviceRestart($params),
|
|
'service.enable' => serviceEnable($params),
|
|
'service.disable' => serviceDisable($params),
|
|
// Server Import operations
|
|
'import.discover' => importDiscover($params),
|
|
'import.start' => importStart($params),
|
|
// SSL Certificate operations
|
|
'ssl.check' => sslCheck($params),
|
|
'ssl.issue' => sslIssue($params),
|
|
'ssl.install' => sslInstall($params),
|
|
'ssl.renew' => sslRenew($params),
|
|
'ssl.generate_self_signed' => sslGenerateSelfSigned($params),
|
|
'ssl.delete' => sslDelete($params),
|
|
// Backup operations
|
|
'backup.create' => backupCreate($params),
|
|
'backup.create_server' => backupCreateServer($params),
|
|
'backup.incremental_direct' => backupServerIncrementalDirect($params),
|
|
'backup.restore' => backupRestore($params),
|
|
'backup.list' => backupList($params),
|
|
'backup.delete' => backupDelete($params),
|
|
'backup.delete_server' => backupDeleteServer($params),
|
|
'backup.verify' => backupVerify($params),
|
|
'backup.get_info' => backupGetInfo($params),
|
|
'backup.upload_remote' => backupUploadRemote($params),
|
|
'backup.download_remote' => backupDownloadRemote($params),
|
|
'backup.list_remote' => backupListRemote($params),
|
|
'backup.delete_remote' => backupDeleteRemote($params),
|
|
'backup.test_destination' => backupTestDestination($params),
|
|
'backup.download_user_archive' => backupDownloadUserArchive($params),
|
|
// cPanel migration operations
|
|
'cpanel.analyze_backup' => cpanelAnalyzeBackup($params),
|
|
'cpanel.restore_backup' => cpanelRestoreBackup($params),
|
|
'cpanel.fix_backup_permissions' => cpanelFixBackupPermissions($params),
|
|
// WHM migration operations
|
|
'whm.download_backup_scp' => whmDownloadBackupScp($params),
|
|
// Jabali system SSH key operations
|
|
'jabali_ssh.get_public_key' => jabaliSshGetPublicKey($params),
|
|
'jabali_ssh.get_private_key' => jabaliSshGetPrivateKey($params),
|
|
'jabali_ssh.ensure_exists' => jabaliSshEnsureExists($params),
|
|
'jabali_ssh.add_to_authorized_keys' => jabaliSshAddToAuthorizedKeys($params),
|
|
// Fail2ban operations
|
|
'fail2ban.status' => fail2banStatus($params),
|
|
'fail2ban.status_light' => fail2banStatusLight($params),
|
|
'fail2ban.install' => fail2banInstall($params),
|
|
'fail2ban.start' => fail2banStart($params),
|
|
'fail2ban.stop' => fail2banStop($params),
|
|
'fail2ban.restart' => fail2banRestart($params),
|
|
'fail2ban.save_settings' => fail2banSaveSettings($params),
|
|
'fail2ban.unban_ip' => fail2banUnbanIp($params),
|
|
'fail2ban.ban_ip' => fail2banBanIp($params),
|
|
'fail2ban.list_jails' => fail2banListJails($params),
|
|
'fail2ban.enable_jail' => fail2banEnableJail($params),
|
|
'fail2ban.disable_jail' => fail2banDisableJail($params),
|
|
'fail2ban.logs' => fail2banLogs($params),
|
|
// ClamAV operations
|
|
'clamav.status' => clamavStatus($params),
|
|
'clamav.status_light' => clamavStatusLight($params),
|
|
'clamav.install' => clamavInstall($params),
|
|
'clamav.start' => clamavStart($params),
|
|
'clamav.stop' => clamavStop($params),
|
|
'clamav.update_signatures' => clamavUpdateSignatures($params),
|
|
'clamav.scan' => clamavScan($params),
|
|
'clamav.realtime_start' => clamavRealtimeStart($params),
|
|
'clamav.realtime_stop' => clamavRealtimeStop($params),
|
|
'clamav.realtime_enable' => clamavRealtimeEnable($params),
|
|
'clamav.realtime_disable' => clamavRealtimeDisable($params),
|
|
'clamav.delete_quarantined' => clamavDeleteQuarantined($params),
|
|
'clamav.clear_threats' => clamavClearThreats($params),
|
|
'clamav.set_light_mode' => clamavSetLightMode($params),
|
|
'clamav.set_full_mode' => clamavSetFullMode($params),
|
|
'clamav.force_update_signatures' => clamavForceUpdateSignatures($params),
|
|
'ssh.get_settings' => sshGetSettings($params),
|
|
'ssh.save_settings' => sshSaveSettings($params),
|
|
// Cron job operations
|
|
'cron.list' => cronList($params),
|
|
'cron.create' => cronCreate($params),
|
|
'cron.delete' => cronDelete($params),
|
|
'cron.toggle' => cronToggle($params),
|
|
'cron.run' => cronRun($params),
|
|
'cron.wp_setup' => cronWordPressSetup($params),
|
|
// Server metrics operations
|
|
'metrics.overview' => metricsOverview($params),
|
|
'metrics.cpu' => metricsCpu($params),
|
|
'metrics.memory' => metricsMemory($params),
|
|
'metrics.disk' => metricsDisk($params),
|
|
'metrics.network' => metricsNetwork($params),
|
|
'metrics.processes' => metricsProcesses($params),
|
|
'metrics.history' => metricsHistory($params),
|
|
'system.kill_process' => systemKillProcess($params),
|
|
// Disk quota operations
|
|
'quota.status' => quotaStatus($params),
|
|
'quota.enable' => quotaEnable($params),
|
|
'quota.set' => quotaSet($params),
|
|
'quota.get' => quotaGet($params),
|
|
'quota.report' => quotaReport($params),
|
|
// IP address management
|
|
'ip.list' => ipList($params),
|
|
'ip.add' => ipAdd($params),
|
|
'ip.remove' => ipRemove($params),
|
|
'ip.info' => ipInfo($params),
|
|
// Security scanner tools
|
|
'scanner.install' => scannerInstall($params),
|
|
'scanner.uninstall' => scannerUninstall($params),
|
|
'scanner.status' => scannerStatus($params),
|
|
'scanner.run_lynis' => scannerRunLynis($params),
|
|
'scanner.run_nikto' => scannerRunNikto($params),
|
|
'scanner.start_lynis' => scannerStartLynis($params),
|
|
'scanner.get_scan_status' => scannerGetScanStatus($params),
|
|
// Log analysis
|
|
'logs.tail' => logsTail($params),
|
|
'logs.goaccess' => logsGoaccess($params),
|
|
// Redis ACL management
|
|
'redis.create_user' => redisCreateUser($params),
|
|
'redis.delete_user' => redisDeleteUser($params),
|
|
'redis.user_exists' => redisUserExists($params),
|
|
'redis.change_password' => redisChangePassword($params),
|
|
'redis.migrate_users' => redisMigrateUsers($params),
|
|
default => ['success' => false, 'error' => "Unknown action: $action"],
|
|
};
|
|
}
|
|
|
|
// ============ USER MANAGEMENT ============
|
|
|
|
function createUser(array $params): array
|
|
{
|
|
$username = $params['username'] ?? '';
|
|
$password = $params['password'] ?? null;
|
|
|
|
logger("Creating user: $username");
|
|
|
|
if (!validateUsername($username)) {
|
|
return ['success' => false, 'error' => 'Invalid username format'];
|
|
}
|
|
|
|
if (isProtectedUser($username)) {
|
|
return ['success' => false, 'error' => 'Cannot create protected system user'];
|
|
}
|
|
|
|
exec("id " . escapeshellarg($username) . " 2>/dev/null", $output, $exitCode);
|
|
if ($exitCode === 0) {
|
|
return ['success' => false, 'error' => 'User already exists'];
|
|
}
|
|
|
|
$homeDir = "/home/$username";
|
|
|
|
// Create user with nologin shell (SFTP-only by default)
|
|
$cmd = sprintf('useradd -m -d %s -s /usr/sbin/nologin %s 2>&1',
|
|
escapeshellarg($homeDir),
|
|
escapeshellarg($username)
|
|
);
|
|
exec($cmd, $output, $exitCode);
|
|
|
|
if ($exitCode !== 0) {
|
|
return ['success' => false, 'error' => 'Failed to create user: ' . implode("\n", $output)];
|
|
}
|
|
|
|
if ($password) {
|
|
$cmd = sprintf('echo %s:%s | chpasswd 2>&1',
|
|
escapeshellarg($username),
|
|
escapeshellarg($password)
|
|
);
|
|
exec($cmd);
|
|
}
|
|
|
|
// Remove symlinks that cause issues
|
|
@unlink("$homeDir/.face.icon");
|
|
@unlink("$homeDir/.face");
|
|
|
|
// Create standard directories (NO ACLs for www-data!)
|
|
$dirs = ['domains', 'logs', 'tmp', 'ssl', 'backups'];
|
|
foreach ($dirs as $dir) {
|
|
$path = "$homeDir/$dir";
|
|
if (!is_dir($path)) {
|
|
mkdir($path, 0755, true);
|
|
}
|
|
chown($path, $username);
|
|
chgrp($path, $username);
|
|
}
|
|
|
|
// Set up for secure SFTP chroot
|
|
exec("usermod -aG sftpusers " . escapeshellarg($username));
|
|
|
|
// Chroot requires root ownership of home directory
|
|
// Use user's group with 750 for complete isolation between users
|
|
chown($homeDir, "root");
|
|
chgrp($homeDir, $username); // User's own group - only this user can access
|
|
chmod($homeDir, 0750); // root=rwx, user's group=r-x, others=none
|
|
|
|
// Create PHP-FPM pool for the user (so it's ready when they create domains)
|
|
// Don't reload FPM here - caller is responsible for reloading after all operations complete
|
|
$fpmResult = createFpmPool($username, false);
|
|
$fpmPoolCreated = (bool) ($fpmResult['pool_created'] ?? false);
|
|
$fpmReloadRequired = $fpmPoolCreated && (bool) ($fpmResult['needs_reload'] ?? false);
|
|
|
|
// Create Redis ACL user for isolated caching
|
|
$redisPassword = bin2hex(random_bytes(16)); // 32 char password
|
|
$redisResult = redisCreateUser(['username' => $username, 'password' => $redisPassword]);
|
|
|
|
if ($redisResult['success']) {
|
|
// Store Redis credentials in user's home directory
|
|
$redisCredFile = "{$homeDir}/.redis_credentials";
|
|
$credContent = "REDIS_USER=jabali_{$username}\n" .
|
|
"REDIS_PASS={$redisPassword}\n" .
|
|
"REDIS_PREFIX={$username}:\n";
|
|
file_put_contents($redisCredFile, $credContent);
|
|
chmod($redisCredFile, 0600);
|
|
chown($redisCredFile, $username);
|
|
chgrp($redisCredFile, $username);
|
|
logger("Created Redis ACL user for $username");
|
|
} else {
|
|
logger("Warning: Failed to create Redis user for $username: " . ($redisResult['error'] ?? 'Unknown error'));
|
|
}
|
|
|
|
logger("Created user $username with home directory $homeDir");
|
|
|
|
return [
|
|
'success' => true,
|
|
'message' => "User $username created successfully",
|
|
'home_directory' => $homeDir,
|
|
'redis_user' => $redisResult['success'] ? "jabali_{$username}" : null,
|
|
'fpm_pool_created' => $fpmPoolCreated,
|
|
'fpm_reload_required' => $fpmReloadRequired,
|
|
];
|
|
}
|
|
|
|
function deleteUser(array $params): array
|
|
{
|
|
$username = $params['username'] ?? '';
|
|
$removeHome = $params['remove_home'] ?? false;
|
|
$domains = $params['domains'] ?? []; // List of user's domains to clean up
|
|
|
|
if (!validateUsername($username)) {
|
|
return ['success' => false, 'error' => 'Invalid username format'];
|
|
}
|
|
|
|
if (isProtectedUser($username)) {
|
|
return ['success' => false, 'error' => 'Cannot delete protected system user'];
|
|
}
|
|
|
|
// Check if user exists
|
|
exec("id " . escapeshellarg($username) . " 2>/dev/null", $idOutput, $idExit);
|
|
if ($idExit !== 0) {
|
|
return ['success' => false, 'error' => 'User does not exist'];
|
|
}
|
|
|
|
$homeDir = "/home/$username";
|
|
|
|
// Get domains from .domains file if not provided
|
|
if (empty($domains)) {
|
|
$domainsFile = "$homeDir/.domains";
|
|
if (file_exists($domainsFile)) {
|
|
$domainsData = json_decode(file_get_contents($domainsFile), true) ?: [];
|
|
$domains = array_keys($domainsData);
|
|
}
|
|
}
|
|
|
|
// Clean up domain-related files for each domain
|
|
foreach ($domains as $domain) {
|
|
if (!validateDomain($domain)) {
|
|
continue;
|
|
}
|
|
|
|
// Remove nginx vhost configs (with .conf extension)
|
|
$nginxAvailable = "/etc/nginx/sites-available/{$domain}.conf";
|
|
$nginxEnabled = "/etc/nginx/sites-enabled/{$domain}.conf";
|
|
// Also try without .conf for backwards compatibility
|
|
$nginxAvailableOld = "/etc/nginx/sites-available/$domain";
|
|
$nginxEnabledOld = "/etc/nginx/sites-enabled/$domain";
|
|
|
|
foreach ([$nginxEnabled, $nginxEnabledOld] as $file) {
|
|
if (file_exists($file) || is_link($file)) {
|
|
@unlink($file);
|
|
logger("Removed nginx symlink: $file");
|
|
}
|
|
}
|
|
foreach ([$nginxAvailable, $nginxAvailableOld] as $file) {
|
|
if (file_exists($file)) {
|
|
@unlink($file);
|
|
logger("Removed nginx config: $file");
|
|
}
|
|
}
|
|
|
|
// Remove DNS zone file
|
|
$zoneFile = "/etc/bind/zones/db.$domain";
|
|
if (file_exists($zoneFile)) {
|
|
@unlink($zoneFile);
|
|
logger("Removed DNS zone: $zoneFile");
|
|
|
|
// Remove from named.conf.local
|
|
$namedConf = '/etc/bind/named.conf.local';
|
|
if (file_exists($namedConf)) {
|
|
$content = file_get_contents($namedConf);
|
|
// Remove zone block for this domain (use [\s\S]*?\n\} to match nested braces)
|
|
$pattern = '/\n?zone\s+"' . preg_quote($domain, '/') . '"\s*\{[\s\S]*?\n\};\n?/';
|
|
$newContent = preg_replace($pattern, "\n", $content);
|
|
if ($newContent !== $content) {
|
|
file_put_contents($namedConf, $newContent);
|
|
logger("Removed zone from named.conf.local: $domain");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove mail directories (both in home and /var/vmail)
|
|
$mailDir = "$homeDir/mail/$domain";
|
|
if (is_dir($mailDir)) {
|
|
exec("rm -rf " . escapeshellarg($mailDir));
|
|
logger("Removed mail directory: $mailDir");
|
|
}
|
|
$vmailDir = "/var/vmail/$domain";
|
|
if (is_dir($vmailDir)) {
|
|
exec("rm -rf " . escapeshellarg($vmailDir));
|
|
logger("Removed vmail directory: $vmailDir");
|
|
}
|
|
|
|
// Remove from Postfix virtual_mailbox_domains
|
|
$vdomainsFile = POSTFIX_VIRTUAL_DOMAINS;
|
|
if (file_exists($vdomainsFile)) {
|
|
$content = file_get_contents($vdomainsFile);
|
|
$lines = explode("\n", $content);
|
|
$lines = array_filter($lines, fn($line) => trim($line) !== $domain);
|
|
file_put_contents($vdomainsFile, implode("\n", $lines));
|
|
}
|
|
|
|
// Remove mailboxes from Postfix virtual_mailbox_maps
|
|
$vmailboxFile = POSTFIX_VIRTUAL_MAILBOXES;
|
|
if (file_exists($vmailboxFile)) {
|
|
$content = file_get_contents($vmailboxFile);
|
|
$lines = explode("\n", $content);
|
|
$lines = array_filter($lines, fn($line) => !str_contains($line, "@$domain"));
|
|
file_put_contents($vmailboxFile, implode("\n", $lines));
|
|
}
|
|
|
|
// Remove from Postfix virtual_alias_maps
|
|
$valiasFile = POSTFIX_VIRTUAL_ALIASES;
|
|
if (file_exists($valiasFile)) {
|
|
$content = file_get_contents($valiasFile);
|
|
$lines = explode("\n", $content);
|
|
$lines = array_filter($lines, fn($line) => !str_contains($line, "@$domain"));
|
|
file_put_contents($valiasFile, implode("\n", $lines));
|
|
}
|
|
|
|
// Remove SSL certificates (live, archive, and renewal)
|
|
$certPath = "/etc/letsencrypt/live/$domain";
|
|
$certArchive = "/etc/letsencrypt/archive/$domain";
|
|
$certRenewal = "/etc/letsencrypt/renewal/$domain.conf";
|
|
if (is_dir($certPath)) {
|
|
exec("rm -rf " . escapeshellarg($certPath));
|
|
logger("Removed SSL certificate: $certPath");
|
|
}
|
|
if (is_dir($certArchive)) {
|
|
exec("rm -rf " . escapeshellarg($certArchive));
|
|
logger("Removed SSL archive: $certArchive");
|
|
}
|
|
if (file_exists($certRenewal)) {
|
|
@unlink($certRenewal);
|
|
logger("Removed SSL renewal config: $certRenewal");
|
|
}
|
|
}
|
|
|
|
// Delete MySQL databases and users belonging to this user
|
|
$dbPrefix = $username . '_';
|
|
$mysqli = getMysqlConnection();
|
|
if ($mysqli) {
|
|
// Get all databases belonging to this user
|
|
$result = $mysqli->query("SHOW DATABASES LIKE '{$mysqli->real_escape_string($dbPrefix)}%'");
|
|
if ($result) {
|
|
while ($row = $result->fetch_row()) {
|
|
$dbName = $row[0];
|
|
// Double-check it starts with username_
|
|
if (strpos($dbName, $dbPrefix) === 0) {
|
|
$mysqli->query("DROP DATABASE IF EXISTS `{$mysqli->real_escape_string($dbName)}`");
|
|
logger("Deleted MySQL database: $dbName");
|
|
}
|
|
}
|
|
$result->free();
|
|
}
|
|
|
|
// Get all MySQL users belonging to this user
|
|
$result = $mysqli->query("SELECT User, Host FROM mysql.user WHERE User LIKE '{$mysqli->real_escape_string($dbPrefix)}%'");
|
|
if ($result) {
|
|
while ($row = $result->fetch_assoc()) {
|
|
$dbUser = $row['User'];
|
|
$dbHost = $row['Host'];
|
|
// Double-check it starts with username_
|
|
if (strpos($dbUser, $dbPrefix) === 0) {
|
|
$mysqli->query("DROP USER IF EXISTS '{$mysqli->real_escape_string($dbUser)}'@'{$mysqli->real_escape_string($dbHost)}'");
|
|
logger("Deleted MySQL user: $dbUser@$dbHost");
|
|
}
|
|
}
|
|
$result->free();
|
|
}
|
|
$mysqli->query("FLUSH PRIVILEGES");
|
|
$mysqli->close();
|
|
}
|
|
|
|
// Remove PHP-FPM pool config
|
|
foreach (glob("/etc/php/*/fpm/pool.d/$username.conf") as $poolConf) {
|
|
@unlink($poolConf);
|
|
logger("Removed PHP-FPM pool: $poolConf");
|
|
}
|
|
|
|
// Delete user with --force to ignore warnings about mail spool
|
|
// Don't use -r since home directory is owned by root for chroot
|
|
$cmd = sprintf('userdel --force %s 2>&1', escapeshellarg($username));
|
|
$userdelOutput = [];
|
|
exec($cmd, $userdelOutput, $userdelExit);
|
|
|
|
// Verify user was actually deleted (userdel may return non-zero for warnings)
|
|
exec("id " . escapeshellarg($username) . " 2>/dev/null", $checkOutput, $checkExit);
|
|
if ($checkExit === 0) {
|
|
// User still exists - deletion actually failed
|
|
return ['success' => false, 'error' => 'Failed to delete user: ' . implode("\n", $userdelOutput)];
|
|
}
|
|
|
|
// Delete Redis ACL user (and all their cached keys)
|
|
$redisResult = redisDeleteUser(['username' => $username]);
|
|
if (!$redisResult['success']) {
|
|
logger("Warning: Failed to delete Redis user for $username: " . ($redisResult['error'] ?? 'Unknown error'));
|
|
} else {
|
|
logger("Deleted Redis ACL user for $username");
|
|
}
|
|
|
|
// Manually remove home directory if requested (since it's owned by root)
|
|
if ($removeHome && is_dir($homeDir)) {
|
|
exec(sprintf('rm -rf %s 2>&1', escapeshellarg($homeDir)), $rmOutput, $rmExit);
|
|
if ($rmExit !== 0) {
|
|
logger("Warning: Failed to remove home directory for $username");
|
|
}
|
|
}
|
|
|
|
// Reload services
|
|
exec('postmap ' . escapeshellarg(POSTFIX_VIRTUAL_DOMAINS) . ' 2>/dev/null');
|
|
exec('postmap ' . escapeshellarg(POSTFIX_VIRTUAL_MAILBOXES) . ' 2>/dev/null');
|
|
exec('postmap ' . escapeshellarg(POSTFIX_VIRTUAL_ALIASES) . ' 2>/dev/null');
|
|
exec('systemctl reload nginx 2>/dev/null');
|
|
exec('rndc reload 2>/dev/null');
|
|
exec('systemctl reload php*-fpm 2>/dev/null');
|
|
|
|
logger("Deleted user $username" . ($removeHome ? " with home directory" : "") . " and cleaned up " . count($domains) . " domain(s)");
|
|
|
|
return ['success' => true, 'message' => "User $username deleted successfully"];
|
|
}
|
|
|
|
function setUserPassword(array $params): array
|
|
{
|
|
$username = $params['username'] ?? '';
|
|
$password = $params['password'] ?? '';
|
|
|
|
if (!validateUsername($username) || empty($password)) {
|
|
return ['success' => false, 'error' => 'Invalid username or password'];
|
|
}
|
|
|
|
exec("id " . escapeshellarg($username) . " 2>/dev/null", $output, $exitCode);
|
|
if ($exitCode !== 0) {
|
|
return ['success' => false, 'error' => 'User does not exist'];
|
|
}
|
|
|
|
$cmd = sprintf('echo %s:%s | chpasswd 2>&1', escapeshellarg($username), escapeshellarg($password));
|
|
exec($cmd, $output, $exitCode);
|
|
|
|
return $exitCode === 0
|
|
? ['success' => true, 'message' => 'Password updated']
|
|
: ['success' => false, 'error' => 'Failed to set password'];
|
|
}
|
|
|
|
function userExists(array $params): array
|
|
{
|
|
$username = $params['username'] ?? '';
|
|
|
|
if (!validateUsername($username)) {
|
|
return ['success' => true, 'exists' => false];
|
|
}
|
|
|
|
exec("id " . escapeshellarg($username) . " 2>/dev/null", $output, $exitCode);
|
|
|
|
return ['success' => true, 'exists' => $exitCode === 0];
|
|
}
|
|
|
|
|
|
// ============ PHP-FPM POOL MANAGEMENT ============
|
|
|
|
function getFpmSocketPath(string $username): string
|
|
{
|
|
$phpVersion = '8.4';
|
|
return "/run/php/php{$phpVersion}-fpm-{$username}.sock";
|
|
}
|
|
|
|
function generateNginxVhost(string $domain, string $publicHtml, string $logs, string $fpmSocket): string
|
|
{
|
|
$config = <<<'NGINXCONF'
|
|
server {
|
|
listen 80;
|
|
listen [::]:80;
|
|
server_name DOMAIN_PLACEHOLDER www.DOMAIN_PLACEHOLDER;
|
|
root DOCROOT_PLACEHOLDER;
|
|
|
|
include /etc/nginx/jabali/includes/waf.conf;
|
|
include /etc/nginx/jabali/includes/geo.conf;
|
|
|
|
# Allow ACME challenge for SSL certificate issuance/renewal
|
|
location /.well-known/acme-challenge/ {
|
|
try_files $uri =404;
|
|
}
|
|
|
|
# Redirect all other HTTP traffic to HTTPS
|
|
location / {
|
|
return 301 https://$host$request_uri;
|
|
}
|
|
}
|
|
|
|
server {
|
|
listen 443 ssl;
|
|
listen [::]:443 ssl;
|
|
http2 on;
|
|
server_name DOMAIN_PLACEHOLDER www.DOMAIN_PLACEHOLDER;
|
|
root DOCROOT_PLACEHOLDER;
|
|
|
|
include /etc/nginx/jabali/includes/waf.conf;
|
|
include /etc/nginx/jabali/includes/geo.conf;
|
|
|
|
# Symlink protection - prevent following symlinks outside document root
|
|
disable_symlinks if_not_owner from=$document_root;
|
|
|
|
ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem;
|
|
ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key;
|
|
|
|
index index.php index.html;
|
|
client_max_body_size 50M;
|
|
|
|
location / {
|
|
try_files $uri $uri/ /index.php?$query_string;
|
|
}
|
|
|
|
location ~ \.php$ {
|
|
fastcgi_pass unix:SOCKET_PLACEHOLDER;
|
|
fastcgi_next_upstream error timeout invalid_header http_500 http_503;
|
|
fastcgi_next_upstream_tries 2;
|
|
fastcgi_next_upstream_timeout 5s;
|
|
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
|
include fastcgi_params;
|
|
}
|
|
|
|
# GoAccess statistics reports
|
|
location /stats/ {
|
|
alias STATS_PLACEHOLDER/;
|
|
index report.html;
|
|
}
|
|
|
|
location ~ /\.(?!well-known).* {
|
|
deny all;
|
|
}
|
|
|
|
access_log LOGS_PLACEHOLDER/access.log combined;
|
|
error_log LOGS_PLACEHOLDER/error.log;
|
|
}
|
|
NGINXCONF;
|
|
|
|
// Stats directory lives under the document root for web access
|
|
$stats = rtrim($publicHtml, '/') . '/stats';
|
|
|
|
$config = str_replace('DOMAIN_PLACEHOLDER', $domain, $config);
|
|
$config = str_replace('DOCROOT_PLACEHOLDER', $publicHtml, $config);
|
|
$config = str_replace('SOCKET_PLACEHOLDER', $fpmSocket, $config);
|
|
$config = str_replace('LOGS_PLACEHOLDER', $logs, $config);
|
|
$config = str_replace('STATS_PLACEHOLDER', $stats, $config);
|
|
|
|
return $config;
|
|
}
|
|
function createFpmPool(string $username, bool $reload = true): array
|
|
{
|
|
$phpVersion = '8.4';
|
|
$poolFile = "/etc/php/{$phpVersion}/fpm/pool.d/{$username}.conf";
|
|
|
|
// Check if pool already exists
|
|
if (file_exists($poolFile)) {
|
|
return [
|
|
'success' => true,
|
|
'message' => 'Pool already exists',
|
|
'socket' => getFpmSocketPath($username),
|
|
'pool_created' => false,
|
|
];
|
|
}
|
|
|
|
$userInfo = posix_getpwnam($username);
|
|
if (!$userInfo) {
|
|
return ['success' => false, 'error' => 'User not found'];
|
|
}
|
|
|
|
$userHome = $userInfo['dir'];
|
|
|
|
// Create required directories
|
|
$dirs = ["{$userHome}/tmp", "{$userHome}/logs"];
|
|
foreach ($dirs as $dir) {
|
|
if (!is_dir($dir)) {
|
|
mkdir($dir, 0755, true);
|
|
chown($dir, $username);
|
|
chgrp($dir, $username);
|
|
}
|
|
}
|
|
|
|
// Default PHP settings - can be overridden via admin settings
|
|
$memoryLimit = '512M';
|
|
$uploadMaxFilesize = '64M';
|
|
$postMaxSize = '64M';
|
|
$maxExecutionTime = '300';
|
|
$maxInputTime = '300';
|
|
$maxInputVars = '3000';
|
|
|
|
// Resource limits - configurable via admin settings
|
|
$pmMaxChildren = (int)($params['pm_max_children'] ?? 5);
|
|
$pmStartServers = max(1, (int)($pmMaxChildren / 5));
|
|
$pmMinSpareServers = max(1, (int)($pmMaxChildren / 5));
|
|
$pmMaxSpareServers = max(2, (int)($pmMaxChildren / 2));
|
|
$pmMaxRequests = (int)($params['pm_max_requests'] ?? 200);
|
|
$rlimitFiles = (int)($params['rlimit_files'] ?? 1024);
|
|
$processPriority = (int)($params['process_priority'] ?? 0);
|
|
$requestTerminateTimeout = (int)($params['request_terminate_timeout'] ?? 300);
|
|
|
|
$poolConfig = "[{$username}]
|
|
user = {$username}
|
|
group = {$username}
|
|
|
|
listen = /run/php/php{$phpVersion}-fpm-{$username}.sock
|
|
listen.owner = {$username}
|
|
listen.group = www-data
|
|
listen.mode = 0660
|
|
|
|
; Process manager settings
|
|
pm = dynamic
|
|
pm.max_children = {$pmMaxChildren}
|
|
pm.start_servers = {$pmStartServers}
|
|
pm.min_spare_servers = {$pmMinSpareServers}
|
|
pm.max_spare_servers = {$pmMaxSpareServers}
|
|
pm.max_requests = {$pmMaxRequests}
|
|
|
|
; Resource limits
|
|
rlimit_files = {$rlimitFiles}
|
|
process.priority = {$processPriority}
|
|
request_terminate_timeout = {$requestTerminateTimeout}s
|
|
; slowlog disabled by default to avoid startup failures when logs dir missing
|
|
; request_slowlog_timeout = 30s
|
|
; slowlog = {$userHome}/logs/php-slow.log
|
|
|
|
chdir = /
|
|
|
|
; PHP Settings (defaults)
|
|
php_admin_value[memory_limit] = {$memoryLimit}
|
|
php_admin_value[upload_max_filesize] = {$uploadMaxFilesize}
|
|
php_admin_value[post_max_size] = {$postMaxSize}
|
|
php_admin_value[max_execution_time] = {$maxExecutionTime}
|
|
php_admin_value[max_input_time] = {$maxInputTime}
|
|
php_admin_value[max_input_vars] = {$maxInputVars}
|
|
|
|
; Security
|
|
php_admin_value[open_basedir] = {$userHome}/:/tmp/:/usr/share/php/
|
|
php_admin_value[upload_tmp_dir] = {$userHome}/tmp
|
|
php_admin_value[session.save_path] = {$userHome}/tmp
|
|
php_admin_value[sys_temp_dir] = {$userHome}/tmp
|
|
php_admin_value[disable_functions] = symlink,link,exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec
|
|
|
|
; Logging
|
|
php_admin_flag[log_errors] = on
|
|
php_admin_value[error_log] = {$userHome}/logs/php-error.log
|
|
|
|
security.limit_extensions = .php
|
|
";
|
|
if (file_put_contents($poolFile, $poolConfig) === false) {
|
|
return [
|
|
'success' => false,
|
|
'error' => 'Failed to create pool configuration',
|
|
'pool_created' => false,
|
|
];
|
|
}
|
|
|
|
// Reload PHP-FPM if requested (default behavior for normal operations)
|
|
// Pass reload=false during migrations to avoid unnecessary reloads during batches
|
|
if ($reload) {
|
|
exec("systemctl reload php{$phpVersion}-fpm 2>&1", $output, $code);
|
|
if ($code !== 0) {
|
|
logger("Warning: PHP-FPM reload failed: " . implode("\n", $output));
|
|
}
|
|
}
|
|
|
|
return [
|
|
'success' => true,
|
|
'socket' => getFpmSocketPath($username),
|
|
'needs_reload' => !$reload,
|
|
'pool_created' => true,
|
|
];
|
|
}
|
|
|
|
function deleteFpmPool(string $username): array
|
|
{
|
|
$phpVersion = '8.4';
|
|
$poolFile = "/etc/php/{$phpVersion}/fpm/pool.d/{$username}.conf";
|
|
|
|
if (file_exists($poolFile)) {
|
|
unlink($poolFile);
|
|
exec("(sleep 1 && systemctl reload php{$phpVersion}-fpm) > /dev/null 2>&1 &");
|
|
}
|
|
|
|
return ['success' => true];
|
|
}
|
|
|
|
/**
|
|
* Update FPM pool limits for a specific user
|
|
*/
|
|
function phpUpdatePoolLimits(array $params): array
|
|
{
|
|
$username = $params['username'] ?? '';
|
|
$phpVersion = '8.4';
|
|
|
|
if (!validateUsername($username)) {
|
|
return ['success' => false, 'error' => 'Invalid username'];
|
|
}
|
|
|
|
$poolFile = "/etc/php/{$phpVersion}/fpm/pool.d/{$username}.conf";
|
|
if (!file_exists($poolFile)) {
|
|
return ['success' => false, 'error' => 'Pool configuration not found'];
|
|
}
|
|
|
|
$userHome = "/home/{$username}";
|
|
|
|
// Ensure logs directory exists (for error logs)
|
|
$logsDir = "{$userHome}/logs";
|
|
if (!is_dir($logsDir)) {
|
|
mkdir($logsDir, 0755, true);
|
|
chown($logsDir, $username);
|
|
chgrp($logsDir, $username);
|
|
}
|
|
|
|
// Get limits from params with defaults
|
|
$pmMaxChildren = (int)($params['pm_max_children'] ?? 5);
|
|
$pmStartServers = max(1, (int)($pmMaxChildren / 5));
|
|
$pmMinSpareServers = max(1, (int)($pmMaxChildren / 5));
|
|
$pmMaxSpareServers = max(2, (int)($pmMaxChildren / 2));
|
|
$pmMaxRequests = (int)($params['pm_max_requests'] ?? 200);
|
|
$rlimitFiles = (int)($params['rlimit_files'] ?? 1024);
|
|
$processPriority = (int)($params['process_priority'] ?? 0);
|
|
$requestTerminateTimeout = (int)($params['request_terminate_timeout'] ?? 300);
|
|
|
|
// PHP settings
|
|
$memoryLimit = $params['memory_limit'] ?? '512M';
|
|
$uploadMaxFilesize = $params['upload_max_filesize'] ?? '64M';
|
|
$postMaxSize = $params['post_max_size'] ?? '64M';
|
|
$maxExecutionTime = $params['max_execution_time'] ?? '300';
|
|
$maxInputTime = $params['max_input_time'] ?? '300';
|
|
$maxInputVars = $params['max_input_vars'] ?? '3000';
|
|
|
|
$poolConfig = "[{$username}]
|
|
user = {$username}
|
|
group = {$username}
|
|
|
|
listen = /run/php/php{$phpVersion}-fpm-{$username}.sock
|
|
listen.owner = {$username}
|
|
listen.group = www-data
|
|
listen.mode = 0660
|
|
|
|
; Process manager settings
|
|
pm = dynamic
|
|
pm.max_children = {$pmMaxChildren}
|
|
pm.start_servers = {$pmStartServers}
|
|
pm.min_spare_servers = {$pmMinSpareServers}
|
|
pm.max_spare_servers = {$pmMaxSpareServers}
|
|
pm.max_requests = {$pmMaxRequests}
|
|
|
|
; Resource limits
|
|
rlimit_files = {$rlimitFiles}
|
|
process.priority = {$processPriority}
|
|
request_terminate_timeout = {$requestTerminateTimeout}s
|
|
; slowlog disabled by default to avoid startup failures when logs dir missing
|
|
; request_slowlog_timeout = 30s
|
|
; slowlog = {$userHome}/logs/php-slow.log
|
|
|
|
chdir = /
|
|
|
|
; PHP Settings
|
|
php_admin_value[memory_limit] = {$memoryLimit}
|
|
php_admin_value[upload_max_filesize] = {$uploadMaxFilesize}
|
|
php_admin_value[post_max_size] = {$postMaxSize}
|
|
php_admin_value[max_execution_time] = {$maxExecutionTime}
|
|
php_admin_value[max_input_time] = {$maxInputTime}
|
|
php_admin_value[max_input_vars] = {$maxInputVars}
|
|
|
|
; Security
|
|
php_admin_value[open_basedir] = {$userHome}/:/tmp/:/usr/share/php/
|
|
php_admin_value[upload_tmp_dir] = {$userHome}/tmp
|
|
php_admin_value[session.save_path] = {$userHome}/tmp
|
|
php_admin_value[sys_temp_dir] = {$userHome}/tmp
|
|
php_admin_value[disable_functions] = symlink,link,exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec
|
|
|
|
; Logging
|
|
php_admin_flag[log_errors] = on
|
|
php_admin_value[error_log] = {$userHome}/logs/php-error.log
|
|
|
|
security.limit_extensions = .php
|
|
";
|
|
|
|
if (file_put_contents($poolFile, $poolConfig) === false) {
|
|
return ['success' => false, 'error' => 'Failed to update pool configuration'];
|
|
}
|
|
|
|
return ['success' => true];
|
|
}
|
|
|
|
/**
|
|
* Update FPM pool limits for all users
|
|
*/
|
|
function phpUpdateAllPoolLimits(array $params): array
|
|
{
|
|
$phpVersion = '8.4';
|
|
$poolDir = "/etc/php/{$phpVersion}/fpm/pool.d";
|
|
|
|
$pools = glob("{$poolDir}/*.conf");
|
|
$updated = [];
|
|
$errors = [];
|
|
|
|
foreach ($pools as $poolFile) {
|
|
$username = basename($poolFile, '.conf');
|
|
|
|
// Skip www.conf (default pool)
|
|
if ($username === 'www') {
|
|
continue;
|
|
}
|
|
|
|
// Verify user exists
|
|
exec("id " . escapeshellarg($username) . " 2>/dev/null", $output, $exitCode);
|
|
if ($exitCode !== 0) {
|
|
continue;
|
|
}
|
|
|
|
$result = phpUpdatePoolLimits(array_merge($params, ['username' => $username]));
|
|
if ($result['success']) {
|
|
$updated[] = $username;
|
|
} else {
|
|
$errors[$username] = $result['error'];
|
|
}
|
|
}
|
|
|
|
// Reload PHP-FPM after all updates
|
|
exec("(sleep 2 && systemctl reload php{$phpVersion}-fpm) > /dev/null 2>&1 &");
|
|
|
|
return [
|
|
'success' => true,
|
|
'updated' => $updated,
|
|
'errors' => $errors,
|
|
];
|
|
}
|
|
|
|
|
|
// ============ DOMAIN MANAGEMENT ============
|
|
|
|
function createDomain(array $params): array
|
|
{
|
|
$username = $params['username'] ?? '';
|
|
$domain = $params['domain'] ?? '';
|
|
|
|
if (!validateUsername($username) || !validateDomain($domain)) {
|
|
return ['success' => false, 'error' => 'Invalid username or domain format'];
|
|
}
|
|
|
|
exec("id " . escapeshellarg($username) . " 2>/dev/null", $output, $exitCode);
|
|
if ($exitCode !== 0) {
|
|
return ['success' => false, 'error' => 'User does not exist'];
|
|
}
|
|
|
|
$homeDir = "/home/$username";
|
|
$domainDir = "$homeDir/domains/$domain";
|
|
$publicDir = "$domainDir/public_html";
|
|
|
|
if (is_dir($domainDir)) {
|
|
return ['success' => false, 'error' => 'Domain directory already exists'];
|
|
}
|
|
|
|
if (!mkdir($publicDir, 0755, true)) {
|
|
return ['success' => false, 'error' => 'Failed to create domain directory'];
|
|
}
|
|
|
|
// Set ownership to user (NO www-data access)
|
|
exec(sprintf('chown -R %s:%s %s', escapeshellarg($username), escapeshellarg($username), escapeshellarg($domainDir)));
|
|
|
|
// Create default index.html
|
|
$indexContent = "<!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);
|
|
}
|
|
|
|
$baseConfig = findWafBaseConfig();
|
|
$shouldDisableWaf = $baseConfig === null;
|
|
|
|
if (!file_exists(JABALI_WAF_INCLUDE)) {
|
|
$content = "# Managed by Jabali\n";
|
|
if ($shouldDisableWaf) {
|
|
$content .= "modsecurity off;\n";
|
|
}
|
|
file_put_contents(JABALI_WAF_INCLUDE, $content);
|
|
} elseif ($shouldDisableWaf) {
|
|
$current = file_get_contents(JABALI_WAF_INCLUDE);
|
|
if ($current === false || strpos($current, 'modsecurity_rules_file') !== false || strpos($current, 'modsecurity on;') !== false) {
|
|
file_put_contents(JABALI_WAF_INCLUDE, "# Managed by Jabali\nmodsecurity off;\n");
|
|
}
|
|
}
|
|
|
|
if (!file_exists(JABALI_GEO_INCLUDE)) {
|
|
file_put_contents(JABALI_GEO_INCLUDE, "# Managed by Jabali\n");
|
|
}
|
|
}
|
|
|
|
function ensureNginxServerIncludes(array $includeLines): array
|
|
{
|
|
$files = glob('/etc/nginx/sites-enabled/*.conf') ?: [];
|
|
$updated = 0;
|
|
|
|
foreach ($files as $file) {
|
|
$content = file_get_contents($file);
|
|
if ($content === false) {
|
|
continue;
|
|
}
|
|
|
|
$original = $content;
|
|
foreach ($includeLines as $line) {
|
|
if (strpos($content, $line) !== false) {
|
|
continue;
|
|
}
|
|
|
|
$content = preg_replace('/(server_name[^\n]*\n)/', "$1 {$line}\n", $content);
|
|
}
|
|
|
|
if ($content !== $original) {
|
|
file_put_contents($file, $content);
|
|
$updated++;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'files' => count($files),
|
|
'updated' => $updated,
|
|
];
|
|
}
|
|
|
|
function nginxTestAndReload(): array
|
|
{
|
|
exec('nginx -t 2>&1', $testOutput, $testCode);
|
|
if ($testCode !== 0) {
|
|
return ['success' => false, 'error' => 'nginx configuration test failed: ' . implode("\n", $testOutput)];
|
|
}
|
|
|
|
exec('systemctl reload nginx 2>&1', $output, $exitCode);
|
|
if ($exitCode !== 0) {
|
|
return ['success' => false, 'error' => 'Failed to reload nginx'];
|
|
}
|
|
|
|
return ['success' => true];
|
|
}
|
|
|
|
function findWafBaseConfig(): ?string
|
|
{
|
|
$paths = [
|
|
'/etc/nginx/modsec/main.conf',
|
|
'/etc/nginx/modsecurity.conf',
|
|
'/etc/modsecurity/modsecurity.conf',
|
|
'/etc/modsecurity/modsecurity.conf-recommended',
|
|
];
|
|
|
|
foreach ($paths as $path) {
|
|
if (file_exists($path) && isWafBaseConfigUsable($path)) {
|
|
return $path;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function isWafBaseConfigUsable(string $path): bool
|
|
{
|
|
if (!is_readable($path)) {
|
|
return false;
|
|
}
|
|
|
|
$content = file_get_contents($path);
|
|
if ($content === false) {
|
|
return false;
|
|
}
|
|
|
|
if (preg_match_all('/^\s*Include\s+("?)([^"\s]+)\1/m', $content, $matches)) {
|
|
foreach ($matches[2] as $includePath) {
|
|
if ($includePath === '/etc/modsecurity/modsecurity.conf' && !file_exists($includePath)) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (preg_match_all('/^\s*SecUnicodeMapFile\s+([^\s]+)\s*/m', $content, $matches)) {
|
|
$baseDir = dirname($path);
|
|
foreach ($matches[1] as $mapPath) {
|
|
$candidates = [];
|
|
if (str_starts_with($mapPath, '/')) {
|
|
$candidates[] = $mapPath;
|
|
} else {
|
|
$candidates[] = $baseDir . '/' . $mapPath;
|
|
$candidates[] = '/etc/modsecurity/' . $mapPath;
|
|
}
|
|
|
|
$found = false;
|
|
foreach ($candidates as $candidate) {
|
|
if (file_exists($candidate)) {
|
|
$found = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!$found) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function wafApplySettings(array $params): array
|
|
{
|
|
$enabled = !empty($params['enabled']);
|
|
$paranoia = (int) ($params['paranoia'] ?? 1);
|
|
$paranoia = max(1, min(4, $paranoia));
|
|
$auditLog = !empty($params['audit_log']);
|
|
|
|
ensureJabaliNginxIncludeFiles();
|
|
|
|
$prevInclude = file_exists(JABALI_WAF_INCLUDE) ? file_get_contents(JABALI_WAF_INCLUDE) : null;
|
|
$prevRules = file_exists(JABALI_WAF_RULES) ? file_get_contents(JABALI_WAF_RULES) : null;
|
|
|
|
if ($enabled) {
|
|
$baseConfig = findWafBaseConfig();
|
|
if (!$baseConfig) {
|
|
file_put_contents(JABALI_WAF_INCLUDE, "# Managed by Jabali\nmodsecurity off;\n");
|
|
return ['success' => false, 'error' => 'ModSecurity base configuration not found'];
|
|
}
|
|
|
|
$rules = [
|
|
'# Managed by Jabali',
|
|
'Include "' . $baseConfig . '"',
|
|
'SecRuleEngine On',
|
|
'SecAuditEngine ' . ($auditLog ? 'On' : 'Off'),
|
|
'SecAuditLog /var/log/nginx/modsec_audit.log',
|
|
'SecAction "id:900000,phase:1,t:none,pass,setvar:tx.paranoia_level=' . $paranoia . '"',
|
|
'SecAction "id:900110,phase:1,t:none,pass,setvar:tx.executing_paranoia_level=' . $paranoia . '"',
|
|
];
|
|
|
|
file_put_contents(JABALI_WAF_RULES, implode("\n", $rules) . "\n");
|
|
|
|
$include = [
|
|
'# Managed by Jabali',
|
|
'modsecurity on;',
|
|
'modsecurity_rules_file ' . JABALI_WAF_RULES . ';',
|
|
];
|
|
|
|
file_put_contents(JABALI_WAF_INCLUDE, implode("\n", $include) . "\n");
|
|
} else {
|
|
file_put_contents(JABALI_WAF_INCLUDE, "# Managed by Jabali\nmodsecurity off;\n");
|
|
}
|
|
|
|
ensureNginxServerIncludes([
|
|
'include ' . JABALI_WAF_INCLUDE . ';',
|
|
]);
|
|
|
|
$reload = nginxTestAndReload();
|
|
if (!($reload['success'] ?? false)) {
|
|
if ($prevInclude === null) {
|
|
@unlink(JABALI_WAF_INCLUDE);
|
|
} else {
|
|
file_put_contents(JABALI_WAF_INCLUDE, $prevInclude);
|
|
}
|
|
|
|
if ($prevRules === null) {
|
|
@unlink(JABALI_WAF_RULES);
|
|
} else {
|
|
file_put_contents(JABALI_WAF_RULES, $prevRules);
|
|
}
|
|
|
|
return $reload;
|
|
}
|
|
|
|
return ['success' => true, 'enabled' => $enabled, 'paranoia' => $paranoia, 'audit_log' => $auditLog];
|
|
}
|
|
|
|
function geoUpdateDatabase(array $params): array
|
|
{
|
|
$accountId = trim((string) ($params['account_id'] ?? ''));
|
|
$licenseKey = trim((string) ($params['license_key'] ?? ''));
|
|
$editionIdsRaw = $params['edition_ids'] ?? 'GeoLite2-Country';
|
|
$useExisting = !empty($params['use_existing']);
|
|
|
|
$toolError = ensureGeoIpUpdateTool();
|
|
if ($toolError !== null) {
|
|
return ['success' => false, 'error' => $toolError];
|
|
}
|
|
|
|
if (!$useExisting && ($accountId === '' || $licenseKey === '')) {
|
|
return ['success' => false, 'error' => 'MaxMind Account ID and License Key are required'];
|
|
}
|
|
|
|
$editionIds = [];
|
|
if (is_array($editionIdsRaw)) {
|
|
$editionIds = $editionIdsRaw;
|
|
} else {
|
|
$editionIds = preg_split('/[,\s]+/', (string) $editionIdsRaw, -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
|
}
|
|
|
|
$editionIds = array_values(array_filter(array_map('trim', $editionIds)));
|
|
if (empty($editionIds)) {
|
|
$editionIds = ['GeoLite2-Country'];
|
|
}
|
|
|
|
$configLines = [
|
|
'# Managed by Jabali',
|
|
'AccountID ' . $accountId,
|
|
'LicenseKey ' . $licenseKey,
|
|
'EditionIDs ' . implode(' ', $editionIds),
|
|
'DatabaseDirectory /usr/share/GeoIP',
|
|
];
|
|
$config = implode("\n", $configLines) . "\n";
|
|
|
|
if (!is_dir('/usr/share/GeoIP')) {
|
|
@mkdir('/usr/share/GeoIP', 0755, true);
|
|
}
|
|
|
|
$configPaths = [
|
|
'/etc/GeoIP.conf',
|
|
'/etc/geoipupdate/GeoIP.conf',
|
|
];
|
|
|
|
foreach ($configPaths as $path) {
|
|
$dir = dirname($path);
|
|
if (!is_dir($dir)) {
|
|
@mkdir($dir, 0755, true);
|
|
}
|
|
|
|
if (!$useExisting) {
|
|
file_put_contents($path, $config);
|
|
@chmod($path, 0600);
|
|
} elseif (!file_exists($path)) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
exec('geoipupdate -v 2>&1', $output, $code);
|
|
$outputText = trim(implode("\n", $output));
|
|
if ($code !== 0) {
|
|
return [
|
|
'success' => false,
|
|
'error' => $outputText !== '' ? $outputText : 'geoipupdate failed',
|
|
];
|
|
}
|
|
|
|
$paths = [];
|
|
foreach ($editionIds as $edition) {
|
|
$paths[] = '/usr/share/GeoIP/' . $edition . '.mmdb';
|
|
$paths[] = '/usr/local/share/GeoIP/' . $edition . '.mmdb';
|
|
}
|
|
|
|
foreach ($paths as $path) {
|
|
if (file_exists($path)) {
|
|
return ['success' => true, 'path' => $path];
|
|
}
|
|
}
|
|
|
|
return ['success' => false, 'error' => 'GeoIP database not found after update'];
|
|
}
|
|
|
|
function geoUploadDatabase(array $params): array
|
|
{
|
|
$edition = trim((string) ($params['edition'] ?? 'GeoLite2-Country'));
|
|
$content = (string) ($params['content'] ?? '');
|
|
|
|
if ($content === '') {
|
|
return ['success' => false, 'error' => 'No database content provided'];
|
|
}
|
|
|
|
if (!preg_match('/^[A-Za-z0-9._-]+$/', $edition)) {
|
|
return ['success' => false, 'error' => 'Invalid edition name'];
|
|
}
|
|
|
|
$decoded = base64_decode($content, true);
|
|
if ($decoded === false) {
|
|
return ['success' => false, 'error' => 'Invalid database content'];
|
|
}
|
|
|
|
$targetDir = '/usr/share/GeoIP';
|
|
if (!is_dir($targetDir)) {
|
|
@mkdir($targetDir, 0755, true);
|
|
}
|
|
|
|
$target = $targetDir . '/' . $edition . '.mmdb';
|
|
if (file_put_contents($target, $decoded) === false) {
|
|
return ['success' => false, 'error' => 'Failed to write GeoIP database'];
|
|
}
|
|
|
|
@chmod($target, 0644);
|
|
|
|
return ['success' => true, 'path' => $target];
|
|
}
|
|
|
|
function ensureGeoIpUpdateTool(): ?string
|
|
{
|
|
if (toolExists('geoipupdate')) {
|
|
return null;
|
|
}
|
|
|
|
$error = installGeoIpUpdateBinary();
|
|
if ($error !== null) {
|
|
return $error;
|
|
}
|
|
|
|
if (!toolExists('geoipupdate')) {
|
|
return 'geoipupdate is not installed';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function installGeoIpUpdateBinary(): ?string
|
|
{
|
|
$arch = php_uname('m');
|
|
$archMap = [
|
|
'x86_64' => 'amd64',
|
|
'amd64' => 'amd64',
|
|
'aarch64' => 'arm64',
|
|
'arm64' => 'arm64',
|
|
];
|
|
$archToken = $archMap[$arch] ?? $arch;
|
|
|
|
$apiUrl = 'https://api.github.com/repos/maxmind/geoipupdate/releases/latest';
|
|
$metadata = @shell_exec('curl -fsSL ' . escapeshellarg($apiUrl) . ' 2>/dev/null');
|
|
if (!$metadata) {
|
|
$metadata = @shell_exec('wget -qO- ' . escapeshellarg($apiUrl) . ' 2>/dev/null');
|
|
}
|
|
|
|
if (!$metadata) {
|
|
return 'Failed to download geoipupdate release metadata';
|
|
}
|
|
|
|
$data = json_decode($metadata, true);
|
|
if (!is_array($data)) {
|
|
return 'Invalid geoipupdate release metadata';
|
|
}
|
|
|
|
$downloadUrl = null;
|
|
foreach (($data['assets'] ?? []) as $asset) {
|
|
$name = strtolower((string) ($asset['name'] ?? ''));
|
|
$url = (string) ($asset['browser_download_url'] ?? '');
|
|
if ($name === '' || $url === '') {
|
|
continue;
|
|
}
|
|
if (strpos($name, 'linux') === false) {
|
|
continue;
|
|
}
|
|
if (strpos($name, $archToken) === false) {
|
|
if (!($archToken === 'amd64' && strpos($name, 'x86_64') !== false)) {
|
|
continue;
|
|
}
|
|
}
|
|
if (!str_ends_with($name, '.tar.gz') && !str_ends_with($name, '.tgz')) {
|
|
continue;
|
|
}
|
|
$downloadUrl = $url;
|
|
break;
|
|
}
|
|
|
|
if (!$downloadUrl) {
|
|
return 'No suitable geoipupdate binary found for ' . $arch;
|
|
}
|
|
|
|
$tmpDir = sys_get_temp_dir() . '/jabali-geoipupdate-' . bin2hex(random_bytes(4));
|
|
@mkdir($tmpDir, 0755, true);
|
|
$archive = $tmpDir . '/geoipupdate.tgz';
|
|
|
|
$downloadCmd = toolExists('curl')
|
|
? 'curl -fsSL ' . escapeshellarg($downloadUrl) . ' -o ' . escapeshellarg($archive)
|
|
: 'wget -qO ' . escapeshellarg($archive) . ' ' . escapeshellarg($downloadUrl);
|
|
|
|
exec($downloadCmd . ' 2>&1', $output, $code);
|
|
if ($code !== 0) {
|
|
return 'Failed to download geoipupdate binary';
|
|
}
|
|
|
|
exec('tar -xzf ' . escapeshellarg($archive) . ' -C ' . escapeshellarg($tmpDir) . ' 2>&1', $output, $code);
|
|
if ($code !== 0) {
|
|
return 'Failed to extract geoipupdate archive';
|
|
}
|
|
|
|
$binary = null;
|
|
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($tmpDir, FilesystemIterator::SKIP_DOTS));
|
|
foreach ($iterator as $file) {
|
|
if ($file->isFile() && $file->getFilename() === 'geoipupdate') {
|
|
$binary = $file->getPathname();
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!$binary) {
|
|
return 'geoipupdate binary not found in archive';
|
|
}
|
|
|
|
exec('install -m 0755 ' . escapeshellarg($binary) . ' /usr/local/bin/geoipupdate 2>&1', $output, $code);
|
|
if ($code !== 0) {
|
|
return 'Failed to install geoipupdate';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function ensureGeoIpModuleEnabled(): ?string
|
|
{
|
|
$modulePaths = [
|
|
'/usr/lib/nginx/modules/ngx_http_geoip2_module.so',
|
|
'/usr/share/nginx/modules/ngx_http_geoip2_module.so',
|
|
];
|
|
|
|
$modulePath = null;
|
|
foreach ($modulePaths as $path) {
|
|
if (file_exists($path)) {
|
|
$modulePath = $path;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!$modulePath) {
|
|
return 'nginx geoip2 module not installed';
|
|
}
|
|
|
|
$modulesEnabledDir = '/etc/nginx/modules-enabled';
|
|
if (!is_dir($modulesEnabledDir)) {
|
|
return 'nginx modules-enabled directory not found';
|
|
}
|
|
|
|
$alreadyEnabled = false;
|
|
foreach (glob($modulesEnabledDir . '/*.conf') ?: [] as $file) {
|
|
$contents = file_get_contents($file);
|
|
if ($contents !== false && strpos($contents, 'geoip2_module') !== false) {
|
|
$alreadyEnabled = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ($alreadyEnabled) {
|
|
return null;
|
|
}
|
|
|
|
$loadLine = 'load_module ' . $modulePath . ';';
|
|
$target = $modulesEnabledDir . '/50-jabali-geoip2.conf';
|
|
file_put_contents($target, $loadLine . "\n");
|
|
|
|
return null;
|
|
}
|
|
|
|
function geoApplyRules(array $params): array
|
|
{
|
|
$rules = $params['rules'] ?? [];
|
|
$activeRules = array_values(array_filter($rules, function ($rule) {
|
|
return !isset($rule['is_active']) || !empty($rule['is_active']);
|
|
}));
|
|
|
|
$allow = array_values(array_filter($activeRules, fn ($rule) => ($rule['action'] ?? '') === 'allow'));
|
|
$block = array_values(array_filter($activeRules, fn ($rule) => ($rule['action'] ?? '') === 'block'));
|
|
|
|
ensureJabaliNginxIncludeFiles();
|
|
|
|
$prevGeoHttp = file_exists(JABALI_GEO_HTTP_CONF) ? file_get_contents(JABALI_GEO_HTTP_CONF) : null;
|
|
$prevGeoInclude = file_exists(JABALI_GEO_INCLUDE) ? file_get_contents(JABALI_GEO_INCLUDE) : null;
|
|
|
|
if (empty($allow) && empty($block)) {
|
|
file_put_contents(JABALI_GEO_HTTP_CONF, "# Managed by Jabali\n# No geo rules enabled\n");
|
|
file_put_contents(JABALI_GEO_INCLUDE, "# Managed by Jabali\n");
|
|
ensureNginxServerIncludes([
|
|
'include ' . JABALI_GEO_INCLUDE . ';',
|
|
]);
|
|
|
|
$reload = nginxTestAndReload();
|
|
if (!($reload['success'] ?? false)) {
|
|
return $reload;
|
|
}
|
|
|
|
return ['success' => true, 'rules' => 0];
|
|
}
|
|
|
|
$mmdbPaths = [
|
|
'/usr/share/GeoIP/GeoLite2-Country.mmdb',
|
|
'/usr/local/share/GeoIP/GeoLite2-Country.mmdb',
|
|
];
|
|
$mmdb = null;
|
|
foreach ($mmdbPaths as $path) {
|
|
if (file_exists($path)) {
|
|
$mmdb = $path;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!$mmdb) {
|
|
$update = geoUpdateDatabase([
|
|
'use_existing' => true,
|
|
'edition_ids' => 'GeoLite2-Country',
|
|
]);
|
|
if (!empty($update['success'])) {
|
|
$mmdb = $update['path'] ?? null;
|
|
}
|
|
}
|
|
|
|
if (!$mmdb) {
|
|
return ['success' => false, 'error' => 'GeoIP database not found. Update the GeoIP database in the panel.'];
|
|
}
|
|
|
|
$geoModule = ensureGeoIpModuleEnabled();
|
|
if ($geoModule !== null) {
|
|
return ['success' => false, 'error' => $geoModule];
|
|
}
|
|
|
|
$countryVar = '$jabali_geo_country_code';
|
|
$mapName = !empty($allow) ? '$jabali_geo_allow' : '$jabali_geo_block';
|
|
$mapLines = [
|
|
"map {$countryVar} {$mapName} {",
|
|
' default 0;',
|
|
];
|
|
|
|
$ruleset = !empty($allow) ? $allow : $block;
|
|
foreach ($ruleset as $rule) {
|
|
$code = strtoupper(trim($rule['country_code'] ?? ''));
|
|
if ($code === '') {
|
|
continue;
|
|
}
|
|
|
|
$mapLines[] = " {$code} 1;";
|
|
}
|
|
$mapLines[] = '}';
|
|
|
|
$httpConf = [
|
|
'# Managed by Jabali',
|
|
"geoip2 {$mmdb} {",
|
|
" {$countryVar} country iso_code;",
|
|
'}',
|
|
'',
|
|
...$mapLines,
|
|
];
|
|
file_put_contents(JABALI_GEO_HTTP_CONF, implode("\n", $httpConf) . "\n");
|
|
|
|
if (!empty($allow)) {
|
|
$geoInclude = "# Managed by Jabali\nif ({$mapName} = 0) { return 403; }\n";
|
|
} else {
|
|
$geoInclude = "# Managed by Jabali\nif ({$mapName} = 1) { return 403; }\n";
|
|
}
|
|
|
|
file_put_contents(JABALI_GEO_INCLUDE, $geoInclude);
|
|
|
|
ensureNginxServerIncludes([
|
|
'include ' . JABALI_GEO_INCLUDE . ';',
|
|
]);
|
|
|
|
$reload = nginxTestAndReload();
|
|
if (!($reload['success'] ?? false)) {
|
|
if ($prevGeoHttp === null) {
|
|
@unlink(JABALI_GEO_HTTP_CONF);
|
|
} else {
|
|
file_put_contents(JABALI_GEO_HTTP_CONF, $prevGeoHttp);
|
|
}
|
|
|
|
if ($prevGeoInclude === null) {
|
|
@unlink(JABALI_GEO_INCLUDE);
|
|
} else {
|
|
file_put_contents(JABALI_GEO_INCLUDE, $prevGeoInclude);
|
|
}
|
|
|
|
return $reload;
|
|
}
|
|
|
|
return ['success' => true, 'rules' => count($ruleset)];
|
|
}
|
|
|
|
function databasePersistTuning(array $params): array
|
|
{
|
|
$name = $params['name'] ?? '';
|
|
$value = $params['value'] ?? '';
|
|
|
|
if (!preg_match('/^[a-zA-Z0-9_]+$/', $name)) {
|
|
return ['success' => false, 'error' => 'Invalid variable name'];
|
|
}
|
|
|
|
$configDir = '/etc/mysql/mariadb.conf.d';
|
|
if (!is_dir($configDir)) {
|
|
$configDir = '/etc/mysql/conf.d';
|
|
}
|
|
|
|
if (!is_dir($configDir)) {
|
|
return ['success' => false, 'error' => 'MySQL configuration directory not found'];
|
|
}
|
|
|
|
$file = $configDir . '/90-jabali-tuning.cnf';
|
|
$lines = file_exists($file) ? file($file, FILE_IGNORE_NEW_LINES) : [];
|
|
|
|
if (empty($lines)) {
|
|
$lines = ['# Managed by Jabali', '[mysqld]'];
|
|
}
|
|
|
|
$hasSection = false;
|
|
$found = false;
|
|
foreach ($lines as $index => $line) {
|
|
if (trim($line) === '[mysqld]') {
|
|
$hasSection = true;
|
|
}
|
|
|
|
if (preg_match('/^\s*' . preg_quote($name, '/') . '\s*=/i', $line)) {
|
|
$lines[$index] = $name . ' = ' . $value;
|
|
$found = true;
|
|
}
|
|
}
|
|
|
|
if (!$hasSection) {
|
|
$lines[] = '[mysqld]';
|
|
}
|
|
|
|
if (!$found) {
|
|
$lines[] = $name . ' = ' . $value;
|
|
}
|
|
|
|
file_put_contents($file, implode("\n", $lines) . "\n");
|
|
|
|
return ['success' => true, 'message' => 'Configuration persisted'];
|
|
}
|
|
|
|
// ============ MAIN ============
|
|
|
|
function main(): void
|
|
{
|
|
@mkdir(dirname(SOCKET_PATH), 0755, true);
|
|
@mkdir(dirname(LOG_FILE), 0755, true);
|
|
|
|
if (file_exists(SOCKET_PATH)) {
|
|
unlink(SOCKET_PATH);
|
|
}
|
|
|
|
|
|
// SSH Key Management Functions
|
|
function sshListKeys(array $params): array
|
|
{
|
|
$username = $params['username'] ?? '';
|
|
|
|
if (!validateUsername($username)) {
|
|
return ['success' => false, 'error' => 'Invalid username'];
|
|
}
|
|
|
|
$userInfo = posix_getpwnam($username);
|
|
if (!$userInfo) {
|
|
return ['success' => false, 'error' => 'User not found'];
|
|
}
|
|
|
|
$sshDir = $userInfo['dir'] . '/.ssh';
|
|
$authKeysFile = $sshDir . '/authorized_keys';
|
|
|
|
if (!file_exists($authKeysFile)) {
|
|
return ['success' => true, 'keys' => []];
|
|
}
|
|
|
|
$keys = [];
|
|
$lines = file($authKeysFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
|
|
|
foreach ($lines as $index => $line) {
|
|
$line = trim($line);
|
|
if (empty($line) || strpos($line, '#') === 0) {
|
|
continue;
|
|
}
|
|
|
|
// Parse SSH key: type base64 comment
|
|
$parts = preg_split('/\s+/', $line, 3);
|
|
if (count($parts) < 2) {
|
|
continue;
|
|
}
|
|
|
|
$type = $parts[0];
|
|
$keyData = $parts[1];
|
|
$comment = $parts[2] ?? 'Key ' . ($index + 1);
|
|
|
|
// Generate fingerprint
|
|
$tempFile = tempnam(sys_get_temp_dir(), 'sshkey');
|
|
file_put_contents($tempFile, $line);
|
|
exec("ssh-keygen -lf " . escapeshellarg($tempFile) . " 2>&1", $fpOutput);
|
|
@unlink($tempFile);
|
|
|
|
$fingerprint = isset($fpOutput[0]) ? preg_replace('/^\d+\s+/', '', $fpOutput[0]) : substr($keyData, 0, 20) . '...';
|
|
|
|
$keys[] = [
|
|
'id' => md5($line),
|
|
'name' => $comment,
|
|
'type' => $type,
|
|
'fingerprint' => $fingerprint,
|
|
'key' => substr($keyData, 0, 30) . '...',
|
|
];
|
|
}
|
|
|
|
return ['success' => true, 'keys' => $keys];
|
|
}
|
|
|
|
function sshAddKey(array $params): array
|
|
{
|
|
$username = $params['username'] ?? '';
|
|
$name = $params['name'] ?? '';
|
|
$publicKey = $params['public_key'] ?? '';
|
|
|
|
if (!validateUsername($username)) {
|
|
return ['success' => false, 'error' => 'Invalid username'];
|
|
}
|
|
|
|
if (empty($publicKey)) {
|
|
return ['success' => false, 'error' => 'Public key is required'];
|
|
}
|
|
|
|
// Validate key format
|
|
$publicKey = trim($publicKey);
|
|
if (!preg_match('/^(ssh-rsa|ssh-ed25519|ssh-dss|ecdsa-sha2-\S+)\s+[A-Za-z0-9+\/=]+/', $publicKey)) {
|
|
return ['success' => false, 'error' => 'Invalid SSH public key format'];
|
|
}
|
|
|
|
$userInfo = posix_getpwnam($username);
|
|
if (!$userInfo) {
|
|
return ['success' => false, 'error' => 'User not found'];
|
|
}
|
|
|
|
$uid = $userInfo['uid'];
|
|
$gid = $userInfo['gid'];
|
|
$sshDir = $userInfo['dir'] . '/.ssh';
|
|
$authKeysFile = $sshDir . '/authorized_keys';
|
|
|
|
// Create .ssh directory if it doesn't exist
|
|
if (!is_dir($sshDir)) {
|
|
mkdir($sshDir, 0700, true);
|
|
chown($sshDir, $uid);
|
|
chgrp($sshDir, $gid);
|
|
}
|
|
|
|
// Add comment if not present
|
|
if (!preg_match('/\s+\S+$/', $publicKey) || preg_match('/==$/', $publicKey)) {
|
|
$publicKey .= ' ' . $name;
|
|
}
|
|
|
|
// Check if key already exists
|
|
if (file_exists($authKeysFile)) {
|
|
$existingKeys = file_get_contents($authKeysFile);
|
|
$keyParts = preg_split('/\s+/', $publicKey);
|
|
if (count($keyParts) >= 2 && strpos($existingKeys, $keyParts[1]) !== false) {
|
|
return ['success' => false, 'error' => 'This key already exists'];
|
|
}
|
|
}
|
|
|
|
// Append key to authorized_keys
|
|
$result = file_put_contents($authKeysFile, $publicKey . "\n", FILE_APPEND | LOCK_EX);
|
|
|
|
if ($result === false) {
|
|
return ['success' => false, 'error' => 'Failed to write authorized_keys file'];
|
|
}
|
|
|
|
// Set proper permissions
|
|
chmod($authKeysFile, 0600);
|
|
chown($authKeysFile, $uid);
|
|
chgrp($authKeysFile, $gid);
|
|
|
|
logger("SSH key added for user $username: $name");
|
|
|
|
return ['success' => true, 'message' => 'SSH key added successfully'];
|
|
}
|
|
|
|
function sshDeleteKey(array $params): array
|
|
{
|
|
$username = $params['username'] ?? '';
|
|
$keyId = $params['key_id'] ?? '';
|
|
|
|
if (!validateUsername($username)) {
|
|
return ['success' => false, 'error' => 'Invalid username'];
|
|
}
|
|
|
|
if (empty($keyId)) {
|
|
return ['success' => false, 'error' => 'Key ID is required'];
|
|
}
|
|
|
|
$userInfo = posix_getpwnam($username);
|
|
if (!$userInfo) {
|
|
return ['success' => false, 'error' => 'User not found'];
|
|
}
|
|
|
|
$uid = $userInfo['uid'];
|
|
$gid = $userInfo['gid'];
|
|
$authKeysFile = $userInfo['dir'] . '/.ssh/authorized_keys';
|
|
|
|
if (!file_exists($authKeysFile)) {
|
|
return ['success' => false, 'error' => 'No SSH keys found'];
|
|
}
|
|
|
|
$lines = file($authKeysFile, FILE_IGNORE_NEW_LINES);
|
|
$newLines = [];
|
|
$found = false;
|
|
|
|
foreach ($lines as $line) {
|
|
if (md5(trim($line)) === $keyId) {
|
|
$found = true;
|
|
continue; // Skip this key
|
|
}
|
|
$newLines[] = $line;
|
|
}
|
|
|
|
if (!$found) {
|
|
return ['success' => false, 'error' => 'Key not found'];
|
|
}
|
|
|
|
// Write back
|
|
file_put_contents($authKeysFile, implode("\n", $newLines) . (count($newLines) > 0 ? "\n" : ""));
|
|
chmod($authKeysFile, 0600);
|
|
chown($authKeysFile, $uid);
|
|
chgrp($authKeysFile, $gid);
|
|
|
|
logger("SSH key deleted for user $username: $keyId");
|
|
|
|
return ['success' => true, 'message' => 'SSH key deleted successfully'];
|
|
}
|
|
|
|
$socket = socket_create(AF_UNIX, SOCK_STREAM, 0);
|
|
if ($socket === false) {
|
|
logger("Failed to create socket", 'ERROR');
|
|
exit(1);
|
|
}
|
|
|
|
if (socket_bind($socket, SOCKET_PATH) === false) {
|
|
logger("Failed to bind socket", 'ERROR');
|
|
exit(1);
|
|
}
|
|
|
|
if (socket_listen($socket, 5) === false) {
|
|
logger("Failed to listen", 'ERROR');
|
|
exit(1);
|
|
}
|
|
|
|
chmod(SOCKET_PATH, 0660);
|
|
chown(SOCKET_PATH, 'root');
|
|
chgrp(SOCKET_PATH, 'www-data');
|
|
|
|
file_put_contents(PID_FILE, getmypid());
|
|
|
|
logger("Jabali Agent started on " . SOCKET_PATH);
|
|
|
|
pcntl_signal(SIGTERM, function () use ($socket) {
|
|
logger("Shutting down...");
|
|
socket_close($socket);
|
|
@unlink(SOCKET_PATH);
|
|
@unlink(PID_FILE);
|
|
exit(0);
|
|
});
|
|
|
|
pcntl_signal(SIGINT, function () use ($socket) {
|
|
logger("Shutting down...");
|
|
socket_close($socket);
|
|
@unlink(SOCKET_PATH);
|
|
@unlink(PID_FILE);
|
|
exit(0);
|
|
});
|
|
|
|
// Make socket non-blocking for signal handling
|
|
socket_set_nonblock($socket);
|
|
|
|
while (true) {
|
|
pcntl_signal_dispatch();
|
|
|
|
$client = @socket_accept($socket);
|
|
if ($client === false) {
|
|
usleep(50000); // 50ms delay
|
|
continue;
|
|
}
|
|
|
|
// Set short timeout for reading
|
|
socket_set_option($client, SOL_SOCKET, SO_RCVTIMEO, ['sec' => 30, 'usec' => 0]);
|
|
|
|
$data = '';
|
|
while (($chunk = @socket_read($client, 65536)) !== false && $chunk !== '') {
|
|
$data .= $chunk;
|
|
// Check if we have complete JSON
|
|
if (strlen($chunk) < 65536) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!empty($data)) {
|
|
$request = json_decode($data, true);
|
|
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
$response = ['success' => false, 'error' => 'Invalid JSON: ' . json_last_error_msg()];
|
|
} else {
|
|
try {
|
|
$response = handleAction($request);
|
|
} catch (Throwable $e) {
|
|
logger("[ERROR] Exception in handleAction: " . $e->getMessage());
|
|
$response = ['success' => false, 'error' => 'Internal error: ' . $e->getMessage()];
|
|
}
|
|
}
|
|
|
|
socket_write($client, json_encode($response));
|
|
}
|
|
|
|
socket_close($client);
|
|
}
|
|
}
|
|
|
|
|
|
// ============ MYSQL MANAGEMENT ============
|
|
|
|
function getMysqlConnection(): ?mysqli
|
|
{
|
|
$socket = "/var/run/mysqld/mysqld.sock";
|
|
|
|
// Try socket connection as root
|
|
$conn = @new mysqli("localhost", "root", "", "", 0, $socket);
|
|
if ($conn->connect_error) {
|
|
// Try TCP
|
|
$conn = @new mysqli("127.0.0.1", "root", "");
|
|
if ($conn->connect_error) {
|
|
return null;
|
|
}
|
|
}
|
|
return $conn;
|
|
}
|
|
|
|
function getMysqlRootCredentials(): ?array
|
|
{
|
|
// Try to read from debian.cnf first (Debian/Ubuntu)
|
|
$debianCnf = '/etc/mysql/debian.cnf';
|
|
if (file_exists($debianCnf)) {
|
|
$content = file_get_contents($debianCnf);
|
|
if (preg_match('/user\s*=\s*(\S+)/', $content, $userMatch) &&
|
|
preg_match('/password\s*=\s*(\S+)/', $content, $passMatch)) {
|
|
return ['user' => $userMatch[1], 'password' => $passMatch[1]];
|
|
}
|
|
}
|
|
|
|
// Try root with no password (socket auth)
|
|
return ['user' => 'root', 'password' => ''];
|
|
}
|
|
|
|
function mysqlListDatabases(array $params): array
|
|
{
|
|
$username = $params["username"] ?? "";
|
|
|
|
$conn = getMysqlConnection();
|
|
if (!$conn) {
|
|
return ["success" => false, "error" => "Cannot connect to MySQL"];
|
|
}
|
|
|
|
$databases = [];
|
|
$prefix = $username . "_";
|
|
|
|
// Get database sizes
|
|
$sizeQuery = "SELECT table_schema AS db_name,
|
|
SUM(data_length + index_length) AS size_bytes
|
|
FROM information_schema.tables
|
|
GROUP BY table_schema";
|
|
$sizeResult = $conn->query($sizeQuery);
|
|
$dbSizes = [];
|
|
if ($sizeResult) {
|
|
while ($row = $sizeResult->fetch_assoc()) {
|
|
$dbSizes[$row['db_name']] = (int)($row['size_bytes'] ?? 0);
|
|
}
|
|
}
|
|
|
|
$result = $conn->query("SHOW DATABASES");
|
|
while ($row = $result->fetch_array()) {
|
|
$dbName = $row[0];
|
|
if (strpos($dbName, $prefix) === 0 || $username === "admin") {
|
|
$sizeBytes = $dbSizes[$dbName] ?? 0;
|
|
$databases[] = [
|
|
"name" => $dbName,
|
|
"size_bytes" => $sizeBytes,
|
|
"size_human" => formatBytes($sizeBytes),
|
|
];
|
|
}
|
|
}
|
|
|
|
$conn->close();
|
|
return ["success" => true, "databases" => $databases];
|
|
}
|
|
|
|
function mysqlCreateDatabase(array $params): array
|
|
{
|
|
$username = $params["username"] ?? "";
|
|
$database = $params["database"] ?? "";
|
|
|
|
if (!validateUsername($username)) {
|
|
return ["success" => false, "error" => "Invalid username"];
|
|
}
|
|
|
|
// Check if already prefixed
|
|
$prefix = $username . "_";
|
|
$cleanDb = preg_replace("/[^a-zA-Z0-9_]/", "", $database);
|
|
if (strpos($cleanDb, $prefix) === 0) {
|
|
$dbName = $cleanDb;
|
|
} else {
|
|
$dbName = $prefix . $cleanDb;
|
|
}
|
|
|
|
if (strlen($dbName) > 64) {
|
|
return ["success" => false, "error" => "Database name too long"];
|
|
}
|
|
|
|
$conn = getMysqlConnection();
|
|
if (!$conn) {
|
|
return ["success" => false, "error" => "Cannot connect to MySQL"];
|
|
}
|
|
|
|
$dbName = $conn->real_escape_string($dbName);
|
|
|
|
if (!$conn->query("CREATE DATABASE IF NOT EXISTS `$dbName` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")) {
|
|
$error = $conn->error;
|
|
$conn->close();
|
|
return ["success" => false, "error" => "Failed to create database: $error"];
|
|
}
|
|
|
|
$conn->close();
|
|
logger("Created MySQL database: $dbName for user $username");
|
|
return ["success" => true, "message" => "Database created", "database" => $dbName];
|
|
}
|
|
|
|
function mysqlDeleteDatabase(array $params): array
|
|
{
|
|
$username = $params["username"] ?? "";
|
|
$database = $params["database"] ?? "";
|
|
|
|
if (!validateUsername($username)) {
|
|
return ["success" => false, "error" => "Invalid username"];
|
|
}
|
|
|
|
$prefix = $username . "_";
|
|
if (strpos($database, $prefix) !== 0 && $username !== "admin") {
|
|
return ["success" => false, "error" => "Access denied"];
|
|
}
|
|
|
|
$conn = getMysqlConnection();
|
|
if (!$conn) {
|
|
return ["success" => false, "error" => "Cannot connect to MySQL"];
|
|
}
|
|
|
|
$dbName = $conn->real_escape_string($database);
|
|
|
|
if (!$conn->query("DROP DATABASE IF EXISTS `$dbName`")) {
|
|
$error = $conn->error;
|
|
$conn->close();
|
|
return ["success" => false, "error" => "Failed to delete database: $error"];
|
|
}
|
|
|
|
$conn->close();
|
|
logger("Deleted MySQL database: $dbName for user $username");
|
|
return ["success" => true, "message" => "Database deleted"];
|
|
}
|
|
|
|
function mysqlListUsers(array $params): array
|
|
{
|
|
$username = $params["username"] ?? "";
|
|
|
|
$conn = getMysqlConnection();
|
|
if (!$conn) {
|
|
return ["success" => false, "error" => "Cannot connect to MySQL"];
|
|
}
|
|
|
|
$users = [];
|
|
$prefix = $username . "_";
|
|
|
|
$result = $conn->query("SELECT User, Host FROM mysql.user WHERE User != 'root' AND User != '' AND User NOT LIKE 'mysql.%'");
|
|
while ($row = $result->fetch_assoc()) {
|
|
if (strpos($row["User"], $prefix) === 0 || $username === "admin") {
|
|
$users[] = ["user" => $row["User"], "host" => $row["Host"]];
|
|
}
|
|
}
|
|
|
|
$conn->close();
|
|
return ["success" => true, "users" => $users];
|
|
}
|
|
|
|
function mysqlCreateUser(array $params): array
|
|
{
|
|
$username = $params["username"] ?? "";
|
|
$dbUser = $params["db_user"] ?? "";
|
|
$password = $params["password"] ?? "";
|
|
$host = $params["host"] ?? "localhost";
|
|
|
|
if (!validateUsername($username)) {
|
|
return ["success" => false, "error" => "Invalid username"];
|
|
}
|
|
|
|
if (empty($password) || strlen($password) < 8) {
|
|
return ["success" => false, "error" => "Password must be at least 8 characters"];
|
|
}
|
|
|
|
// Check if already prefixed
|
|
$prefix = $username . "_";
|
|
$cleanDbUser = preg_replace("/[^a-zA-Z0-9_]/", "", $dbUser);
|
|
if (strpos($cleanDbUser, $prefix) === 0) {
|
|
$dbUserName = $cleanDbUser;
|
|
} else {
|
|
$dbUserName = $prefix . $cleanDbUser;
|
|
}
|
|
|
|
if (strlen($dbUserName) > 32) {
|
|
return ["success" => false, "error" => "Username too long"];
|
|
}
|
|
|
|
$conn = getMysqlConnection();
|
|
if (!$conn) {
|
|
return ["success" => false, "error" => "Cannot connect to MySQL"];
|
|
}
|
|
|
|
$dbUserName = $conn->real_escape_string($dbUserName);
|
|
$host = $conn->real_escape_string($host);
|
|
$password = $conn->real_escape_string($password);
|
|
|
|
if (!$conn->query("CREATE USER '$dbUserName'@'$host' IDENTIFIED BY '$password'")) {
|
|
$error = $conn->error;
|
|
$conn->close();
|
|
return ["success" => false, "error" => "Failed to create user: $error"];
|
|
}
|
|
|
|
$conn->close();
|
|
logger("Created MySQL user: $dbUserName@$host for user $username");
|
|
return ["success" => true, "message" => "User created", "db_user" => $dbUserName];
|
|
}
|
|
|
|
function mysqlDeleteUser(array $params): array
|
|
{
|
|
$username = $params["username"] ?? "";
|
|
$dbUser = $params["db_user"] ?? "";
|
|
$host = $params["host"] ?? "localhost";
|
|
|
|
if (!validateUsername($username)) {
|
|
return ["success" => false, "error" => "Invalid username"];
|
|
}
|
|
|
|
$prefix = $username . "_";
|
|
if (strpos($dbUser, $prefix) !== 0 && $username !== "admin") {
|
|
return ["success" => false, "error" => "Access denied"];
|
|
}
|
|
|
|
$conn = getMysqlConnection();
|
|
if (!$conn) {
|
|
return ["success" => false, "error" => "Cannot connect to MySQL"];
|
|
}
|
|
|
|
$dbUser = $conn->real_escape_string($dbUser);
|
|
$host = $conn->real_escape_string($host);
|
|
|
|
if (!$conn->query("DROP USER IF EXISTS '$dbUser'@'$host'")) {
|
|
$error = $conn->error;
|
|
$conn->close();
|
|
return ["success" => false, "error" => "Failed to delete user: $error"];
|
|
}
|
|
|
|
$conn->close();
|
|
logger("Deleted MySQL user: $dbUser@$host for user $username");
|
|
return ["success" => true, "message" => "User deleted"];
|
|
}
|
|
|
|
function mysqlChangePassword(array $params): array
|
|
{
|
|
$username = $params["username"] ?? "";
|
|
$dbUser = $params["db_user"] ?? "";
|
|
$password = $params["password"] ?? "";
|
|
$host = $params["host"] ?? "localhost";
|
|
|
|
if (!validateUsername($username)) {
|
|
return ["success" => false, "error" => "Invalid username"];
|
|
}
|
|
|
|
$prefix = $username . "_";
|
|
if (strpos($dbUser, $prefix) !== 0 && $username !== "admin") {
|
|
return ["success" => false, "error" => "Access denied"];
|
|
}
|
|
|
|
if (empty($password) || strlen($password) < 8) {
|
|
return ["success" => false, "error" => "Password must be at least 8 characters"];
|
|
}
|
|
|
|
$conn = getMysqlConnection();
|
|
if (!$conn) {
|
|
return ["success" => false, "error" => "Cannot connect to MySQL"];
|
|
}
|
|
|
|
$dbUser = $conn->real_escape_string($dbUser);
|
|
$host = $conn->real_escape_string($host);
|
|
$password = $conn->real_escape_string($password);
|
|
|
|
if (!$conn->query("ALTER USER '$dbUser'@'$host' IDENTIFIED BY '$password'")) {
|
|
$error = $conn->error;
|
|
$conn->close();
|
|
return ["success" => false, "error" => "Failed to change password: $error"];
|
|
}
|
|
|
|
$conn->query("FLUSH PRIVILEGES");
|
|
$conn->close();
|
|
logger("Changed password for MySQL user: $dbUser@$host");
|
|
return ["success" => true, "message" => "Password changed"];
|
|
}
|
|
|
|
function mysqlGrantPrivileges(array $params): array
|
|
{
|
|
$username = $params["username"] ?? "";
|
|
$dbUser = $params["db_user"] ?? "";
|
|
$database = $params["database"] ?? "";
|
|
$privileges = $params["privileges"] ?? ["ALL"];
|
|
$host = $params["host"] ?? "localhost";
|
|
|
|
if (!validateUsername($username)) {
|
|
return ["success" => false, "error" => "Invalid username"];
|
|
}
|
|
|
|
$prefix = $username . "_";
|
|
if ($username !== "admin") {
|
|
if (strpos($dbUser, $prefix) !== 0) {
|
|
return ["success" => false, "error" => "Access denied to user"];
|
|
}
|
|
if (strpos($database, $prefix) !== 0 && $database !== "*") {
|
|
return ["success" => false, "error" => "Access denied to database"];
|
|
}
|
|
}
|
|
|
|
$conn = getMysqlConnection();
|
|
if (!$conn) {
|
|
return ["success" => false, "error" => "Cannot connect to MySQL"];
|
|
}
|
|
|
|
$dbUser = $conn->real_escape_string($dbUser);
|
|
$host = $conn->real_escape_string($host);
|
|
$database = $conn->real_escape_string($database);
|
|
|
|
$allowedPrivs = ["ALL", "SELECT", "INSERT", "UPDATE", "DELETE", "CREATE", "DROP", "INDEX", "ALTER", "EXECUTE", "CREATE VIEW", "SHOW VIEW"];
|
|
$privList = [];
|
|
foreach ($privileges as $priv) {
|
|
$priv = strtoupper(trim($priv));
|
|
if (in_array($priv, $allowedPrivs)) {
|
|
$privList[] = $priv;
|
|
}
|
|
}
|
|
|
|
if (empty($privList)) {
|
|
$privList = ["ALL"];
|
|
}
|
|
|
|
$privString = implode(", ", $privList);
|
|
$dbTarget = $database === "*" ? "*.*" : "`$database`.*";
|
|
|
|
if (!$conn->query("GRANT $privString ON $dbTarget TO '$dbUser'@'$host'")) {
|
|
$error = $conn->error;
|
|
$conn->close();
|
|
return ["success" => false, "error" => "Failed to grant privileges: $error"];
|
|
}
|
|
|
|
$conn->query("FLUSH PRIVILEGES");
|
|
$conn->close();
|
|
logger("Granted $privString on $database to $dbUser@$host");
|
|
return ["success" => true, "message" => "Privileges granted"];
|
|
}
|
|
|
|
function mysqlRevokePrivileges(array $params): array
|
|
{
|
|
$username = $params["username"] ?? "";
|
|
$dbUser = $params["db_user"] ?? "";
|
|
$database = $params["database"] ?? "";
|
|
$host = $params["host"] ?? "localhost";
|
|
|
|
if (!validateUsername($username)) {
|
|
return ["success" => false, "error" => "Invalid username"];
|
|
}
|
|
|
|
$prefix = $username . "_";
|
|
if ($username !== "admin") {
|
|
if (strpos($dbUser, $prefix) !== 0) {
|
|
return ["success" => false, "error" => "Access denied to user"];
|
|
}
|
|
}
|
|
|
|
$conn = getMysqlConnection();
|
|
if (!$conn) {
|
|
return ["success" => false, "error" => "Cannot connect to MySQL"];
|
|
}
|
|
|
|
$dbUser = $conn->real_escape_string($dbUser);
|
|
$host = $conn->real_escape_string($host);
|
|
$database = $conn->real_escape_string($database);
|
|
|
|
$dbTarget = $database === "*" ? "*.*" : "`$database`.*";
|
|
|
|
if (!$conn->query("REVOKE ALL PRIVILEGES ON $dbTarget FROM '$dbUser'@'$host'")) {
|
|
$error = $conn->error;
|
|
$conn->close();
|
|
return ["success" => false, "error" => "Failed to revoke privileges: $error"];
|
|
}
|
|
|
|
$conn->query("FLUSH PRIVILEGES");
|
|
$conn->close();
|
|
logger("Revoked privileges on $database from $dbUser@$host");
|
|
return ["success" => true, "message" => "Privileges revoked"];
|
|
}
|
|
|
|
function mysqlGetPrivileges(array $params): array
|
|
{
|
|
$username = $params["username"] ?? "";
|
|
$dbUser = $params["db_user"] ?? "";
|
|
$host = $params["host"] ?? "localhost";
|
|
|
|
if (!validateUsername($username)) {
|
|
return ["success" => false, "error" => "Invalid username"];
|
|
|
|
// Signal handling for graceful shutdown
|
|
$shutdown = false;
|
|
|
|
pcntl_async_signals(true);
|
|
pcntl_signal(SIGTERM, function() use (&$shutdown) {
|
|
global $socket;
|
|
$shutdown = true;
|
|
if ($socket) {
|
|
socket_close($socket);
|
|
}
|
|
exit(0);
|
|
});
|
|
pcntl_signal(SIGINT, function() use (&$shutdown) {
|
|
global $socket;
|
|
$shutdown = true;
|
|
if ($socket) {
|
|
socket_close($socket);
|
|
}
|
|
exit(0);
|
|
});
|
|
|
|
while (!$shutdown) {
|
|
// Set socket timeout so it doesn't block forever
|
|
socket_set_option($socket, SOL_SOCKET, SO_RCVTIMEO, ['sec' => 1, 'usec' => 0]);
|
|
|
|
$client = @socket_accept($socket);
|
|
|
|
if ($client === false) {
|
|
// Timeout or error - check if we should shutdown
|
|
if ($shutdown) break;
|
|
continue;
|
|
}
|
|
|
|
handleConnection($client);
|
|
}
|
|
|
|
socket_close($socket);
|
|
unlink($socketPath);
|
|
}
|
|
|
|
$conn = getMysqlConnection();
|
|
if (!$conn) {
|
|
return ["success" => false, "error" => "Cannot connect to MySQL"];
|
|
}
|
|
|
|
$dbUser = $conn->real_escape_string($dbUser);
|
|
$host = $conn->real_escape_string($host);
|
|
|
|
$rawPrivileges = [];
|
|
$parsedPrivileges = [];
|
|
|
|
$result = $conn->query("SHOW GRANTS FOR '$dbUser'@'$host'");
|
|
|
|
if ($result) {
|
|
while ($row = $result->fetch_array()) {
|
|
$grant = $row[0];
|
|
$rawPrivileges[] = $grant;
|
|
|
|
// Parse: GRANT SELECT, INSERT ON `db`.* TO user
|
|
// Or: GRANT ALL PRIVILEGES ON `db`.* TO user
|
|
if (preg_match('/GRANT\s+(.+?)\s+ON\s+[`"\']*([^`"\'\.\s]+)[`"\']*\.\*\s+TO/i', $grant, $matches)) {
|
|
$privsStr = trim($matches[1]);
|
|
$db = trim($matches[2], "`\'\"");
|
|
|
|
if ($db !== "*") {
|
|
$privList = [];
|
|
if (stripos($privsStr, "ALL PRIVILEGES") !== false) {
|
|
$privList = ["ALL PRIVILEGES"];
|
|
} elseif (stripos($privsStr, "ALL") === 0) {
|
|
$privList = ["ALL PRIVILEGES"];
|
|
} else {
|
|
$parts = explode(",", $privsStr);
|
|
foreach ($parts as $p) {
|
|
$p = trim($p);
|
|
if (!empty($p) && stripos($p, "GRANT OPTION") === false) {
|
|
$privList[] = strtoupper($p);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!empty($privList)) {
|
|
$parsedPrivileges[] = [
|
|
"database" => $db,
|
|
"privileges" => $privList
|
|
];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$conn->close();
|
|
return ["success" => true, "privileges" => $rawPrivileges, "parsed" => $parsedPrivileges];
|
|
}
|
|
|
|
main();
|
|
|
|
// Domain Management Functions
|
|
|
|
function domainCreate(array $params): array
|
|
{
|
|
$username = $params['username'] ?? '';
|
|
$domain = $params['domain'] ?? '';
|
|
|
|
if (!validateUsername($username)) {
|
|
return ['success' => false, 'error' => 'Invalid username'];
|
|
}
|
|
|
|
// Validate domain format
|
|
$domain = strtolower(trim($domain));
|
|
if (!preg_match('/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*\.[a-z]{2,}$/', $domain)) {
|
|
return ['success' => false, 'error' => 'Invalid domain format'];
|
|
}
|
|
|
|
// Check if domain already exists
|
|
$vhostFile = "/etc/nginx/sites-available/{$domain}.conf";
|
|
if (file_exists($vhostFile)) {
|
|
return ['success' => false, 'error' => 'Domain already exists'];
|
|
}
|
|
|
|
// Get user info
|
|
$userInfo = posix_getpwnam($username);
|
|
if (!$userInfo) {
|
|
return ['success' => false, 'error' => 'User not found'];
|
|
}
|
|
|
|
$userHome = $userInfo['dir'];
|
|
$uid = $userInfo['uid'];
|
|
$gid = $userInfo['gid'];
|
|
|
|
ensureJabaliNginxIncludeFiles();
|
|
|
|
// Create domain directories
|
|
$domainRoot = "{$userHome}/domains/{$domain}";
|
|
$publicHtml = "{$domainRoot}/public_html";
|
|
$logs = "{$domainRoot}/logs";
|
|
|
|
$dirs = [$domainRoot, $publicHtml, $logs];
|
|
foreach ($dirs as $dir) {
|
|
if (!is_dir($dir)) {
|
|
if (!mkdir($dir, 0755, true)) {
|
|
return ['success' => false, 'error' => "Failed to create directory: {$dir}"];
|
|
}
|
|
chown($dir, $uid);
|
|
chgrp($dir, $gid);
|
|
}
|
|
}
|
|
|
|
// Create default index.html
|
|
$indexContent = '<!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';
|
|
|
|
if (!file_exists($mailLogFile)) {
|
|
return ['success' => true, 'logs' => []];
|
|
}
|
|
|
|
// Read last N lines of mail log
|
|
$output = [];
|
|
exec("tail -n 1000 " . escapeshellarg($mailLogFile), $output);
|
|
|
|
$currentMessage = null;
|
|
$messageIndex = [];
|
|
|
|
foreach ($output as $line) {
|
|
// Parse Postfix log lines - support both traditional syslog and ISO 8601 timestamp formats
|
|
// Traditional: Jan 17 10:30:45 server postfix/smtp[12345]: MSGID: to=<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+([A-F0-9]+):\s+(.+)$/', $line, $matches)) {
|
|
$timestamp = strtotime($matches[1]);
|
|
$component = $matches[2];
|
|
$queueId = $matches[4];
|
|
$message = $matches[5];
|
|
}
|
|
// Try traditional syslog format
|
|
elseif (preg_match('/^(\w+\s+\d+\s+\d+:\d+:\d+)\s+\S+\s+postfix\/(\w+)\[(\d+)\]:\s+([A-F0-9]+):\s+(.+)$/', $line, $matches)) {
|
|
$timestamp = strtotime($matches[1] . ' ' . date('Y'));
|
|
$component = $matches[2];
|
|
$queueId = $matches[4];
|
|
$message = $matches[5];
|
|
}
|
|
|
|
if ($timestamp && $queueId && $message) {
|
|
|
|
// Initialize message entry
|
|
if (!isset($messageIndex[$queueId])) {
|
|
$messageIndex[$queueId] = [
|
|
'timestamp' => $timestamp,
|
|
'queue_id' => $queueId,
|
|
'from' => null,
|
|
'to' => null,
|
|
'subject' => null,
|
|
'status' => 'unknown',
|
|
'message' => '',
|
|
];
|
|
}
|
|
|
|
// Parse from
|
|
if (preg_match('/from=<([^>]*)>/', $message, $fromMatch)) {
|
|
$messageIndex[$queueId]['from'] = $fromMatch[1];
|
|
}
|
|
|
|
// Parse to
|
|
if (preg_match('/to=<([^>]*)>/', $message, $toMatch)) {
|
|
$messageIndex[$queueId]['to'] = $toMatch[1];
|
|
}
|
|
|
|
// Parse status
|
|
if (preg_match('/status=(\w+)/', $message, $statusMatch)) {
|
|
$messageIndex[$queueId]['status'] = $statusMatch[1];
|
|
$messageIndex[$queueId]['message'] = $message;
|
|
}
|
|
|
|
// Parse delay and relay info
|
|
if (preg_match('/relay=([^,]+)/', $message, $relayMatch)) {
|
|
$messageIndex[$queueId]['relay'] = $relayMatch[1];
|
|
}
|
|
}
|
|
}
|
|
|
|
// Convert to array and limit
|
|
$logs = array_values($messageIndex);
|
|
|
|
// Sort by timestamp descending
|
|
usort($logs, fn($a, $b) => $b['timestamp'] <=> $a['timestamp']);
|
|
|
|
// Limit results
|
|
$logs = array_slice($logs, 0, $limit);
|
|
|
|
return ['success' => true, 'logs' => $logs];
|
|
}
|
|
|
|
// ============ UFW Firewall Management ============
|
|
|
|
function ufwStatus(array $params): array
|
|
{
|
|
$output = [];
|
|
exec('ufw status verbose 2>&1', $output, $exitCode);
|
|
|
|
$status = implode("\n", $output);
|
|
$isActive = strpos($status, 'Status: active') !== false;
|
|
|
|
// Parse default policies
|
|
$defaultIncoming = 'deny';
|
|
$defaultOutgoing = 'allow';
|
|
if (preg_match('/Default: (\w+) \(incoming\), (\w+) \(outgoing\)/', $status, $matches)) {
|
|
$defaultIncoming = $matches[1];
|
|
$defaultOutgoing = $matches[2];
|
|
}
|
|
|
|
return [
|
|
'success' => true,
|
|
'active' => $isActive,
|
|
'status_text' => $status,
|
|
'default_incoming' => $defaultIncoming,
|
|
'default_outgoing' => $defaultOutgoing,
|
|
];
|
|
}
|
|
|
|
function ufwListRules(array $params): array
|
|
{
|
|
$output = [];
|
|
exec('ufw status numbered 2>&1', $output, $exitCode);
|
|
|
|
$rules = [];
|
|
$statusLine = '';
|
|
|
|
foreach ($output as $line) {
|
|
if (strpos($line, 'Status:') !== false) {
|
|
$statusLine = $line;
|
|
continue;
|
|
}
|
|
|
|
// Parse numbered rules like: [ 1] 22/tcp ALLOW IN Anywhere
|
|
if (preg_match('/\[\s*(\d+)\]\s+(.+?)\s+(ALLOW|DENY|REJECT|LIMIT)\s+(IN|OUT)?\s*(.*)/', $line, $matches)) {
|
|
$rules[] = [
|
|
'number' => (int)$matches[1],
|
|
'to' => trim($matches[2]),
|
|
'action' => $matches[3],
|
|
'direction' => $matches[4] ?: 'IN',
|
|
'from' => trim($matches[5]),
|
|
];
|
|
}
|
|
}
|
|
|
|
return [
|
|
'success' => true,
|
|
'rules' => $rules,
|
|
'raw_output' => implode("\n", $output),
|
|
];
|
|
}
|
|
|
|
function ufwEnable(array $params): array
|
|
{
|
|
exec('echo "y" | ufw enable 2>&1', $output, $exitCode);
|
|
|
|
return [
|
|
'success' => $exitCode === 0,
|
|
'message' => implode("\n", $output),
|
|
];
|
|
}
|
|
|
|
function ufwDisable(array $params): array
|
|
{
|
|
exec('ufw disable 2>&1', $output, $exitCode);
|
|
|
|
return [
|
|
'success' => $exitCode === 0,
|
|
'message' => implode("\n", $output),
|
|
];
|
|
}
|
|
|
|
function ufwAllowPort(array $params): array
|
|
{
|
|
$port = $params['port'] ?? '';
|
|
$protocol = $params['protocol'] ?? ''; // tcp, udp, or empty for both
|
|
$comment = $params['comment'] ?? '';
|
|
|
|
if (empty($port)) {
|
|
return ['success' => false, 'error' => 'Port is required'];
|
|
}
|
|
|
|
// Validate port
|
|
if (!preg_match('/^\d+$/', $port) && !preg_match('/^\d+:\d+$/', $port)) {
|
|
return ['success' => false, 'error' => 'Invalid port format'];
|
|
}
|
|
|
|
$rule = $port;
|
|
if (!empty($protocol)) {
|
|
$rule .= '/' . $protocol;
|
|
}
|
|
|
|
$cmd = "ufw allow $rule";
|
|
if (!empty($comment)) {
|
|
$cmd .= " comment " . escapeshellarg($comment);
|
|
}
|
|
|
|
exec($cmd . ' 2>&1', $output, $exitCode);
|
|
|
|
return [
|
|
'success' => $exitCode === 0,
|
|
'message' => implode("\n", $output),
|
|
];
|
|
}
|
|
|
|
function ufwDenyPort(array $params): array
|
|
{
|
|
$port = $params['port'] ?? '';
|
|
$protocol = $params['protocol'] ?? '';
|
|
$comment = $params['comment'] ?? '';
|
|
|
|
if (empty($port)) {
|
|
return ['success' => false, 'error' => 'Port is required'];
|
|
}
|
|
|
|
$rule = $port;
|
|
if (!empty($protocol)) {
|
|
$rule .= '/' . $protocol;
|
|
}
|
|
|
|
$cmd = "ufw deny $rule";
|
|
if (!empty($comment)) {
|
|
$cmd .= " comment " . escapeshellarg($comment);
|
|
}
|
|
|
|
exec($cmd . ' 2>&1', $output, $exitCode);
|
|
|
|
return [
|
|
'success' => $exitCode === 0,
|
|
'message' => implode("\n", $output),
|
|
];
|
|
}
|
|
|
|
function ufwAllowIp(array $params): array
|
|
{
|
|
$ip = $params['ip'] ?? '';
|
|
$port = $params['port'] ?? '';
|
|
$protocol = $params['protocol'] ?? '';
|
|
$comment = $params['comment'] ?? '';
|
|
|
|
if (empty($ip)) {
|
|
return ['success' => false, 'error' => 'IP address is required'];
|
|
}
|
|
|
|
// Validate IP (simple check)
|
|
if (!filter_var($ip, FILTER_VALIDATE_IP) && !preg_match('/^\d+\.\d+\.\d+\.\d+\/\d+$/', $ip)) {
|
|
return ['success' => false, 'error' => 'Invalid IP address format'];
|
|
}
|
|
|
|
$cmd = "ufw allow from " . escapeshellarg($ip);
|
|
|
|
if (!empty($port)) {
|
|
$cmd .= " to any port $port";
|
|
if (!empty($protocol)) {
|
|
$cmd .= " proto $protocol";
|
|
}
|
|
}
|
|
|
|
if (!empty($comment)) {
|
|
$cmd .= " comment " . escapeshellarg($comment);
|
|
}
|
|
|
|
exec($cmd . ' 2>&1', $output, $exitCode);
|
|
|
|
return [
|
|
'success' => $exitCode === 0,
|
|
'message' => implode("\n", $output),
|
|
];
|
|
}
|
|
|
|
function ufwDenyIp(array $params): array
|
|
{
|
|
$ip = $params['ip'] ?? '';
|
|
$port = $params['port'] ?? '';
|
|
$protocol = $params['protocol'] ?? '';
|
|
$comment = $params['comment'] ?? '';
|
|
|
|
if (empty($ip)) {
|
|
return ['success' => false, 'error' => 'IP address is required'];
|
|
}
|
|
|
|
if (!filter_var($ip, FILTER_VALIDATE_IP) && !preg_match('/^\d+\.\d+\.\d+\.\d+\/\d+$/', $ip)) {
|
|
return ['success' => false, 'error' => 'Invalid IP address format'];
|
|
}
|
|
|
|
$cmd = "ufw deny from " . escapeshellarg($ip);
|
|
|
|
if (!empty($port)) {
|
|
$cmd .= " to any port $port";
|
|
if (!empty($protocol)) {
|
|
$cmd .= " proto $protocol";
|
|
}
|
|
}
|
|
|
|
if (!empty($comment)) {
|
|
$cmd .= " comment " . escapeshellarg($comment);
|
|
}
|
|
|
|
exec($cmd . ' 2>&1', $output, $exitCode);
|
|
|
|
return [
|
|
'success' => $exitCode === 0,
|
|
'message' => implode("\n", $output),
|
|
];
|
|
}
|
|
|
|
function ufwDeleteRule(array $params): array
|
|
{
|
|
$ruleNumber = $params['rule_number'] ?? '';
|
|
|
|
if (empty($ruleNumber)) {
|
|
return ['success' => false, 'error' => 'Rule number is required'];
|
|
}
|
|
|
|
exec('echo "y" | ufw delete ' . (int)$ruleNumber . ' 2>&1', $output, $exitCode);
|
|
|
|
return [
|
|
'success' => $exitCode === 0,
|
|
'message' => implode("\n", $output),
|
|
];
|
|
}
|
|
|
|
function ufwSetDefault(array $params): array
|
|
{
|
|
$direction = $params['direction'] ?? 'incoming'; // incoming or outgoing
|
|
$policy = $params['policy'] ?? 'deny'; // allow, deny, reject
|
|
|
|
if (!in_array($direction, ['incoming', 'outgoing'])) {
|
|
return ['success' => false, 'error' => 'Direction must be incoming or outgoing'];
|
|
}
|
|
|
|
if (!in_array($policy, ['allow', 'deny', 'reject'])) {
|
|
return ['success' => false, 'error' => 'Policy must be allow, deny, or reject'];
|
|
}
|
|
|
|
exec("ufw default $policy $direction 2>&1", $output, $exitCode);
|
|
|
|
return [
|
|
'success' => $exitCode === 0,
|
|
'message' => implode("\n", $output),
|
|
];
|
|
}
|
|
|
|
function ufwLimitPort(array $params): array
|
|
{
|
|
$port = $params['port'] ?? '';
|
|
$protocol = $params['protocol'] ?? 'tcp';
|
|
|
|
if (empty($port)) {
|
|
return ['success' => false, 'error' => 'Port is required'];
|
|
}
|
|
|
|
$rule = $port;
|
|
if (!empty($protocol)) {
|
|
$rule .= '/' . $protocol;
|
|
}
|
|
|
|
exec("ufw limit $rule 2>&1", $output, $exitCode);
|
|
|
|
return [
|
|
'success' => $exitCode === 0,
|
|
'message' => implode("\n", $output),
|
|
];
|
|
}
|
|
|
|
function ufwReset(array $params): array
|
|
{
|
|
exec('echo "y" | ufw reset 2>&1', $output, $exitCode);
|
|
|
|
return [
|
|
'success' => $exitCode === 0,
|
|
'message' => implode("\n", $output),
|
|
];
|
|
}
|
|
|
|
function ufwReload(array $params): array
|
|
{
|
|
exec('ufw reload 2>&1', $output, $exitCode);
|
|
|
|
return [
|
|
'success' => $exitCode === 0,
|
|
'message' => implode("\n", $output),
|
|
];
|
|
}
|
|
|
|
function ufwAllowService(array $params): array
|
|
{
|
|
$service = $params['service'] ?? '';
|
|
|
|
if (empty($service)) {
|
|
return ['success' => false, 'error' => 'Service name is required'];
|
|
}
|
|
|
|
// Whitelist common services
|
|
$allowedServices = ['ssh', 'http', 'https', 'ftp', 'smtp', 'pop3', 'imap', 'dns', 'mysql', 'postgresql'];
|
|
if (!in_array(strtolower($service), $allowedServices) && !preg_match('/^[a-zA-Z0-9_-]+$/', $service)) {
|
|
return ['success' => false, 'error' => 'Invalid service name'];
|
|
}
|
|
|
|
exec("ufw allow " . escapeshellarg($service) . " 2>&1", $output, $exitCode);
|
|
|
|
return [
|
|
'success' => $exitCode === 0,
|
|
'message' => implode("\n", $output),
|
|
];
|
|
}
|
|
|
|
// ============ PHP VERSION MANAGEMENT ============
|
|
|
|
function phpInstall(array $params): array
|
|
{
|
|
$version = $params['version'] ?? '';
|
|
|
|
if (!preg_match('/^\d+\.\d+$/', $version)) {
|
|
return ['success' => false, 'error' => 'Invalid PHP version format'];
|
|
}
|
|
|
|
// Check if dpkg/apt is locked
|
|
exec("fuser /var/lib/dpkg/lock-frontend 2>/dev/null", $lockOutput, $lockCode);
|
|
if ($lockCode === 0 && !empty($lockOutput)) {
|
|
return ['success' => false, 'error' => 'Package manager is busy. Please wait for the current operation to complete.'];
|
|
}
|
|
|
|
logger("Installing PHP $version");
|
|
|
|
if (!file_exists('/etc/apt/sources.list.d/php.list')) {
|
|
exec('apt-get update 2>&1');
|
|
exec('apt-get install -y apt-transport-https lsb-release ca-certificates curl 2>&1');
|
|
exec('curl -sSL https://packages.sury.org/php/apt.gpg | gpg --dearmor -o /usr/share/keyrings/php-archive-keyring.gpg 2>&1');
|
|
$release = trim(shell_exec('lsb_release -sc'));
|
|
file_put_contents('/etc/apt/sources.list.d/php.list',
|
|
"deb [signed-by=/usr/share/keyrings/php-archive-keyring.gpg] https://packages.sury.org/php/ $release main\n");
|
|
exec('apt-get update 2>&1');
|
|
}
|
|
|
|
// Core PHP packages + WordPress recommended modules (imagick, opcache, redis, soap, imap, exif)
|
|
$packages = "php{$version}-fpm php{$version}-cli php{$version}-common php{$version}-mysql php{$version}-zip php{$version}-gd php{$version}-mbstring php{$version}-curl php{$version}-xml php{$version}-bcmath php{$version}-intl php{$version}-imagick php{$version}-opcache php{$version}-redis php{$version}-soap php{$version}-imap php{$version}-exif";
|
|
|
|
exec("DEBIAN_FRONTEND=noninteractive apt-get install -y $packages 2>&1", $output, $exitCode);
|
|
|
|
if ($exitCode !== 0) {
|
|
return ['success' => false, 'error' => 'Failed to install PHP: ' . implode("\n", $output)];
|
|
}
|
|
|
|
exec("systemctl enable php{$version}-fpm 2>&1");
|
|
exec("systemctl start php{$version}-fpm 2>&1");
|
|
|
|
return ['success' => true, 'version' => $version, 'message' => "PHP $version installed successfully"];
|
|
}
|
|
|
|
function phpUninstall(array $params): array
|
|
{
|
|
$version = $params['version'] ?? '';
|
|
|
|
if (!preg_match('/^\d+\.\d+$/', $version)) {
|
|
return ['success' => false, 'error' => 'Invalid PHP version format'];
|
|
}
|
|
|
|
// Check if dpkg/apt is locked
|
|
exec("fuser /var/lib/dpkg/lock-frontend 2>/dev/null", $lockOutput, $lockCode);
|
|
if ($lockCode === 0 && !empty($lockOutput)) {
|
|
return ['success' => false, 'error' => 'Package manager is busy. Please wait for the current operation to complete.'];
|
|
}
|
|
|
|
exec("systemctl stop php{$version}-fpm 2>/dev/null");
|
|
exec("systemctl disable php{$version}-fpm 2>/dev/null");
|
|
|
|
$output = [];
|
|
// Use purge instead of remove to also delete config files
|
|
exec("DEBIAN_FRONTEND=noninteractive apt-get purge -y php{$version}-* 2>&1", $output, $exitCode);
|
|
|
|
if ($exitCode !== 0) {
|
|
$errorOutput = implode("\n", $output);
|
|
if (strpos($errorOutput, 'lock') !== false || strpos($errorOutput, 'Could not get lock') !== false) {
|
|
return ['success' => false, 'error' => 'Package manager is busy. Please wait for the current operation to complete.'];
|
|
}
|
|
return ['success' => false, 'error' => "Failed to uninstall PHP $version: " . $errorOutput];
|
|
}
|
|
|
|
exec("DEBIAN_FRONTEND=noninteractive apt-get autoremove --purge -y 2>&1");
|
|
|
|
// Clean up any remaining config directory
|
|
$configDir = "/etc/php/{$version}";
|
|
if (is_dir($configDir)) {
|
|
exec("rm -rf " . escapeshellarg($configDir));
|
|
}
|
|
|
|
return ['success' => true, 'version' => $version, 'message' => "PHP $version uninstalled"];
|
|
}
|
|
|
|
function phpSetDefaultVersion(array $params): array
|
|
{
|
|
$version = $params['version'] ?? '';
|
|
|
|
if (!preg_match('/^\d+\.\d+$/', $version)) {
|
|
return ['success' => false, 'error' => 'Invalid PHP version format'];
|
|
}
|
|
|
|
exec("update-alternatives --set php /usr/bin/php{$version} 2>&1", $output, $exitCode);
|
|
|
|
return ['success' => $exitCode === 0, 'version' => $version, 'message' => "PHP $version set as default"];
|
|
}
|
|
|
|
function phpRestartFpm(array $params): array
|
|
{
|
|
return phpReloadFpm($params);
|
|
}
|
|
|
|
function phpReloadFpm(array $params): array
|
|
{
|
|
$version = $params['version'] ?? '';
|
|
|
|
if (!preg_match('/^\d+\.\d+$/', $version)) {
|
|
return ['success' => false, 'error' => 'Invalid PHP version format'];
|
|
}
|
|
|
|
// Check if PHP-FPM service exists
|
|
$serviceFile = "/lib/systemd/system/php{$version}-fpm.service";
|
|
if (!file_exists($serviceFile)) {
|
|
return ['success' => false, 'error' => "PHP $version is not installed"];
|
|
}
|
|
|
|
exec("systemctl reload php{$version}-fpm 2>&1", $output, $exitCode);
|
|
|
|
if ($exitCode !== 0) {
|
|
$errorOutput = implode("\n", $output);
|
|
return ['success' => false, 'error' => "Failed to reload PHP $version FPM: " . ($errorOutput ?: 'Service error')];
|
|
}
|
|
|
|
return ['success' => true, 'version' => $version, 'message' => "PHP $version FPM reloaded"];
|
|
}
|
|
|
|
function phpRestartAllFpm(array $params): array
|
|
{
|
|
return phpReloadAllFpm($params);
|
|
}
|
|
|
|
function phpReloadAllFpm(array $params): array
|
|
{
|
|
$background = $params['background'] ?? false;
|
|
$delay = (int) ($params['delay'] ?? 0);
|
|
|
|
// Find all installed PHP-FPM services
|
|
$services = glob('/lib/systemd/system/php*-fpm.service');
|
|
$versions = [];
|
|
foreach ($services as $serviceFile) {
|
|
if (preg_match('/php(\d+\.\d+)-fpm\.service$/', basename($serviceFile), $matches)) {
|
|
$versions[] = $matches[1];
|
|
}
|
|
}
|
|
|
|
if (empty($versions)) {
|
|
return ['success' => false, 'error' => 'No PHP-FPM services found'];
|
|
}
|
|
|
|
// Background reload to avoid blocking the caller
|
|
if ($background) {
|
|
$delayCmd = $delay > 0 ? "sleep {$delay} && " : '';
|
|
$cmd = "({$delayCmd}";
|
|
|
|
foreach ($versions as $version) {
|
|
$cmd .= "systemctl reload php{$version}-fpm 2>/dev/null; ";
|
|
}
|
|
$cmd .= ") > /dev/null 2>&1 &";
|
|
|
|
exec($cmd);
|
|
|
|
return [
|
|
'success' => true,
|
|
'background' => true,
|
|
'delay' => $delay,
|
|
'versions' => $versions,
|
|
'message' => 'PHP-FPM reload scheduled' . ($delay > 0 ? " in {$delay} seconds" : ' in background'),
|
|
];
|
|
}
|
|
|
|
$reloaded = [];
|
|
$failed = [];
|
|
|
|
foreach ($versions as $version) {
|
|
exec("systemctl reload php{$version}-fpm 2>&1", $output, $exitCode);
|
|
|
|
if ($exitCode === 0) {
|
|
$reloaded[] = $version;
|
|
} else {
|
|
$failed[] = $version;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'success' => empty($failed),
|
|
'reloaded' => $reloaded,
|
|
'failed' => $failed,
|
|
'message' => 'Reloaded PHP-FPM versions: ' . implode(', ', $reloaded) . (empty($failed) ? '' : '. Failed: ' . implode(', ', $failed)),
|
|
];
|
|
}
|
|
|
|
function phpListVersions(array $params): array
|
|
{
|
|
$versions = [];
|
|
|
|
foreach (glob('/etc/php/*', GLOB_ONLYDIR) as $dir) {
|
|
$version = basename($dir);
|
|
if (preg_match('/^\d+\.\d+$/', $version)) {
|
|
// Check if PHP binary actually exists (not just config directory)
|
|
$phpBinary = "/usr/bin/php{$version}";
|
|
$fpmService = "/lib/systemd/system/php{$version}-fpm.service";
|
|
|
|
if (!file_exists($phpBinary) && !file_exists($fpmService)) {
|
|
// PHP was uninstalled but config dir remains - skip it
|
|
continue;
|
|
}
|
|
|
|
$fpmStatus = trim(shell_exec("systemctl is-active php{$version}-fpm 2>/dev/null") ?? 'inactive');
|
|
$versions[] = [
|
|
'version' => $version,
|
|
'fpm_status' => $fpmStatus ?: 'inactive',
|
|
];
|
|
}
|
|
}
|
|
|
|
$defaultVersion = null;
|
|
$phpV = shell_exec('php -v 2>/dev/null');
|
|
if ($phpV && preg_match('/PHP (\d+\.\d+)/', $phpV, $matches)) {
|
|
$defaultVersion = $matches[1];
|
|
}
|
|
|
|
return ['success' => true, 'versions' => $versions, 'default' => $defaultVersion];
|
|
}
|
|
|
|
function phpInstallWordPressModules(array $params): array
|
|
{
|
|
$version = $params['version'] ?? null;
|
|
|
|
// Get all installed PHP versions if no specific version provided
|
|
$versions = [];
|
|
if ($version) {
|
|
if (!preg_match('/^\d+\.\d+$/', $version)) {
|
|
return ['success' => false, 'error' => 'Invalid PHP version format'];
|
|
}
|
|
$versions[] = $version;
|
|
} else {
|
|
// Install for all installed PHP versions
|
|
foreach (glob('/etc/php/*', GLOB_ONLYDIR) as $dir) {
|
|
$ver = basename($dir);
|
|
if (preg_match('/^\d+\.\d+$/', $ver)) {
|
|
$versions[] = $ver;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (empty($versions)) {
|
|
return ['success' => false, 'error' => 'No PHP versions found'];
|
|
}
|
|
|
|
logger("Installing WordPress PHP modules for versions: " . implode(', ', $versions));
|
|
|
|
// WordPress recommended modules
|
|
$wpModules = ['imagick', 'opcache', 'redis', 'soap', 'imap', 'exif'];
|
|
|
|
$installed = [];
|
|
$failed = [];
|
|
|
|
foreach ($versions as $ver) {
|
|
$packages = [];
|
|
foreach ($wpModules as $module) {
|
|
$packages[] = "php{$ver}-{$module}";
|
|
}
|
|
|
|
$packageList = implode(' ', $packages);
|
|
exec("apt-get install -y $packageList 2>&1", $output, $exitCode);
|
|
|
|
if ($exitCode === 0) {
|
|
$installed[] = $ver;
|
|
// Reload PHP-FPM for this version
|
|
exec("systemctl reload php{$ver}-fpm 2>/dev/null");
|
|
} else {
|
|
$failed[] = $ver;
|
|
}
|
|
}
|
|
|
|
if (empty($failed)) {
|
|
return [
|
|
'success' => true,
|
|
'message' => 'WordPress PHP modules installed for: ' . implode(', ', $installed),
|
|
'modules' => $wpModules,
|
|
'versions' => $installed
|
|
];
|
|
} elseif (!empty($installed)) {
|
|
return [
|
|
'success' => true,
|
|
'message' => 'Installed for: ' . implode(', ', $installed) . '. Failed for: ' . implode(', ', $failed),
|
|
'modules' => $wpModules,
|
|
'versions' => $installed,
|
|
'failed' => $failed
|
|
];
|
|
} else {
|
|
return ['success' => false, 'error' => 'Failed to install modules for all versions'];
|
|
}
|
|
}
|
|
|
|
// ============ SSH KEY GENERATION ============
|
|
|
|
function sshGenerateKey(array $params): array
|
|
{
|
|
$name = $params['name'] ?? 'key';
|
|
$type = $params['type'] ?? 'ed25519';
|
|
$passphrase = $params['passphrase'] ?? '';
|
|
|
|
$tempDir = sys_get_temp_dir() . '/jabali_ssh_' . uniqid();
|
|
mkdir($tempDir, 0700, true);
|
|
|
|
$keyFile = "$tempDir/key";
|
|
$pubFile = "$keyFile.pub";
|
|
$ppkFile = "$tempDir/key.ppk";
|
|
|
|
try {
|
|
$keyType = $type === 'ed25519' ? 'ed25519' : 'rsa';
|
|
$bits = $type === 'rsa' ? '-b 4096' : '';
|
|
$passOpt = $passphrase ? "-N " . escapeshellarg($passphrase) : "-N ''";
|
|
|
|
$cmd = "ssh-keygen -t {$keyType} {$bits} -f " . escapeshellarg($keyFile) . " {$passOpt} -C " . escapeshellarg($name) . " 2>&1";
|
|
exec($cmd, $output, $returnCode);
|
|
|
|
if ($returnCode !== 0 || !file_exists($keyFile)) {
|
|
return ['success' => false, 'error' => 'Failed to generate SSH key: ' . implode("\n", $output)];
|
|
}
|
|
|
|
$privateKey = file_get_contents($keyFile);
|
|
$publicKey = file_get_contents($pubFile);
|
|
|
|
// Get fingerprint
|
|
exec("ssh-keygen -lf " . escapeshellarg($pubFile) . " 2>&1", $fpOutput);
|
|
$fingerprint = $fpOutput[0] ?? '';
|
|
|
|
// Convert to PPK format
|
|
$ppkKey = null;
|
|
if ($passphrase) {
|
|
$ppkCmd = "puttygen " . escapeshellarg($keyFile) . " -o " . escapeshellarg($ppkFile) . " -O private -P --old-passphrase " . escapeshellarg($passphrase) . " --new-passphrase " . escapeshellarg($passphrase) . " 2>&1";
|
|
} else {
|
|
$ppkCmd = "puttygen " . escapeshellarg($keyFile) . " -o " . escapeshellarg($ppkFile) . " -O private 2>&1";
|
|
}
|
|
exec($ppkCmd, $ppkOutput, $ppkReturnCode);
|
|
|
|
if (file_exists($ppkFile)) {
|
|
$ppkKey = file_get_contents($ppkFile);
|
|
}
|
|
|
|
return [
|
|
'success' => true,
|
|
'name' => $name,
|
|
'type' => $type,
|
|
'private_key' => $privateKey,
|
|
'public_key' => $publicKey,
|
|
'ppk_key' => $ppkKey,
|
|
'fingerprint' => $fingerprint,
|
|
];
|
|
} finally {
|
|
@unlink($keyFile);
|
|
@unlink($pubFile);
|
|
@unlink($ppkFile);
|
|
@rmdir($tempDir);
|
|
}
|
|
}
|
|
|
|
// ============ SSH SHELL ACCESS MANAGEMENT ============
|
|
|
|
function sshEnableShell(array $params): array
|
|
{
|
|
$username = $params['username'] ?? '';
|
|
|
|
if (!validateUsername($username)) {
|
|
return ['success' => false, 'error' => 'Invalid username'];
|
|
}
|
|
|
|
$userInfo = posix_getpwnam($username);
|
|
if (!$userInfo) {
|
|
return ['success' => false, 'error' => 'User does not exist'];
|
|
}
|
|
|
|
// Create user's home directory in jail
|
|
$jailHome = "/var/jail/home/$username";
|
|
if (!is_dir($jailHome)) {
|
|
mkdir($jailHome, 0750, true);
|
|
chown($jailHome, $username);
|
|
chgrp($jailHome, $username);
|
|
}
|
|
|
|
// Add user to shellusers group and remove from sftpusers
|
|
exec("usermod -aG shellusers " . escapeshellarg($username));
|
|
exec("gpasswd -d " . escapeshellarg($username) . " sftpusers 2>/dev/null");
|
|
|
|
// Change shell to bash (relative to chroot /var/jail, so /bin/bash)
|
|
exec("usermod -s /bin/bash " . escapeshellarg($username));
|
|
|
|
// Add user to jail's passwd/group
|
|
$passwdLine = "$username:x:{$userInfo['uid']}:{$userInfo['gid']}::/home/$username:/bin/bash\n";
|
|
file_put_contents('/var/jail/etc/passwd', $passwdLine, FILE_APPEND);
|
|
|
|
$groupLine = "$username:x:{$userInfo['gid']}:\n";
|
|
file_put_contents('/var/jail/etc/group', $groupLine, FILE_APPEND);
|
|
|
|
// Bind mount user's actual home to jail home for file access
|
|
$realHome = $userInfo['dir'];
|
|
if (!is_mounted($jailHome)) {
|
|
exec("mount --bind " . escapeshellarg($realHome) . " " . escapeshellarg($jailHome));
|
|
|
|
// Add to fstab for persistence
|
|
$fstabEntry = "$realHome $jailHome none bind 0 0\n";
|
|
if (strpos(file_get_contents('/etc/fstab'), $jailHome) === false) {
|
|
file_put_contents('/etc/fstab', $fstabEntry, FILE_APPEND);
|
|
}
|
|
}
|
|
|
|
logger("Enabled shell access for user $username");
|
|
|
|
return [
|
|
'success' => true,
|
|
'message' => "Shell access enabled for $username",
|
|
'shell' => '/bin/bash',
|
|
];
|
|
}
|
|
|
|
function sshDisableShell(array $params): array
|
|
{
|
|
$username = $params['username'] ?? '';
|
|
|
|
if (!validateUsername($username)) {
|
|
return ['success' => false, 'error' => 'Invalid username'];
|
|
}
|
|
|
|
$userInfo = posix_getpwnam($username);
|
|
if (!$userInfo) {
|
|
return ['success' => false, 'error' => 'User does not exist'];
|
|
}
|
|
|
|
// Remove from shellusers and add to sftpusers
|
|
exec("gpasswd -d " . escapeshellarg($username) . " shellusers 2>/dev/null");
|
|
exec("usermod -aG sftpusers " . escapeshellarg($username));
|
|
|
|
// Change shell to nologin
|
|
exec("usermod -s /usr/sbin/nologin " . escapeshellarg($username));
|
|
|
|
// Unmount bind mount if exists
|
|
$jailHome = "/var/jail/home/$username";
|
|
if (is_mounted($jailHome)) {
|
|
exec("umount " . escapeshellarg($jailHome));
|
|
|
|
// Remove from fstab
|
|
$fstab = file_get_contents('/etc/fstab');
|
|
$fstab = preg_replace('/.*' . preg_quote($jailHome, '/') . '.*\n/', '', $fstab);
|
|
file_put_contents('/etc/fstab', $fstab);
|
|
}
|
|
|
|
// Remove from jail passwd/group
|
|
if (file_exists('/var/jail/etc/passwd')) {
|
|
$passwd = file_get_contents('/var/jail/etc/passwd');
|
|
$passwd = preg_replace('/^' . preg_quote($username, '/') . ':.*\n/m', '', $passwd);
|
|
file_put_contents('/var/jail/etc/passwd', $passwd);
|
|
}
|
|
if (file_exists('/var/jail/etc/group')) {
|
|
$group = file_get_contents('/var/jail/etc/group');
|
|
$group = preg_replace('/^' . preg_quote($username, '/') . ':.*\n/m', '', $group);
|
|
file_put_contents('/var/jail/etc/group', $group);
|
|
}
|
|
|
|
logger("Disabled shell access for user $username");
|
|
|
|
return [
|
|
'success' => true,
|
|
'message' => "Shell access disabled for $username (SFTP-only)",
|
|
'shell' => '/usr/sbin/nologin',
|
|
];
|
|
}
|
|
|
|
function sshGetShellStatus(array $params): array
|
|
{
|
|
$username = $params['username'] ?? '';
|
|
|
|
if (!validateUsername($username)) {
|
|
return ['success' => false, 'error' => 'Invalid username'];
|
|
}
|
|
|
|
$userInfo = posix_getpwnam($username);
|
|
if (!$userInfo) {
|
|
return ['success' => false, 'error' => 'User does not exist'];
|
|
}
|
|
|
|
$shell = $userInfo['shell'];
|
|
$hasShell = !in_array($shell, ['/usr/sbin/nologin', '/bin/false', '/sbin/nologin']);
|
|
|
|
// Check group membership
|
|
exec("groups " . escapeshellarg($username), $groupOutput);
|
|
$groups = $groupOutput[0] ?? '';
|
|
$inShellUsers = strpos($groups, 'shellusers') !== false;
|
|
$inSftpUsers = strpos($groups, 'sftpusers') !== false;
|
|
|
|
return [
|
|
'success' => true,
|
|
'username' => $username,
|
|
'shell' => $shell,
|
|
'shell_enabled' => $hasShell && $inShellUsers,
|
|
'sftp_only' => !$hasShell || $inSftpUsers,
|
|
'groups' => $groups,
|
|
];
|
|
}
|
|
|
|
function is_mounted(string $path): bool
|
|
{
|
|
exec("mountpoint -q " . escapeshellarg($path), $output, $returnCode);
|
|
return $returnCode === 0;
|
|
}
|
|
|
|
// ============ SERVER SETTINGS ============
|
|
|
|
function setHostname(array $params): array
|
|
{
|
|
$hostname = $params['hostname'] ?? '';
|
|
|
|
if (empty($hostname) || !preg_match('/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/', $hostname)) {
|
|
return ['success' => false, 'error' => 'Invalid hostname format'];
|
|
}
|
|
|
|
// Set hostname
|
|
exec("hostnamectl set-hostname " . escapeshellarg($hostname) . " 2>&1", $output, $exitCode);
|
|
|
|
if ($exitCode !== 0) {
|
|
return ['success' => false, 'error' => 'Failed to set hostname: ' . implode("\n", $output)];
|
|
}
|
|
|
|
// Update /etc/hosts
|
|
$hosts = file_get_contents('/etc/hosts');
|
|
$shortname = explode('.', $hostname)[0];
|
|
|
|
// Update or add the 127.0.1.1 line
|
|
if (preg_match('/^127\.0\.1\.1\s+.*/m', $hosts)) {
|
|
$hosts = preg_replace('/^127\.0\.1\.1\s+.*/m', "127.0.1.1\t$hostname $shortname", $hosts);
|
|
} else {
|
|
$hosts .= "\n127.0.1.1\t$hostname $shortname\n";
|
|
}
|
|
file_put_contents('/etc/hosts', $hosts);
|
|
|
|
return ['success' => true, 'hostname' => $hostname];
|
|
}
|
|
|
|
function setUploadLimits(array $params): array
|
|
{
|
|
$sizeMb = (int) ($params['size_mb'] ?? 100);
|
|
|
|
// Validate size (1-500 MB)
|
|
if ($sizeMb < 1) $sizeMb = 1;
|
|
if ($sizeMb > 500) $sizeMb = 500;
|
|
|
|
$errors = [];
|
|
|
|
// Update nginx global config
|
|
$nginxConf = '/etc/nginx/nginx.conf';
|
|
if (file_exists($nginxConf)) {
|
|
$content = file_get_contents($nginxConf);
|
|
$content = preg_replace('/client_max_body_size\s+\d+M;/', "client_max_body_size {$sizeMb}M;", $content);
|
|
file_put_contents($nginxConf, $content);
|
|
}
|
|
|
|
// Update jabali site config
|
|
$jabaliSites = glob('/etc/nginx/sites-available/*');
|
|
foreach ($jabaliSites as $site) {
|
|
$content = file_get_contents($site);
|
|
if (strpos($content, 'client_max_body_size') !== false) {
|
|
$content = preg_replace('/client_max_body_size\s+\d+M;/', "client_max_body_size {$sizeMb}M;", $content);
|
|
file_put_contents($site, $content);
|
|
}
|
|
}
|
|
|
|
// Update PHP-FPM pools
|
|
$pools = glob('/etc/php/*/fpm/pool.d/*.conf');
|
|
foreach ($pools as $pool) {
|
|
$content = file_get_contents($pool);
|
|
$content = preg_replace('/^php_admin_value\[upload_max_filesize\].*$/m', "php_admin_value[upload_max_filesize] = {$sizeMb}M", $content);
|
|
$content = preg_replace('/^php_admin_value\[post_max_size\].*$/m', "php_admin_value[post_max_size] = {$sizeMb}M", $content);
|
|
file_put_contents($pool, $content);
|
|
}
|
|
|
|
// Update PHP ini files
|
|
$inis = glob('/etc/php/*/fpm/php.ini');
|
|
foreach ($inis as $ini) {
|
|
$content = file_get_contents($ini);
|
|
$content = preg_replace('/^upload_max_filesize\s*=.*/m', "upload_max_filesize = {$sizeMb}M", $content);
|
|
$content = preg_replace('/^post_max_size\s*=.*/m', "post_max_size = {$sizeMb}M", $content);
|
|
file_put_contents($ini, $content);
|
|
}
|
|
|
|
// Reload services
|
|
exec('systemctl reload nginx 2>&1', $output, $exitCode);
|
|
if ($exitCode !== 0) {
|
|
$errors[] = 'Failed to reload nginx';
|
|
}
|
|
|
|
exec('systemctl reload php*-fpm 2>&1', $output, $exitCode);
|
|
if ($exitCode !== 0) {
|
|
// Try specific version
|
|
exec('systemctl reload php8.4-fpm 2>&1', $output, $exitCode);
|
|
}
|
|
|
|
logger("Upload limits set to {$sizeMb}MB");
|
|
|
|
return [
|
|
'success' => empty($errors),
|
|
'size_mb' => $sizeMb,
|
|
'errors' => $errors
|
|
];
|
|
}
|
|
|
|
function updateBindConfig(array $params): array
|
|
{
|
|
$listenIp = $params['listen_ip'] ?? 'any';
|
|
$forwarders = $params['forwarders'] ?? ['8.8.8.8', '8.8.4.4'];
|
|
|
|
$forwardersList = implode("; ", array_map('trim', $forwarders)) . ";";
|
|
|
|
$config = <<<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 metricsHistory(array $params): array
|
|
{
|
|
// This would read from a stored history file if we implement metric collection
|
|
// For now, return current snapshot
|
|
$points = $params['points'] ?? 60;
|
|
|
|
$history = [
|
|
'cpu' => [],
|
|
'memory' => [],
|
|
'load' => [],
|
|
];
|
|
|
|
// Generate current point
|
|
$cpu = metricsCpu([]);
|
|
$memory = metricsMemory([]);
|
|
$load = getLoadAverage();
|
|
|
|
$history['cpu'][] = [
|
|
'timestamp' => time(),
|
|
'value' => $cpu['data']['usage'] ?? 0,
|
|
];
|
|
|
|
$history['memory'][] = [
|
|
'timestamp' => time(),
|
|
'value' => $memory['data']['usage_percent'] ?? 0,
|
|
];
|
|
|
|
$history['load'][] = [
|
|
'timestamp' => time(),
|
|
'value' => $load['1min'] ?? 0,
|
|
];
|
|
|
|
return [
|
|
'success' => true,
|
|
'data' => $history,
|
|
];
|
|
}
|
|
|
|
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)];
|
|
}
|