view = App::getInstance()->getView(); $this->serverModel = new Server(); $this->auditService = new AuditService(); } private function requireServerAccess(int $serverId, string $minRole = 'viewer'): ?array { $userId = (int) Session::get('user_id'); $server = $this->serverModel->findById($serverId); if (!$server) { $this->view->error(404); } $role = $this->serverModel->getUserRole($serverId, $userId); if ($role === null) { $this->view->error(404); } if ($minRole === 'manager' && $role !== 'owner' && $role !== 'manager') { Session::setFlash('error', 'You do not have permission to perform this action.'); $this->view->redirect("/servers/{$serverId}"); } return $server; } public function index(): void { $page = (int) ($_GET['page'] ?? 1); $search = $_GET['search'] ?? ''; $status = $_GET['status'] ?? ''; $group = $_GET['group'] ?? ''; $userId = (int) Session::get('user_id'); $filters = []; if ($search) $filters['search'] = $search; if ($status) $filters['status'] = $status; if ($group) $filters['group_name'] = $group; $accessibleIds = $this->serverModel->getAccessibleServerIds($userId); $filters['accessible_ids'] = !empty($accessibleIds) ? $accessibleIds : [-1]; $servers = $this->serverModel->getAll($page, 15, $filters); $groups = $this->serverModel->getGroups(); $this->view->display('servers.index', [ 'title' => 'Servers - ServerManager', 'servers' => $servers, 'groups' => $groups, 'search' => $search, 'statusFilter' => $status, 'groupFilter' => $group, ]); } public function create(): void { $groups = $this->serverModel->getGroups(); $this->view->display('servers.create', [ 'title' => 'Add Server - ServerManager', 'groups' => $groups, ]); } public function store(): void { $validator = new Validator(); $rules = [ 'name' => 'required|min:2|max:100', 'ip_address' => 'required', 'ssh_port' => 'required|numeric|port', 'ssh_user' => 'required|min:2|max:50', 'group_name' => 'max:50', ]; if (!$validator->validate($_POST, $rules)) { Session::setFlash('error', $validator->getFirstError()); $this->view->redirect('/servers/create'); } if (empty($_POST['ssh_password']) && empty($_POST['ssh_key'])) { Session::setFlash('error', 'You must provide either an SSH password or a private key.'); $this->view->redirect('/servers/create'); } $data = [ 'name' => $_POST['name'], 'description' => $_POST['description'] ?? '', 'ip_address' => $_POST['ip_address'], 'ssh_port' => (int) $_POST['ssh_port'], 'ssh_user' => $_POST['ssh_user'], 'ssh_password' => $_POST['ssh_password'] ?? null, 'ssh_key' => $_POST['ssh_key'] ?? null, 'group_name' => $_POST['group_name'] ?? null, 'is_active' => (int) ($_POST['is_active'] ?? 1), ]; $id = $this->serverModel->create($data); $this->auditService->log('server_created', 'server', $id, [ 'name' => $data['name'], 'ip_address' => $data['ip_address'], ]); Session::setFlash('success', 'Server added successfully.'); $this->view->redirect('/servers'); } public function show(int $id): void { $server = $this->requireServerAccess($id, 'viewer'); $role = $this->serverModel->getUserRole($id, (int) Session::get('user_id')); $monitoringService = new MonitoringService(); $latestMetrics = $monitoringService->getLatestMetrics($id); $historicalMetrics = $monitoringService->getHistoricalMetrics($id, 24); $this->view->display('servers.show', [ 'title' => $server['name'] . ' - ServerManager', 'server' => $server, 'metrics' => $latestMetrics, 'historicalMetrics' => $historicalMetrics, 'server_role' => $role, ]); } public function edit(int $id): void { $server = $this->requireServerAccess($id, 'manager'); $groups = $this->serverModel->getGroups(); $this->view->display('servers.edit', [ 'title' => 'Edit ' . $server['name'] . ' - ServerManager', 'server' => $server, 'groups' => $groups, ]); } public function update(int $id): void { $server = $this->requireServerAccess($id, 'manager'); $validator = new Validator(); $rules = [ 'name' => 'required|min:2|max:100', 'ip_address' => 'required', 'ssh_port' => 'required|numeric|port', 'ssh_user' => 'required|min:2|max:50', 'group_name' => 'max:50', ]; if (!$validator->validate($_POST, $rules)) { Session::setFlash('error', $validator->getFirstError()); $this->view->redirect("/servers/{$id}/edit"); } $data = [ 'name' => $_POST['name'], 'description' => $_POST['description'] ?? '', 'ip_address' => $_POST['ip_address'], 'ssh_port' => (int) $_POST['ssh_port'], 'ssh_user' => $_POST['ssh_user'], 'group_name' => $_POST['group_name'] ?? null, 'is_active' => (int) ($_POST['is_active'] ?? 1), ]; if (!empty($_POST['ssh_password'])) { $data['ssh_password'] = $_POST['ssh_password']; } if (!empty($_POST['ssh_key'])) { $data['ssh_key'] = $_POST['ssh_key']; } $this->serverModel->update($id, $data); $this->auditService->log('server_updated', 'server', $id, [ 'name' => $data['name'], ]); Session::setFlash('success', 'Server updated successfully.'); $this->view->redirect("/servers/{$id}"); } public function delete(int $id): void { $server = $this->requireServerAccess($id, 'manager'); $this->serverModel->delete($id); $this->auditService->log('server_deleted', 'server', $id, [ 'name' => $server['name'], ]); Session::setFlash('success', 'Server deleted successfully.'); $this->view->redirect('/servers'); } public function toggleActive(int $id): void { $server = $this->serverModel->findById($id); if (!$server) { $this->view->json(['success' => false, 'message' => 'Server not found'], 404); } $userId = (int) Session::get('user_id'); $role = $this->serverModel->getUserRole($id, $userId); if ($role !== 'owner' && $role !== 'manager') { $this->view->json(['success' => false, 'message' => 'Forbidden'], 403); } $newState = $this->serverModel->toggleActive($id); $this->auditService->log( $newState ? 'server_activated' : 'server_deactivated', 'server', $id, ['name' => $server['name']] ); $this->view->json([ 'success' => true, 'is_active' => $newState, 'message' => $newState ? 'Server activated.' : 'Server deactivated.', ]); } public function testConnection(int $id): void { $server = $this->requireServerAccess($id, 'viewer'); $ssh = new SSHService(); $result = $ssh->testConnection($server); $this->auditService->logCredentialAccess($id, 'SSH connection test'); $this->view->json([ 'success' => $result, 'message' => $result ? 'Connection successful.' : 'Connection failed.', ]); } public function processes(int $id): void { $server = $this->requireServerAccess($id, 'viewer'); try { $ssh = new SSHService(); if (!$ssh->connect($server)) { throw new \RuntimeException('Could not establish SSH connection'); } $this->auditService->logCredentialAccess($id, 'View processes'); $result = $ssh->exec( "ps aux --sort=-%cpu | awk 'NR>1{pid=\$2; user=\$1; cpu=\$3; mem=\$4; vsz=\$5; rss=\$6; tty=\$7; stat=\$8; start=\$9; time=\$10; cmd=substr(\$0, index(\$0,\$11)); print user\"|\"pid\"|\"cpu\"|\"mem\"|\"vsz\"|\"rss\"|\"tty\"|\"stat\"|\"start\"|\"time\"|\"cmd}' | head -50" ); $ssh->disconnect(); $processList = []; foreach (explode("\n", trim($result['output'])) as $line) { $parts = explode('|', $line); if (count($parts) >= 11) { $processList[] = [ 'user' => $parts[0], 'pid' => (int) $parts[1], 'cpu' => (float) $parts[2], 'mem' => (float) $parts[3], 'vsz' => (int) $parts[4], 'rss' => (int) $parts[5], 'tty' => $parts[6], 'stat' => $parts[7], 'start' => $parts[8], 'time' => $parts[9], 'command' => $parts[10], ]; } } $this->view->display('servers.processes', [ 'title' => 'Processes - ' . $server['name'] . ' - ServerManager', 'server' => $server, 'processList' => $processList, ]); } catch (\Throwable $e) { Session::setFlash('error', 'Failed to get processes: ' . $e->getMessage()); $this->view->redirect("/servers/{$id}"); } } public function systemInfo(int $id): void { $server = $this->requireServerAccess($id, 'viewer'); try { $ssh = new SSHService(); if (!$ssh->connect($server)) { throw new \RuntimeException('Could not establish SSH connection'); } $this->auditService->logCredentialAccess($id, 'View system info'); $info = $ssh->getSystemInfo(); $ssh->disconnect(); $this->view->display('servers.system_info', [ 'title' => 'System Info - ' . $server['name'] . ' - ServerManager', 'server' => $server, 'systemInfo' => $info, ]); } catch (\Throwable $e) { Session::setFlash('error', 'Failed to get system info: ' . $e->getMessage()); $this->view->redirect("/servers/{$id}"); } } public function logs(int $id): void { $server = $this->requireServerAccess($id, 'viewer'); $logType = $_GET['type'] ?? 'syslog'; $lines = (int) ($_GET['lines'] ?? 100); $logFiles = [ 'syslog' => '/var/log/syslog', 'auth' => '/var/log/auth.log', 'messages' => '/var/log/messages', 'nginx' => '/var/log/nginx/error.log', 'apache' => '/var/log/apache2/error.log', 'mysql' => '/var/log/mysql/error.log', 'dmesg' => 'dmesg', ]; $logOutput = ''; try { $ssh = new SSHService(); if ($ssh->connect($server)) { $this->auditService->logCredentialAccess($id, 'View system logs'); if ($logType === 'dmesg') { $result = $ssh->exec('dmesg | tail -n ' . $lines); } else { $logFile = $logFiles[$logType] ?? $logFiles['syslog']; $result = $ssh->exec("tail -n {$lines} {$logFile} 2>/dev/null || echo 'Log file not found or not accessible'"); } $logOutput = $result['output']; $ssh->disconnect(); } } catch (\Throwable $e) { $logOutput = 'Error: ' . $e->getMessage(); } $this->view->display('servers.logs', [ 'title' => 'System Logs - ' . $server['name'] . ' - ServerManager', 'server' => $server, 'logOutput' => $logOutput, 'logType' => $logType, 'lines' => $lines, 'logFiles' => array_keys($logFiles), ]); } public function reboot(int $id): void { $server = $this->serverModel->findById($id); if (!$server) { $this->view->json(['success' => false, 'message' => 'Server not found'], 404); } $userId = (int) Session::get('user_id'); $role = $this->serverModel->getUserRole($id, $userId); if ($role !== 'owner' && $role !== 'manager') { $this->view->json(['success' => false, 'message' => 'Forbidden'], 403); } try { $ssh = new SSHService(); if (!$ssh->connect($server)) { throw new \RuntimeException('Could not establish SSH connection'); } $this->auditService->logServerAction($id, 'reboot', 'sudo reboot'); $ssh->exec('sudo reboot 2>&1 || echo "Reboot command sent (may require sudo)"'); $ssh->disconnect(); $this->auditService->log('server_rebooted', 'server', $id, ['name' => $server['name']]); $this->view->json([ 'success' => true, 'message' => 'Reboot command sent.', ]); } catch (\Throwable $e) { $this->view->json([ 'success' => false, 'message' => 'Failed to send reboot command: ' . $e->getMessage(), ]); } } public function shutdown(int $id): void { $server = $this->serverModel->findById($id); if (!$server) { $this->view->json(['success' => false, 'message' => 'Server not found'], 404); } $userId = (int) Session::get('user_id'); $role = $this->serverModel->getUserRole($id, $userId); if ($role !== 'owner' && $role !== 'manager') { $this->view->json(['success' => false, 'message' => 'Forbidden'], 403); } try { $ssh = new SSHService(); if (!$ssh->connect($server)) { throw new \RuntimeException('Could not establish SSH connection'); } $this->auditService->logServerAction($id, 'shutdown', 'sudo shutdown -h now'); $ssh->exec('sudo shutdown -h now 2>&1 || echo "Shutdown command sent (may require sudo)"'); $ssh->disconnect(); $this->auditService->log('server_shutdown', 'server', $id, ['name' => $server['name']]); $this->view->json([ 'success' => true, 'message' => 'Shutdown command sent.', ]); } catch (\Throwable $e) { $this->view->json([ 'success' => false, 'message' => 'Failed to send shutdown command: ' . $e->getMessage(), ]); } } public function restartService(int $id): void { $server = $this->serverModel->findById($id); if (!$server) { $this->view->json(['success' => false, 'message' => 'Server not found'], 404); } $userId = (int) Session::get('user_id'); $role = $this->serverModel->getUserRole($id, $userId); if ($role !== 'owner' && $role !== 'manager') { $this->view->json(['success' => false, 'message' => 'Forbidden'], 403); } $service = $_POST['service'] ?? ''; if (empty($service)) { $this->view->json(['success' => false, 'message' => 'Service name is required']); } $allowedServices = ['nginx', 'apache2', 'mysql', 'mariadb', 'postgresql', 'redis', 'docker', 'sshd', 'cron']; $service = preg_replace('/[^a-zA-Z0-9\-_]/', '', $service); try { $ssh = new SSHService(); if (!$ssh->connect($server)) { throw new \RuntimeException('Could not establish SSH connection'); } $this->auditService->logServerAction($id, 'service_restart', "sudo systemctl restart {$service}"); $result = $ssh->exec("sudo systemctl restart {$service} 2>&1"); $ssh->disconnect(); $this->auditService->log('service_restarted', 'server', $id, [ 'name' => $server['name'], 'service' => $service, ]); $this->view->json([ 'success' => true, 'message' => "Service '{$service}' restarted.", 'output' => $result['output'], ]); } catch (\Throwable $e) { $this->view->json([ 'success' => false, 'message' => 'Failed to restart service: ' . $e->getMessage(), ]); } } public function killProcess(int $id): void { $server = $this->serverModel->findById($id); if (!$server) { $this->view->json(['success' => false, 'message' => 'Server not found'], 404); } $userId = (int) Session::get('user_id'); $role = $this->serverModel->getUserRole($id, $userId); if ($role !== 'owner' && $role !== 'manager') { $this->view->json(['success' => false, 'message' => 'Forbidden'], 403); } $pid = (int) ($_POST['pid'] ?? 0); $signal = (int) ($_POST['signal'] ?? 15); if ($pid <= 0) { $this->view->json(['success' => false, 'message' => 'Invalid PID']); } $allowedSignals = [9, 15]; if (!in_array($signal, $allowedSignals, true)) { $signal = 15; } try { $ssh = new SSHService(); if (!$ssh->connect($server)) { throw new \RuntimeException('Could not establish SSH connection'); } $this->auditService->logServerAction($id, 'kill_process', "kill -{$signal} {$pid}"); $result = $ssh->exec("kill -{$signal} {$pid} 2>&1"); $ssh->disconnect(); $exitStatus = $result['exit_status'] ?? 0; $success = $exitStatus === 0; $this->view->json([ 'success' => $success, 'message' => $success ? "Process {$pid} terminated with signal {$signal}." : "Failed to kill process {$pid}: " . ($result['output'] ?: 'Unknown error'), ]); } catch (\Throwable $e) { $this->view->json([ 'success' => false, 'message' => 'Failed to kill process: ' . $e->getMessage(), ]); } } public function nginx(int $id): void { $server = $this->requireServerAccess($id, 'viewer'); try { $ssh = new SSHService(); if (!$ssh->connect($server)) { throw new \RuntimeException('Could not establish SSH connection'); } $this->auditService->logCredentialAccess($id, 'View nginx config'); $installed = trim($ssh->exec('command -v nginx 2>/dev/null || which nginx 2>/dev/null || echo ""')['output'] ?? ''); $nginxFound = !empty($installed); $data = [ 'installed' => $nginxFound, ]; if ($nginxFound) { $data['version'] = trim($ssh->exec('nginx -v 2>&1')['output'] ?? ''); $statusResult = $ssh->exec("systemctl is-active nginx 2>/dev/null || echo 'inactive'"); $data['status'] = trim($statusResult['output'] ?? 'inactive'); $enabledResult = $ssh->exec("systemctl is-enabled nginx 2>/dev/null || echo 'disabled'"); $data['enabled'] = trim($enabledResult['output'] ?? 'disabled'); $confResult = $ssh->exec('cat /etc/nginx/nginx.conf 2>/dev/null || echo "CONFIG_NOT_FOUND"'); $data['config'] = $confResult['output'] === 'CONFIG_NOT_FOUND' ? '' : $confResult['output']; $sitesResult = $ssh->exec('ls -1 /etc/nginx/sites-enabled/ 2>/dev/null || echo ""'); $data['sites_enabled'] = array_filter(explode("\n", trim($sitesResult['output']))); $availResult = $ssh->exec('ls -1 /etc/nginx/sites-available/ 2>/dev/null || echo ""'); $data['sites_available'] = array_filter(explode("\n", trim($availResult['output']))); $testResult = $ssh->exec('sudo nginx -t 2>&1'); $data['config_test'] = trim($testResult['output']); $data['config_valid'] = $testResult['exit_status'] === 0; } $ssh->disconnect(); $this->view->display('servers.nginx', [ 'title' => 'Nginx - ' . $server['name'] . ' - ServerManager', 'server' => $server, 'nginx' => $data, ]); } catch (\Throwable $e) { Session::setFlash('error', 'Failed to get nginx info: ' . $e->getMessage()); $this->view->redirect("/servers/{$id}"); } } public function nginxGetFile(int $id): void { $server = $this->requireServerAccess($id, 'viewer'); $file = $_GET['file'] ?? ''; if (empty($file) || str_contains($file, '..')) { $this->view->json(['success' => false, 'message' => 'Invalid file path']); } $allowedPaths = ['/etc/nginx/nginx.conf', '/etc/nginx/sites-available/', '/etc/nginx/sites-enabled/']; $fullPath = str_starts_with($file, '/etc/nginx/') ? $file : '/etc/nginx/sites-available/' . basename($file); $valid = false; foreach ($allowedPaths as $path) { if (str_starts_with($fullPath, $path)) { $valid = true; break; } } if (!$valid) { $this->view->json(['success' => false, 'message' => 'Access denied']); } try { $ssh = new SSHService(); if (!$ssh->connect($server)) { throw new \RuntimeException('Could not establish SSH connection'); } $result = $ssh->exec("cat " . escapeshellarg($fullPath) . " 2>/dev/null || echo 'FILE_NOT_FOUND'"); $ssh->disconnect(); if (trim($result['output']) === 'FILE_NOT_FOUND') { $this->view->json(['success' => false, 'message' => 'File not found']); } $this->view->json([ 'success' => true, 'path' => $fullPath, 'content' => $result['output'], ]); } catch (\Throwable $e) { $this->view->json(['success' => false, 'message' => $e->getMessage()]); } } public function nginxSaveFile(int $id): void { $server = $this->serverModel->findById($id); if (!$server) { $this->view->json(['success' => false, 'message' => 'Server not found'], 404); } $userId = (int) Session::get('user_id'); $role = $this->serverModel->getUserRole($id, $userId); if ($role !== 'owner' && $role !== 'manager') { $this->view->json(['success' => false, 'message' => 'Forbidden'], 403); } $file = $_POST['file'] ?? ''; $content = $_POST['content'] ?? ''; if (empty($file) || str_contains($file, '..')) { $this->view->json(['success' => false, 'message' => 'Invalid file path']); } $allowedPaths = ['/etc/nginx/nginx.conf', '/etc/nginx/sites-available/']; $fullPath = str_starts_with($file, '/etc/nginx/') ? $file : '/etc/nginx/sites-available/' . basename($file); $valid = false; foreach ($allowedPaths as $path) { if (str_starts_with($fullPath, $path)) { $valid = true; break; } } if (!$valid) { $this->view->json(['success' => false, 'message' => 'Access denied']); } try { $ssh = new SSHService(); if (!$ssh->connect($server)) { throw new \RuntimeException('Could not establish SSH connection'); } $b64 = base64_encode($content); $result = $ssh->exec("echo {$b64} | base64 -d | sudo tee " . escapeshellarg($fullPath) . " > /dev/null 2>&1; echo EXIT:\$?"); $ssh->disconnect(); $this->auditService->logServerAction($id, 'nginx_save_config', $fullPath); $this->view->json([ 'success' => true, 'message' => 'File saved successfully.', ]); } catch (\Throwable $e) { $this->view->json([ 'success' => false, 'message' => 'Failed to save file: ' . $e->getMessage(), ]); } } public function nginxDeleteSite(int $id): void { $server = $this->serverModel->findById($id); if (!$server) { $this->view->json(['success' => false, 'message' => 'Server not found'], 404); } $userId = (int) Session::get('user_id'); $role = $this->serverModel->getUserRole($id, $userId); if ($role !== 'owner' && $role !== 'manager') { $this->view->json(['success' => false, 'message' => 'Forbidden'], 403); } $site = $_POST['site'] ?? ''; if (empty($site) || str_contains($site, '..') || str_contains($site, '/')) { $this->view->json(['success' => false, 'message' => 'Invalid site name']); } try { $ssh = new SSHService(); if (!$ssh->connect($server)) { throw new \RuntimeException('Could not establish SSH connection'); } $this->auditService->logServerAction($id, 'nginx_delete_site', $site); $result = $ssh->exec("sudo /bin/rm /etc/nginx/sites-enabled/" . escapeshellarg($site) . " /etc/nginx/sites-available/" . escapeshellarg($site) . " 2>&1; echo EXIT:\$?"); $exitCode = (int) trim(explode("\n", $result['output'] ?? 'EXIT:-1')[0] ?? '-1'); $ssh->disconnect(); $this->view->json([ 'success' => $exitCode === 0, 'message' => $exitCode === 0 ? "Site '{$site}' deleted." : "Failed to delete site '{$site}'.", ]); } catch (\Throwable $e) { $this->view->json([ 'success' => false, 'message' => 'Failed to delete site: ' . $e->getMessage(), ]); } } public function nginxCreateSite(int $id): void { $server = $this->serverModel->findById($id); if (!$server) { $this->view->json(['success' => false, 'message' => 'Server not found'], 404); } $userId = (int) Session::get('user_id'); $role = $this->serverModel->getUserRole($id, $userId); if ($role !== 'owner' && $role !== 'manager') { $this->view->json(['success' => false, 'message' => 'Forbidden'], 403); } $siteName = trim($_POST['site_name'] ?? ''); $siteRoot = trim($_POST['site_root'] ?? ''); $serverName = trim($_POST['server_name'] ?? ''); $index = trim($_POST['index'] ?? 'index.html index.htm index.php'); $enablePhp = !empty($_POST['enable_php']); $enableSite = !empty($_POST['enable_site']); if (empty($siteName) || empty($siteRoot)) { $this->view->json(['success' => false, 'message' => 'Site name and root directory are required']); } if (!preg_match('/^[a-zA-Z0-9._-]+$/', $siteName)) { $this->view->json(['success' => false, 'message' => 'Invalid site name. Use only letters, numbers, dots, hyphens.']); } if (str_starts_with($siteRoot, '/etc/nginx') || str_contains($siteRoot, '..')) { $this->view->json(['success' => false, 'message' => 'Invalid root directory']); } if (empty($serverName)) { $serverName = $siteName; } $config = "server {\n"; $config .= " listen 80;\n"; $config .= " listen [::]:80;\n\n"; $config .= " root {$siteRoot};\n"; $config .= " index {$index};\n\n"; $config .= " server_name {$serverName};\n\n"; $config .= " location / {\n"; $config .= " try_files \$uri \$uri/ =404;\n"; $config .= " }\n"; if ($enablePhp) { $config .= "\n location ~ \\.php\$ {\n"; $config .= " include snippets/fastcgi-php.conf;\n"; $config .= " fastcgi_pass unix:/var/run/php/php-fpm.sock;\n"; $config .= " }\n"; } $config .= "}\n"; try { $ssh = new SSHService(); if (!$ssh->connect($server)) { throw new \RuntimeException('Could not establish SSH connection'); } $file = "/etc/nginx/sites-available/{$siteName}"; $b64 = base64_encode($config); $result = $ssh->exec("echo {$b64} | base64 -d | sudo tee " . escapeshellarg($file) . " > /dev/null 2>&1; echo EXIT:\$?"); $exitCode = (int) trim($result['output'] ?? '-1'); if ($exitCode !== 0) { throw new \RuntimeException('Failed to create site configuration file'); } $this->auditService->logServerAction($id, 'nginx_create_site', $siteName); if ($enableSite) { $linkResult = $ssh->exec('sudo ln -s ' . escapeshellarg($file) . ' /etc/nginx/sites-enabled/ 2>&1; echo EXIT:$?'); } $ssh->disconnect(); $this->view->json([ 'success' => true, 'message' => "Site '{$siteName}' created successfully." . ($enableSite ? ' Enabled.' : ''), ]); } catch (\Throwable $e) { $this->view->json([ 'success' => false, 'message' => 'Failed to create site: ' . $e->getMessage(), ]); } } public function nginxToggleSite(int $id): void { $server = $this->serverModel->findById($id); if (!$server) { $this->view->json(['success' => false, 'message' => 'Server not found'], 404); } $userId = (int) Session::get('user_id'); $role = $this->serverModel->getUserRole($id, $userId); if ($role !== 'owner' && $role !== 'manager') { $this->view->json(['success' => false, 'message' => 'Forbidden'], 403); } $site = $_POST['site'] ?? ''; $enable = (bool) ($_POST['enable'] ?? false); if (empty($site) || str_contains($site, '..') || str_contains($site, '/')) { $this->view->json(['success' => false, 'message' => 'Invalid site name']); } try { $ssh = new SSHService(); if (!$ssh->connect($server)) { throw new \RuntimeException('Could not establish SSH connection'); } if ($enable) { $result = $ssh->exec('sudo ln -s /etc/nginx/sites-available/' . escapeshellarg($site) . ' /etc/nginx/sites-enabled/ 2>&1; echo EXIT:$?'); } else { $result = $ssh->exec('sudo rm /etc/nginx/sites-enabled/' . escapeshellarg($site) . ' 2>&1; echo EXIT:$?'); } $ssh->disconnect(); $action = $enable ? 'enabled' : 'disabled'; $this->auditService->logServerAction($id, "nginx_{$action}_site", $site); $this->view->json([ 'success' => true, 'message' => "Site '{$site}' {$action}.", ]); } catch (\Throwable $e) { $this->view->json([ 'success' => false, 'message' => 'Failed to toggle site: ' . $e->getMessage(), ]); } } public function nginxAction(int $id): void { $server = $this->serverModel->findById($id); if (!$server) { $this->view->json(['success' => false, 'message' => 'Server not found'], 404); } $userId = (int) Session::get('user_id'); $role = $this->serverModel->getUserRole($id, $userId); if ($role !== 'owner' && $role !== 'manager') { $this->view->json(['success' => false, 'message' => 'Forbidden'], 403); } $action = $_POST['action'] ?? ''; $allowed = ['restart', 'reload', 'test', 'start', 'stop']; if (!in_array($action, $allowed, true)) { $this->view->json(['success' => false, 'message' => 'Invalid action']); } try { $ssh = new SSHService(); if (!$ssh->connect($server)) { throw new \RuntimeException('Could not establish SSH connection'); } $this->auditService->logServerAction($id, "nginx_{$action}", "sudo systemctl {$action} nginx"); if ($action === 'test') { $result = $ssh->exec('sudo nginx -t 2>&1'); $valid = $result['exit_status'] === 0; $this->view->json([ 'success' => $valid, 'message' => $valid ? 'Configuration is valid.' : 'Configuration test failed.', 'output' => trim($result['output']), ]); } else { $result = $ssh->exec("sudo systemctl {$action} nginx 2>&1"); $success = $result['exit_status'] === 0; $msg = $success ? "Nginx {$action}ed successfully." : "Failed to {$action} nginx: " . ($result['output'] ?: 'Unknown error'); $this->view->json(['success' => $success, 'message' => $msg, 'output' => trim($result['output'])]); } $ssh->disconnect(); } catch (\Throwable $e) { $this->view->json([ 'success' => false, 'message' => 'Failed to execute nginx action: ' . $e->getMessage(), ]); } } public function ssl(int $id): void { $server = $this->requireServerAccess($id, 'viewer'); try { $ssh = new SSHService(); if (!$ssh->connect($server)) { throw new \RuntimeException('Could not establish SSH connection'); } $this->auditService->logCredentialAccess($id, 'View SSL certificates'); $installed = trim($ssh->exec('command -v certbot 2>/dev/null || which certbot 2>/dev/null || echo ""')['output'] ?? ''); $certbotFound = !empty($installed); $data = ['certbot_installed' => $certbotFound]; if ($certbotFound) { $data['version'] = trim($ssh->exec('certbot --version 2>&1')['output'] ?? ''); $certsResult = $ssh->exec('sudo certbot certificates 2>&1'); $data['certificates_raw'] = trim($certsResult['output']); $certs = []; $lines = explode("\n", $certsResult['output']); $currentCert = null; foreach ($lines as $line) { if (preg_match('/^ Certificate Name:\s+(.+)$/', $line, $m)) { if ($currentCert) $certs[] = $currentCert; $currentCert = ['name' => trim($m[1]), 'domains' => [], 'expiry' => '']; } elseif ($currentCert && preg_match('/^\s+Domains:\s+(.+)$/', $line, $m)) { $currentCert['domains'] = array_map('trim', explode(' ', trim($m[1]))); } elseif ($currentCert && preg_match('/^\s+Expiry Date:\s+(.+?)(?:\s+\(VALID|$)/', $line, $m)) { $currentCert['expiry'] = trim($m[1]); } elseif ($currentCert && preg_match('/^\s+Certificate Path:\s+(.+)$/', $line, $m)) { $currentCert['path'] = trim($m[1]); } elseif ($currentCert && preg_match('/^\s+Key Type:\s+(.+)$/', $line, $m)) { $currentCert['key_type'] = trim($m[1]); } } if ($currentCert) $certs[] = $currentCert; $data['certificates'] = $certs; $renewalResult = $ssh->exec('certbot renew --dry-run 2>&1 | tail -5'); $data['renewal_status'] = trim($renewalResult['output']); } else { $osResult = $ssh->exec('(. /etc/os-release && echo $ID) 2>/dev/null || echo "unknown"'); $data['os'] = trim($osResult['output'] ?? 'unknown'); } $ssh->disconnect(); $this->view->display('servers.ssl', [ 'title' => 'SSL Certificates - ' . $server['name'] . ' - ServerManager', 'server' => $server, 'ssl' => $data, ]); } catch (\Throwable $e) { Session::setFlash('error', 'Failed to get SSL info: ' . $e->getMessage()); $this->view->redirect("/servers/{$id}"); } } public function sslAction(int $id): void { $server = $this->serverModel->findById($id); if (!$server) { $this->view->json(['success' => false, 'message' => 'Server not found'], 404); } $userId = (int) Session::get('user_id'); $role = $this->serverModel->getUserRole($id, $userId); if ($role !== 'owner' && $role !== 'manager') { $this->view->json(['success' => false, 'message' => 'Forbidden'], 403); } $action = $_POST['action'] ?? ''; $allowed = ['install', 'request', 'renew', 'delete']; if (!in_array($action, $allowed, true)) { $this->view->json(['success' => false, 'message' => 'Invalid action']); } try { $ssh = new SSHService(); if (!$ssh->connect($server)) { throw new \RuntimeException('Could not establish SSH connection'); } $this->auditService->logServerAction($id, "certbot_{$action}", $action); if ($action === 'install') { $osResult = $ssh->exec('(. /etc/os-release && echo $ID) 2>/dev/null || echo "unknown"'); $os = trim($osResult['output'] ?? 'unknown'); if ($os === 'ubuntu' || $os === 'debian') { $result = $ssh->exec('sudo apt-get update -qq 2>&1 && sudo apt-get install -y -qq certbot python3-certbot-nginx 2>&1; echo EXIT:$?'); } elseif ($os === 'centos' || $os === 'fedora' || $os === 'rhel' || str_contains($os, 'rocky') || str_contains($os, 'almalinux')) { $result = $ssh->exec('sudo dnf install -y -q certbot python3-certbot-nginx 2>&1; echo EXIT:$?'); } else { $result = $ssh->exec('sudo apt-get install -y -qq certbot 2>&1 || sudo yum install -y -q certbot 2>&1; echo EXIT:$?'); } $exitCode = (int) trim(explode('EXIT:', $result['output'] ?? 'EXIT:-1')[1] ?? '-1'); $ssh->disconnect(); $this->view->json([ 'success' => $exitCode === 0, 'message' => $exitCode === 0 ? 'Certbot installed successfully.' : 'Failed to install certbot. Try installing manually.', 'output' => trim($result['output']), ]); } elseif ($action === 'request') { $domains = trim($_POST['domains'] ?? ''); $email = trim($_POST['email'] ?? ''); if (empty($domains)) { $this->view->json(['success' => false, 'message' => 'Domain is required']); } $domainList = preg_replace('/\s+/', ',', $domains); $emailArg = !empty($email) ? "--email {$email}" : '--register-unsafely-without-email'; $cmd = "sudo certbot --nginx -d {$domainList} {$emailArg} --non-interactive --agree-tos 2>&1; echo EXIT:\$?"; $result = $ssh->exec($cmd); $exitCode = (int) trim(explode('EXIT:', $result['output'] ?? 'EXIT:-1')[1] ?? '-1'); $ssh->disconnect(); $this->view->json([ 'success' => $exitCode === 0, 'message' => $exitCode === 0 ? 'Certificate obtained successfully.' : 'Failed to obtain certificate.', 'output' => trim($result['output']), ]); } elseif ($action === 'renew') { $result = $ssh->exec('sudo certbot renew --non-interactive 2>&1; echo EXIT:$?'); $exitCode = (int) trim(explode('EXIT:', $result['output'] ?? 'EXIT:-1')[1] ?? '-1'); $ssh->disconnect(); $this->view->json([ 'success' => $exitCode === 0, 'message' => $exitCode === 0 ? 'Certificates renewed.' : 'Renewal attempted with some issues.', 'output' => trim($result['output']), ]); } elseif ($action === 'delete') { $certName = trim($_POST['cert_name'] ?? ''); if (empty($certName)) { $this->view->json(['success' => false, 'message' => 'Certificate name is required']); } $result = $ssh->exec("sudo certbot delete --non-interactive --cert-name " . escapeshellarg($certName) . " 2>&1; echo EXIT:\$?"); $exitCode = (int) trim(explode('EXIT:', $result['output'] ?? 'EXIT:-1')[1] ?? '-1'); $ssh->disconnect(); $this->view->json([ 'success' => $exitCode === 0, 'message' => $exitCode === 0 ? "Certificate '{$certName}' deleted." : 'Failed to delete certificate.', 'output' => trim($result['output']), ]); } else { $ssh->disconnect(); $this->view->json(['success' => false, 'message' => 'Unknown action']); } } catch (\Throwable $e) { $this->view->json([ 'success' => false, 'message' => 'Failed: ' . $e->getMessage(), ]); } } public function database(int $id): void { $server = $this->requireServerAccess($id, 'viewer'); try { $ssh = new SSHService(); if (!$ssh->connect($server)) { throw new \RuntimeException('Could not establish SSH connection'); } $this->auditService->logCredentialAccess($id, 'View database manager'); $mysqlFound = trim($ssh->exec('command -v mysql 2>/dev/null || which mysql 2>/dev/null || echo ""')['output'] ?? ''); $mariadbFound = trim($ssh->exec('command -v mariadb 2>/dev/null || which mariadb 2>/dev/null || echo ""')['output'] ?? ''); $hasMysql = !empty($mysqlFound); $hasMariadb = !empty($mariadbFound); $dbType = ''; $version = ''; $dbStatus = ''; if ($hasMariadb) { $dbType = 'MariaDB'; $version = trim($ssh->exec('mariadb --version 2>&1')['output'] ?? ''); $dbStatus = trim($ssh->exec("systemctl is-active mariadb 2>/dev/null || echo 'inactive'")['output'] ?? 'inactive'); } elseif ($hasMysql) { $versionResult = $ssh->exec('mysql --version 2>&1'); $version = trim($versionResult['output'] ?? ''); if (stripos($version, 'mariadb') !== false) { $dbType = 'MariaDB'; $dbStatus = trim($ssh->exec("systemctl is-active mariadb 2>/dev/null || echo 'inactive'")['output'] ?? 'inactive'); } else { $dbType = 'MySQL'; $dbStatus = trim($ssh->exec("systemctl is-active mysql 2>/dev/null || echo 'inactive'")['output'] ?? 'inactive'); } $hasMysql = true; } $data = [ 'installed' => $hasMysql || $hasMariadb, 'db_type' => $dbType, 'version' => $version, 'status' => $dbStatus, ]; if ($data['installed']) { $mysqlBin = $dbType === 'MariaDB' ? 'mariadb' : 'mysql'; $dbsResult = $ssh->exec("sudo {$mysqlBin} --batch -e \"SELECT TABLE_SCHEMA AS name, ROUND(SUM(DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024, 1) AS size_mb FROM information_schema.TABLES GROUP BY TABLE_SCHEMA ORDER BY size_mb DESC\" 2>/dev/null | tail -n +2 || echo 'ACCESS_DENIED'"); $dbsOutput = trim($dbsResult['output'] ?? ''); $databases = []; if ($dbsOutput && $dbsOutput !== 'NO_ACCESS') { foreach (explode("\n", $dbsOutput) as $line) { $parts = preg_split('/\s+/', trim($line), 2); if (count($parts) === 2) { $databases[] = ['name' => $parts[0], 'size_mb' => (float) $parts[1]]; } } } $data['databases'] = $databases; $mysqlBin = $dbType === 'MariaDB' ? 'mariadb' : 'mysql'; $sizeResult = $ssh->exec("sudo {$mysqlBin} -e \"SELECT ROUND(SUM(DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024, 1) AS total_mb FROM information_schema.TABLES\" 2>/dev/null | tail -1"); $data['total_size_mb'] = (float) trim($sizeResult['output'] ?? '0'); $processResult = $ssh->exec("sudo {$mysqlBin} -e \"SHOW PROCESSLIST\" 2>/dev/null | head -20"); $processes = []; $lines = explode("\n", trim($processResult['output'] ?? '')); $header = true; foreach ($lines as $line) { if ($header) { $header = false; continue; } $parts = preg_split('/\s+\|\s+/', trim($line, '| ')); if (count($parts) >= 5) { $processes[] = [ 'id' => $parts[0], 'user' => $parts[1], 'host' => $parts[2], 'db' => $parts[3] ?? '', 'command' => $parts[4] ?? '', 'time' => $parts[5] ?? '0', 'state' => $parts[6] ?? '', 'info' => $parts[7] ?? '', ]; } } $data['processes'] = array_slice($processes, 0, 20); } $ssh->disconnect(); $this->view->display('servers.database', [ 'title' => "{$dbType} Manager - {$server['name']} - ServerManager", 'server' => $server, 'db' => $data, ]); } catch (\Throwable $e) { Session::setFlash('error', 'Failed to get database info: ' . $e->getMessage()); $this->view->redirect("/servers/{$id}"); } } public function databaseAction(int $id): void { $server = $this->serverModel->findById($id); if (!$server) { $this->view->json(['success' => false, 'message' => 'Server not found'], 404); } $userId = (int) Session::get('user_id'); $role = $this->serverModel->getUserRole($id, $userId); if ($role !== 'owner' && $role !== 'manager') { $this->view->json(['success' => false, 'message' => 'Forbidden'], 403); } $action = $_POST['action'] ?? ''; $allowed = ['query', 'tables', 'structure', 'browse', 'users', 'create_user', 'grant', 'revoke', 'drop_user', 'change_pass']; if (!in_array($action, $allowed, true)) { $this->view->json(['success' => false, 'message' => 'Invalid action']); } try { $ssh = new SSHService(); if (!$ssh->connect($server)) { throw new \RuntimeException('Could not establish SSH connection'); } $this->auditService->logServerAction($id, "db_{$action}", $action); $mysqlBinResult = $ssh->exec('command -v mariadb 2>/dev/null || echo mysql'); $mysqlBin = trim($mysqlBinResult['output'] ?? 'mariadb'); $db = trim($_POST['db'] ?? ''); $table = trim($_POST['table'] ?? ''); if ($action === 'tables') { if (empty($db)) { $this->view->json(['success' => false, 'message' => 'Database is required']); } $escapedDb = str_replace("'", "'\\''", $db); $result = $ssh->exec("sudo {$mysqlBin} --batch -e 'USE `{$escapedDb}`; SHOW TABLE STATUS' 2>&1"); $lines = explode("\n", trim($result['output'] ?? '')); $tables = []; $header = true; foreach ($lines as $line) { if ($header) { $header = false; continue; } $parts = explode("\t", $line); if (count($parts) >= 5) { $tables[] = [ 'name' => $parts[0] ?? '', 'engine' => $parts[1] ?? '', 'rows' => (int) ($parts[4] ?? 0), 'size' => (int) (($parts[6] ?? 0) + ($parts[8] ?? 0)), 'collation' => $parts[14] ?? '', ]; } } $ssh->disconnect(); $this->view->json(['success' => true, 'tables' => $tables]); } elseif ($action === 'structure') { if (empty($db) || empty($table)) { $this->view->json(['success' => false, 'message' => 'Database and table are required']); } $escapedDb = str_replace("'", "'\\''", $db); $escapedTable = str_replace("'", "'\\''", $table); $result = $ssh->exec("sudo {$mysqlBin} --batch -e 'SHOW FULL COLUMNS FROM `{$escapedDb}`.`{$escapedTable}`' 2>&1"); $lines = explode("\n", trim($result['output'] ?? '')); $columns = []; $header = true; foreach ($lines as $line) { if ($header) { $header = false; continue; } if (str_starts_with($line, '---')) continue; $parts = explode("\t", $line); if (count($parts) >= 6) { $columns[] = [ 'field' => $parts[0] ?? '', 'type' => $parts[1] ?? '', 'collation' => $parts[2] ?? '', 'null' => $parts[3] ?? '', 'key' => $parts[4] ?? '', 'default' => $parts[5] ?? '', 'extra' => $parts[6] ?? '', ]; } } $ssh->disconnect(); $this->view->json(['success' => true, 'columns' => $columns]); } elseif ($action === 'browse') { if (empty($db) || empty($table)) { $this->view->json(['success' => false, 'message' => 'Database and table are required']); } $page = max(1, (int) ($_POST['page'] ?? 1)); $limit = 50; $offset = ($page - 1) * $limit; $escapedDb = str_replace("'", "'\\''", $db); $escapedTable = str_replace("'", "'\\''", $table); $colsResult = $ssh->exec("sudo {$mysqlBin} --batch -e 'SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA=\"{$escapedDb}\" AND TABLE_NAME=\"{$escapedTable}\" ORDER BY ORDINAL_POSITION' 2>&1 | tail -n +2"); $columns = array_map('trim', array_filter(explode("\n", trim($colsResult['output'] ?? '')))); $where = ''; $conditions = []; foreach ($columns as $col) { $val = trim($_POST[$col] ?? ''); if ($val !== '') { $escapedVal = str_replace(["\\", "'"], ["\\\\", "'\\''"], $val); $conditions[] = "`{$escapedTable}`.`{$col}` LIKE '%{$escapedVal}%'"; } } if (!empty($conditions)) { $where = ' WHERE ' . implode(' AND ', $conditions); } $escapedWhere = str_replace("'", "'\\''", $where); $countResult = $ssh->exec("sudo {$mysqlBin} --batch -e 'SELECT COUNT(*) AS cnt FROM `{$escapedDb}`.`{$escapedTable}`{$escapedWhere}' 2>&1 | tail -1"); $totalRows = (int) trim($countResult['output'] ?? '0'); $data = []; if ($totalRows > 0) { $result = $ssh->exec("sudo {$mysqlBin} --batch -e 'SELECT * FROM `{$escapedDb}`.`{$escapedTable}`{$escapedWhere} LIMIT {$limit} OFFSET {$offset}' 2>&1 | tail -n +2"); if (trim($result['output'] ?? '')) { foreach (explode("\n", trim($result['output'])) as $line) { $data[] = explode("\t", $line); } } } $ssh->disconnect(); $this->view->json([ 'success' => true, 'columns' => $columns, 'rows' => $data, 'total' => $totalRows, 'page' => $page, 'per_page' => $limit, ]); } elseif ($action === 'users') { $userResult = $ssh->exec("sudo {$mysqlBin} --batch -e \"SELECT User, Host, password_expired FROM mysql.user ORDER BY User\""); $users = []; $userLines = explode("\n", trim($userResult['output'] ?? '')); array_shift($userLines); foreach ($userLines as $line) { $parts = explode("\t", $line); if (count($parts) >= 2 && $parts[0] !== '' && $parts[0] !== 'User') { $users[] = ['user' => $parts[0], 'host' => $parts[1], 'expired' => $parts[2] ?? '', 'locked' => '']; } } $grantsResult = $ssh->exec("sudo {$mysqlBin} --batch -e \"SELECT User, Host, Db FROM mysql.db ORDER BY User, Db\""); $grants = []; $grantLines = explode("\n", trim($grantsResult['output'] ?? '')); array_shift($grantLines); foreach ($grantLines as $line) { $parts = explode("\t", $line); if (count($parts) >= 3 && $parts[0] !== '') { $grants[] = ['user' => $parts[0], 'host' => $parts[1], 'db' => $parts[2], 'table_priv' => '', 'column_priv' => '']; } } $ssh->disconnect(); $this->view->json(['success' => true, 'users' => $users, 'grants' => $grants]); } elseif ($action === 'create_user') { $newUser = trim($_POST['new_user'] ?? ''); $newPass = trim($_POST['new_pass'] ?? ''); $newHost = trim($_POST['new_host'] ?? 'localhost'); if (empty($newUser) || empty($newPass)) { $this->view->json(['success' => false, 'message' => 'Username and password are required']); } if (!preg_match('/^[a-zA-Z0-9_.-]+$/', $newUser)) { $this->view->json(['success' => false, 'message' => 'Invalid username']); } $sqlCreate = "CREATE USER '{$newUser}'@'{$newHost}' IDENTIFIED BY '{$newPass}'"; $b64 = base64_encode($sqlCreate); $result = $ssh->exec("echo {$b64} | base64 -d | sudo {$mysqlBin} 2>&1; echo EXIT:\$?"); $exitCode = (int) trim(explode('EXIT:', $result['output'] ?? 'EXIT:-1')[1] ?? '-1'); $ssh->disconnect(); $this->view->json([ 'success' => $exitCode === 0, 'message' => $exitCode === 0 ? "User '{$newUser}' created." : 'Failed to create user.', 'output' => trim($result['output']), ]); } elseif ($action === 'grant') { $grantUser = trim($_POST['grant_user'] ?? ''); $grantDb = trim($_POST['grant_db'] ?? ''); $grantHost = trim($_POST['grant_host'] ?? 'localhost'); $grantPrivs = trim($_POST['grant_privs'] ?? 'ALL PRIVILEGES'); if (empty($grantUser) || empty($grantDb)) { $this->view->json(['success' => false, 'message' => 'User and database are required']); } $sqlGrant = "GRANT {$grantPrivs} ON {$grantDb}.* TO '{$grantUser}'@'{$grantHost}'; FLUSH PRIVILEGES"; $b64 = base64_encode($sqlGrant); $result = $ssh->exec("echo {$b64} | base64 -d | sudo {$mysqlBin} 2>&1; echo EXIT:\$?"); $exitCode = (int) trim(explode('EXIT:', $result['output'] ?? 'EXIT:-1')[1] ?? '-1'); $ssh->disconnect(); $this->view->json([ 'success' => $exitCode === 0, 'message' => $exitCode === 0 ? "Privileges granted to '{$grantUser}' on {$grantDb}." : 'Failed to grant privileges.', ]); } elseif ($action === 'revoke') { $revokeUser = trim($_POST['revoke_user'] ?? ''); $revokeDb = trim($_POST['revoke_db'] ?? ''); $revokeHost = trim($_POST['revoke_host'] ?? 'localhost'); if (empty($revokeUser) || empty($revokeDb)) { $this->view->json(['success' => false, 'message' => 'User and database are required']); } $sqlRevoke = "REVOKE ALL PRIVILEGES ON {$revokeDb}.* FROM '{$revokeUser}'@'{$revokeHost}'; FLUSH PRIVILEGES"; $b64 = base64_encode($sqlRevoke); $result = $ssh->exec("echo {$b64} | base64 -d | sudo {$mysqlBin} 2>&1; echo EXIT:\$?"); $exitCode = (int) trim(explode('EXIT:', $result['output'] ?? 'EXIT:-1')[1] ?? '-1'); $ssh->disconnect(); $this->view->json([ 'success' => $exitCode === 0, 'message' => $exitCode === 0 ? "Privileges revoked from '{$revokeUser}' on {$revokeDb}." : 'Failed to revoke privileges.', ]); } elseif ($action === 'query') { $query = trim($_POST['query'] ?? ''); if (empty($query)) { $this->view->json(['success' => false, 'message' => 'Query is required']); } $upper = strtoupper(substr(trim($query), 0, 6)); $allowedPrefixes = ['SELECT', 'SHOW', 'DESC', 'EXPLAI']; $blocked = true; foreach ($allowedPrefixes as $prefix) { if (str_starts_with($upper, $prefix)) { $blocked = false; break; } } if ($blocked) { $this->view->json(['success' => false, 'message' => 'Only SELECT/SHOW/DESCRIBE/EXPLAIN queries are allowed']); } $escaped = str_replace("'", "'\\''", $query); $result = $ssh->exec("sudo {$mysqlBin} -e '{$escaped}' 2>&1"); $output = trim($result['output'] ?? ''); $ssh->disconnect(); $this->view->json([ 'success' => !empty($output), 'message' => 'Query executed.', 'output' => $output, ]); } elseif ($action === 'drop_user') { $targetUser = trim($_POST['target_user'] ?? ''); $targetHost = trim($_POST['target_host'] ?? 'localhost'); if (empty($targetUser)) { $this->view->json(['success' => false, 'message' => 'Username required']); } $sql = "DROP USER '{$targetUser}'@'{$targetHost}'"; $b64 = base64_encode($sql); $result = $ssh->exec("echo {$b64} | base64 -d | sudo {$mysqlBin} 2>&1; echo EXIT:\$?"); $exitCode = (int) trim(explode('EXIT:', $result['output'] ?? 'EXIT:-1')[1] ?? '-1'); $ssh->disconnect(); $this->view->json([ 'success' => $exitCode === 0, 'message' => $exitCode === 0 ? "User '{$targetUser}' deleted." : 'Failed to delete user.', ]); } elseif ($action === 'change_pass') { $targetUser = trim($_POST['target_user'] ?? ''); $targetHost = trim($_POST['target_host'] ?? 'localhost'); $newPass = trim($_POST['new_pass'] ?? ''); if (empty($targetUser) || empty($newPass)) { $this->view->json(['success' => false, 'message' => 'Username and password required']); } $sql = "ALTER USER '{$targetUser}'@'{$targetHost}' IDENTIFIED BY '{$newPass}'"; $b64 = base64_encode($sql); $result = $ssh->exec("echo {$b64} | base64 -d | sudo {$mysqlBin} 2>&1; echo EXIT:\$?"); $exitCode = (int) trim(explode('EXIT:', $result['output'] ?? 'EXIT:-1')[1] ?? '-1'); $ssh->disconnect(); $this->view->json([ 'success' => $exitCode === 0, 'message' => $exitCode === 0 ? "Password changed for '{$targetUser}'." : 'Failed to change password.', ]); } } catch (\Throwable $e) { $this->view->json([ 'success' => false, 'message' => 'Failed: ' . $e->getMessage(), ]); } } public function sidebarServers(): void { $userId = (int) Session::get('user_id'); $accessibleIds = $this->serverModel->getAccessibleServerIds($userId); $servers = $this->serverModel->findAll(); $result = []; foreach ($servers as $s) { if (!in_array((int) $s['id'], $accessibleIds, true)) { continue; } $result[] = [ 'id' => $s['id'], 'name' => $s['name'], 'current_status' => $s['current_status'] ?? 'unknown', 'ip_address' => $s['ip_address'] ?? '', ]; } $this->view->json(['success' => true, 'data' => $result]); } public function services(int $id): void { $server = $this->requireServerAccess($id, 'viewer'); try { $ssh = new SSHService(); if (!$ssh->connect($server)) { throw new \RuntimeException('Could not establish SSH connection'); } $this->auditService->logCredentialAccess($id, 'View services'); $listResult = $ssh->exec("systemctl list-units --type=service --all --no-pager --no-legend 2>/dev/null | awk '{print \$1, \$3}'"); $serviceNames = []; foreach (explode("\n", trim($listResult['output'] ?? '')) as $line) { if (preg_match('/^(\S+)\s+(\S+)/', $line, $m)) { $serviceNames[$m[1]] = $m[2] === 'active' ? 'active' : ($m[2] === 'inactive' ? 'inactive' : 'other'); } } $enabledNames = []; $enabledResult = $ssh->exec("systemctl list-unit-files --type=service --no-pager --no-legend 2>/dev/null | awk '\$2==\"enabled\"{print \$1}'"); foreach (explode("\n", trim($enabledResult['output'] ?? '')) as $line) { $enabledNames[trim($line)] = true; } $services = []; foreach ($serviceNames as $name => $status) { $services[] = [ 'name' => $name, 'status' => $status, 'enabled' => isset($enabledNames[$name]), ]; } $ssh->disconnect(); $this->view->display('servers.services', [ 'title' => 'Services - ' . $server['name'] . ' - ServerManager', 'server' => $server, 'services' => $services, ]); } catch (\Throwable $e) { Session::setFlash('error', 'Failed to get services: ' . $e->getMessage()); $this->view->redirect("/servers/{$id}"); } } public function servicesAction(int $id): void { $server = $this->serverModel->findById($id); if (!$server) { $this->view->json(['success' => false, 'message' => 'Server not found'], 404); } $userId = (int) Session::get('user_id'); $role = $this->serverModel->getUserRole($id, $userId); if ($role !== 'owner' && $role !== 'manager') { $this->view->json(['success' => false, 'message' => 'Forbidden'], 403); } $action = $_POST['action'] ?? ''; $service = $_POST['service'] ?? ''; $allowed = ['start', 'stop', 'restart', 'reload', 'enable', 'disable']; if (empty($service) || !in_array($action, $allowed, true)) { $this->view->json(['success' => false, 'message' => 'Invalid action or service']); } try { $ssh = new SSHService(); if (!$ssh->connect($server)) { throw new \RuntimeException('Could not establish SSH connection'); } $this->auditService->logServerAction($id, "service_{$action}", "{$service} {$action}"); $result = $ssh->exec("sudo systemctl {$action} {$service} 2>&1; echo EXIT:\$?"); $exitCode = (int) trim(explode('EXIT:', $result['output'] ?? 'EXIT:-1')[1] ?? '-1'); $ssh->disconnect(); $this->view->json([ 'success' => $exitCode === 0, 'message' => $exitCode === 0 ? "Service '{$service}' {$action}ed." : "Failed to {$action} '{$service}'.", 'output' => trim($result['output']), ]); } catch (\Throwable $e) { $this->view->json(['success' => false, 'message' => 'Failed: ' . $e->getMessage()]); } } public function systemUsers(int $id): void { $server = $this->requireServerAccess($id, 'viewer'); try { $ssh = new SSHService(); if (!$ssh->connect($server)) { throw new \RuntimeException('Could not establish SSH connection'); } $this->auditService->logCredentialAccess($id, 'View system users'); $data = []; $usersResult = $ssh->exec("awk -F: '\$3>=1000||\$3==0{printf \"%s|%s|%s|%s\\n\",\$1,\$3,\$6,\$7}' /etc/passwd 2>/dev/null | sort -t: -k2 -n | head -100"); $users = []; foreach (explode("\n", trim($usersResult['output'] ?? '')) as $line) { $parts = explode("|", $line); if (count($parts) >= 4 && is_numeric($parts[1])) { $users[] = [ 'username' => $parts[0], 'uid' => (int) $parts[1], 'home' => $parts[2], 'shell' => $parts[3], ]; } } $data['users'] = $users; $groupsResult = $ssh->exec("awk -F: '{print \$1}' /etc/group 2>/dev/null | sort | head -50"); $data['groups'] = array_filter(explode("\n", trim($groupsResult['output'] ?? ''))); $totalResult = $ssh->exec("awk -F: '\$3>=1000||\$3==0{print \$1}' /etc/passwd 2>/dev/null | wc -l"); $data['total_users'] = (int) trim($totalResult['output'] ?? '0'); $lastResult = $ssh->exec("last -5 -w 2>/dev/null | head -10 || echo 'No login history'"); $data['last_logins'] = trim($lastResult['output'] ?? ''); $ssh->disconnect(); $this->view->display('servers.system_users', [ 'title' => 'System Users - ' . $server['name'] . ' - ServerManager', 'server' => $server, 'sysusers' => $data, ]); } catch (\Throwable $e) { Session::setFlash('error', 'Failed to get users: ' . $e->getMessage()); $this->view->redirect("/servers/{$id}"); } } public function systemUsersAction(int $id): void { $server = $this->serverModel->findById($id); if (!$server) { $this->view->json(['success' => false, 'message' => 'Server not found'], 404); } $userId = (int) Session::get('user_id'); $role = $this->serverModel->getUserRole($id, $userId); if ($role !== 'owner' && $role !== 'manager') { $this->view->json(['success' => false, 'message' => 'Forbidden'], 403); } $action = $_POST['action'] ?? ''; $allowed = ['add', 'delete', 'lock', 'unlock', 'addgroup']; if (!in_array($action, $allowed, true)) { $this->view->json(['success' => false, 'message' => 'Invalid action']); } try { $ssh = new SSHService(); if (!$ssh->connect($server)) { throw new \RuntimeException('Could not establish SSH connection'); } $this->auditService->logServerAction($id, "sysuser_{$action}", $action); if ($action === 'add') { $username = trim($_POST['username'] ?? ''); $password = trim($_POST['password'] ?? ''); $groups = trim($_POST['groups'] ?? ''); $createHome = !empty($_POST['create_home']); if (empty($username) || empty($password)) { $this->view->json(['success' => false, 'message' => 'Username and password required']); } if (!preg_match('/^[a-z_][a-z0-9_-]*$/', $username)) { $this->view->json(['success' => false, 'message' => 'Invalid username']); } $homeFlag = $createHome ? '-m' : '-M'; $groupsFlag = ''; if (!empty($groups)) { $sanitized = preg_replace('/[^a-z0-9_,-]/', '', $groups); $groupsFlag = " -G {$sanitized}"; } $cmd = "sudo useradd {$homeFlag}{$groupsFlag} {$username} 2>&1; echo EXIT:\$?"; $result = $ssh->exec($cmd); $exitCode = (int) trim(explode('EXIT:', $result['output'] ?? 'EXIT:-1')[1] ?? '-1'); if ($exitCode === 0) { $b64 = base64_encode("{$username}:{$password}"); $ssh->exec("echo {$b64} | base64 -d | sudo chpasswd 2>&1"); } $result = $ssh->exec($cmd); $exitCode = (int) trim(explode('EXIT:', $result['output'] ?? 'EXIT:-1')[1] ?? '-1'); $ssh->disconnect(); $this->view->json([ 'success' => $exitCode === 0, 'message' => $exitCode === 0 ? "User '{$username}' created." : 'Failed to create user.', 'output' => trim($result['output']), ]); } elseif ($action === 'delete') { $username = trim($_POST['username'] ?? ''); $removeHome = !empty($_POST['remove_home']); if (empty($username)) { $this->view->json(['success' => false, 'message' => 'Username required']); } $rmFlag = $removeHome ? '-r' : ''; $result = $ssh->exec("sudo userdel {$rmFlag} {$username} 2>&1; echo EXIT:\$?"); $exitCode = (int) trim(explode('EXIT:', $result['output'] ?? 'EXIT:-1')[1] ?? '-1'); $ssh->disconnect(); $this->view->json([ 'success' => $exitCode === 0, 'message' => $exitCode === 0 ? "User '{$username}' deleted." : 'Failed to delete user.', ]); } elseif ($action === 'lock') { $username = trim($_POST['username'] ?? ''); if (empty($username)) $this->view->json(['success' => false, 'message' => 'Username required']); $result = $ssh->exec("sudo passwd -l {$username} 2>&1; echo EXIT:\$?"); $exitCode = (int) trim(explode('EXIT:', $result['output'] ?? 'EXIT:-1')[1] ?? '-1'); $ssh->disconnect(); $this->view->json([ 'success' => $exitCode === 0, 'message' => $exitCode === 0 ? "User '{$username}' locked." : 'Failed to lock user.', ]); } elseif ($action === 'unlock') { $username = trim($_POST['username'] ?? ''); if (empty($username)) $this->view->json(['success' => false, 'message' => 'Username required']); $result = $ssh->exec("sudo passwd -u {$username} 2>&1; echo EXIT:\$?"); $exitCode = (int) trim(explode('EXIT:', $result['output'] ?? 'EXIT:-1')[1] ?? '-1'); $ssh->disconnect(); $this->view->json([ 'success' => $exitCode === 0, 'message' => $exitCode === 0 ? "User '{$username}' unlocked." : 'Failed to unlock user.', ]); } elseif ($action === 'addgroup') { $group = trim($_POST['group'] ?? ''); if (empty($group)) $this->view->json(['success' => false, 'message' => 'Group name required']); $result = $ssh->exec("sudo groupadd -f {$group} 2>&1; echo EXIT:\$?"); $exitCode = (int) trim(explode('EXIT:', $result['output'] ?? 'EXIT:-1')[1] ?? '-1'); $ssh->disconnect(); $this->view->json([ 'success' => $exitCode === 0, 'message' => $exitCode === 0 ? "Group '{$group}' created." : 'Failed to create group.', ]); } } catch (\Throwable $e) { $this->view->json(['success' => false, 'message' => 'Failed: ' . $e->getMessage()]); } } public function metrics(int $id): void { $server = $this->requireServerAccess($id, 'viewer'); $monitoringService = new MonitoringService(); $metrics = $monitoringService->getLatestMetrics($id); $this->view->json([ 'success' => true, 'data' => $metrics, ]); } }