Files
server-manager/src/Controllers/ServerController.php

1646 lines
67 KiB
PHP
Executable File

<?php
declare(strict_types=1);
namespace ServerManager\Controllers;
use ServerManager\Core\App;
use ServerManager\Core\Session;
use ServerManager\Core\Validator;
use ServerManager\Models\Server;
use ServerManager\Services\SSHService;
use ServerManager\Services\MonitoringService;
use ServerManager\Services\AuditService;
use ServerManager\Models\CommandHistory;
class ServerController
{
private \ServerManager\Core\View $view;
private Server $serverModel;
private AuditService $auditService;
public function __construct()
{
$this->view = App::getInstance()->getView();
$this->serverModel = new Server();
$this->auditService = new AuditService();
}
public function index(): void
{
$page = (int) ($_GET['page'] ?? 1);
$search = $_GET['search'] ?? '';
$status = $_GET['status'] ?? '';
$group = $_GET['group'] ?? '';
$filters = [];
if ($search) $filters['search'] = $search;
if ($status) $filters['status'] = $status;
if ($group) $filters['group_name'] = $group;
$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->serverModel->findById($id);
if (!$server) {
$this->view->error(404);
}
$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,
]);
}
public function edit(int $id): void
{
$server = $this->serverModel->findById($id);
if (!$server) {
$this->view->error(404);
}
$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->serverModel->findById($id);
if (!$server) {
$this->view->error(404);
}
$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->serverModel->findById($id);
if (!$server) {
$this->view->error(404);
}
$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);
}
$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->serverModel->findById($id);
if (!$server) {
$this->view->json(['success' => false, 'message' => 'Server not found'], 404);
}
$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->serverModel->findById($id);
if (!$server) {
$this->view->error(404);
}
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->serverModel->findById($id);
if (!$server) {
$this->view->error(404);
}
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->serverModel->findById($id);
if (!$server) {
$this->view->error(404);
}
$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);
}
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);
}
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);
}
$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);
}
$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->serverModel->findById($id);
if (!$server) {
$this->view->error(404);
}
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->serverModel->findById($id);
if (!$server) {
$this->view->json(['success' => false, 'message' => 'Server not found'], 404);
}
$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);
}
$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);
}
$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);
}
$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);
}
$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);
}
$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->serverModel->findById($id);
if (!$server) {
$this->view->error(404);
}
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);
}
$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->serverModel->findById($id);
if (!$server) {
$this->view->error(404);
}
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);
}
$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
{
$servers = $this->serverModel->findAll();
$result = [];
foreach ($servers as $s) {
$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 systemUsers(int $id): void
{
$server = $this->serverModel->findById($id);
if (!$server) { $this->view->error(404); }
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);
}
$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
{
$monitoringService = new MonitoringService();
$metrics = $monitoringService->getLatestMetrics($id);
$this->view->json([
'success' => true,
'data' => $metrics,
]);
}
}