ServerManager project files

This commit is contained in:
2026-06-06 17:58:34 -04:00
parent ce1863d955
commit 9c0bc7f189
502 changed files with 87837 additions and 0 deletions

View File

@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace ServerManager\Controllers;
use ServerManager\Core\App;
use ServerManager\Core\Session;
use ServerManager\Core\Validator;
use ServerManager\Models\User;
use ServerManager\Services\AuditService;
class AdminController
{
private \ServerManager\Core\View $view;
private User $userModel;
private AuditService $auditService;
public function __construct()
{
$this->view = App::getInstance()->getView();
$this->userModel = new User();
$this->auditService = new AuditService();
}
public function users(): void
{
$page = (int) ($_GET['page'] ?? 1);
$users = $this->userModel->getAll($page, 20);
$this->view->display('admin.users', [
'title' => 'User Management - ServerManager',
'users' => $users,
]);
}
public function createUser(): void
{
$validator = new Validator();
$rules = [
'username' => 'required|min:3|max:50|alphaNum',
'email' => 'required|email',
'password' => 'required|min:8',
'role' => 'required|in:super_admin,admin,operator',
];
if (!$validator->validate($_POST, $rules)) {
$this->view->json(['success' => false, 'message' => $validator->getFirstError()]);
}
$existingUser = $this->userModel->findByUsername($_POST['username']);
if ($existingUser) {
$this->view->json(['success' => false, 'message' => 'Username already exists.']);
}
$existingEmail = $this->userModel->findByEmail($_POST['email']);
if ($existingEmail) {
$this->view->json(['success' => false, 'message' => 'Email already exists.']);
}
$id = $this->userModel->create([
'username' => $_POST['username'],
'email' => $_POST['email'],
'password' => $_POST['password'],
'role' => $_POST['role'],
'is_active' => 1,
]);
$this->auditService->logUserManagement($id, 'user_created');
$this->view->json([
'success' => true,
'message' => 'User created successfully.',
'user_id' => $id,
]);
}
public function updateUser(int $id): void
{
$user = $this->userModel->findById($id);
if (!$user) {
$this->view->json(['success' => false, 'message' => 'User not found'], 404);
}
$updateData = [];
if (!empty($_POST['email'])) {
$updateData['email'] = $_POST['email'];
}
if (!empty($_POST['role'])) {
$updateData['role'] = $_POST['role'];
}
if (!empty($_POST['password'])) {
$updateData['password'] = $_POST['password'];
}
if (isset($_POST['is_active'])) {
$updateData['is_active'] = (int) $_POST['is_active'];
}
if (empty($updateData)) {
$this->view->json(['success' => false, 'message' => 'No data to update.']);
}
$this->userModel->update($id, $updateData);
$this->auditService->logUserManagement($id, 'user_updated');
$this->view->json([
'success' => true,
'message' => 'User updated successfully.',
]);
}
public function deleteUser(int $id): void
{
$user = $this->userModel->findById($id);
if (!$user) {
$this->view->json(['success' => false, 'message' => 'User not found'], 404);
}
if ($user['id'] === Session::get('user_id')) {
$this->view->json(['success' => false, 'message' => 'You cannot delete your own account.']);
}
$this->userModel->delete($id);
$this->auditService->logUserManagement($id, 'user_deleted');
$this->view->json([
'success' => true,
'message' => 'User deleted successfully.',
]);
}
public function auditLogs(): void
{
$page = (int) ($_GET['page'] ?? 1);
$action = $_GET['action'] ?? '';
$userId = $_GET['user_id'] ?? '';
$filters = [];
if ($action) $filters['action'] = $action;
if ($userId) $filters['user_id'] = (int) $userId;
$logs = $this->auditService->getAuditLogs($page, 50, $filters);
$this->view->display('admin.audit', [
'title' => 'Audit Logs - ServerManager',
'logs' => $logs,
'action' => $action,
'userId' => $userId,
]);
}
public function securitySettings(): void
{
$this->view->display('admin.security', [
'title' => 'Security Settings - ServerManager',
]);
}
}

283
src/Controllers/ApiController.php Executable file
View File

@@ -0,0 +1,283 @@
<?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\Models\User;
use ServerManager\Services\MonitoringService;
use ServerManager\Services\SSHService;
use ServerManager\Services\AuditService;
use ServerManager\Models\CommandHistory;
class ApiController
{
private \ServerManager\Core\View $view;
public function __construct()
{
$this->view = App::getInstance()->getView();
}
private function authenticateRequest(): ?array
{
$token = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
$token = str_replace('Bearer ', '', $token);
if (empty($token) && isset($_GET['api_token'])) {
$token = $_GET['api_token'];
}
if (empty($token)) {
$this->view->json(['error' => 'API token is required'], 401);
}
$userModel = new User();
$user = $userModel->findByApiToken($token);
if (!$user) {
$this->view->json(['error' => 'Invalid API token'], 401);
}
return $user;
}
public function status(): void
{
$this->authenticateRequest();
$monitoringService = new MonitoringService();
$stats = $monitoringService->getDashboardStats();
$this->view->json([
'success' => true,
'data' => [
'application' => 'ServerManager',
'version' => '1.0.0',
'stats' => $stats,
'timestamp' => date('c'),
],
]);
}
public function servers(): void
{
$user = $this->authenticateRequest();
$serverModel = new Server();
$page = (int) ($_GET['page'] ?? 1);
$perPage = (int) ($_GET['per_page'] ?? 20);
$search = $_GET['search'] ?? '';
$filters = [];
if ($search) $filters['search'] = $search;
$servers = $serverModel->getAll($page, $perPage, $filters);
$this->view->json([
'success' => true,
'data' => $servers['data'],
'pagination' => [
'page' => $servers['page'],
'per_page' => $servers['per_page'],
'total' => $servers['total'],
'total_pages' => $servers['total_pages'],
],
]);
}
public function serverDetail(int $id): void
{
$user = $this->authenticateRequest();
$serverModel = new Server();
$server = $serverModel->findById($id);
if (!$server) {
$this->view->json(['error' => 'Server not found'], 404);
}
$monitoringService = new MonitoringService();
$metrics = $monitoringService->getLatestMetrics($id);
$this->view->json([
'success' => true,
'data' => [
'server' => $server,
'metrics' => $metrics,
],
]);
}
public function serverAdd(): void
{
$user = $this->authenticateRequest();
$input = json_decode(file_get_contents('php://input'), true) ?? $_POST;
$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',
];
if (!$validator->validate($input, $rules)) {
$this->view->json(['error' => $validator->getFirstError()], 400);
}
$serverModel = new Server();
$id = $serverModel->create($input);
$this->view->json([
'success' => true,
'message' => 'Server created successfully.',
'server_id' => $id,
], 201);
}
public function serverUpdate(int $id): void
{
$user = $this->authenticateRequest();
$input = json_decode(file_get_contents('php://input'), true) ?? $_POST;
$serverModel = new Server();
$server = $serverModel->findById($id);
if (!$server) {
$this->view->json(['error' => 'Server not found'], 404);
}
$serverModel->update($id, $input);
$this->view->json([
'success' => true,
'message' => 'Server updated successfully.',
]);
}
public function serverDelete(int $id): void
{
$user = $this->authenticateRequest();
$serverModel = new Server();
$server = $serverModel->findById($id);
if (!$server) {
$this->view->json(['error' => 'Server not found'], 404);
}
$serverModel->delete($id);
$this->view->json([
'success' => true,
'message' => 'Server deleted successfully.',
]);
}
public function serverMetrics(int $id): void
{
$user = $this->authenticateRequest();
$monitoringService = new MonitoringService();
$hours = (int) ($_GET['hours'] ?? 24);
if ($hours > 168) $hours = 168;
$metrics = $monitoringService->getHistoricalMetrics($id, $hours);
$this->view->json([
'success' => true,
'data' => $metrics,
]);
}
public function executeCommand(int $id): void
{
$user = $this->authenticateRequest();
$input = json_decode(file_get_contents('php://input'), true) ?? $_POST;
$command = $input['command'] ?? '';
if (empty($command)) {
$this->view->json(['error' => 'Command is required'], 400);
}
$serverModel = new Server();
$server = $serverModel->findById($id);
if (!$server) {
$this->view->json(['error' => 'Server not found'], 404);
}
try {
$ssh = new SSHService();
if (!$ssh->connect($server)) {
throw new \RuntimeException('Could not establish SSH connection');
}
$result = $ssh->exec($command);
$ssh->disconnect();
$commandHistory = new CommandHistory();
$commandHistory->log(
$id,
$user['id'],
$command,
$result['output'],
$result['exit_status'] ?? 0
);
$auditService = new AuditService();
$auditService->logServerAction($id, 'api_command_executed', $command);
$this->view->json([
'success' => true,
'data' => [
'output' => $result['output'],
'exit_status' => $result['exit_status'],
'stderr' => $result['stderr'],
],
]);
} catch (\Throwable $e) {
$this->view->json([
'error' => 'Command execution failed: ' . $e->getMessage(),
], 500);
}
}
public function users(): void
{
$currentUser = $this->authenticateRequest();
if ($currentUser['role'] !== 'super_admin' && $currentUser['role'] !== 'admin') {
$this->view->json(['error' => 'Forbidden'], 403);
}
$userModel = new User();
$users = $userModel->getAll(1, 100);
$this->view->json([
'success' => true,
'data' => $users['data'],
]);
}
public function refreshAllMetrics(): void
{
$user = $this->authenticateRequest();
$monitoringService = new MonitoringService();
$results = $monitoringService->collectAllMetrics();
$this->view->json([
'success' => true,
'data' => $results,
]);
}
}

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace ServerManager\Controllers;
use ServerManager\Core\App;
use ServerManager\Core\Session;
use ServerManager\Core\Validator;
use ServerManager\Models\User;
use ServerManager\Services\AuditService;
class AuthController
{
private User $userModel;
private AuditService $auditService;
private \ServerManager\Core\View $view;
public function __construct()
{
$this->userModel = new User();
$this->auditService = new AuditService();
$this->view = App::getInstance()->getView();
}
public function loginForm(): void
{
if (Session::has('user_id')) {
$this->view->redirect('/dashboard');
}
$this->view->setLayout('auth');
$this->view->display('auth.login', [
'title' => 'Login - ServerManager',
]);
}
public function login(): void
{
$validator = new Validator();
$rules = [
'username' => 'required',
'password' => 'required|min:6',
];
if (!$validator->validate($_POST, $rules)) {
$error = $validator->getFirstError();
$this->auditService->logLogin($_POST['username'] ?? 'unknown', false, $error);
Session::setFlash('error', $error);
$this->view->redirect('/login');
}
$username = $_POST['username'];
$password = $_POST['password'];
$user = $this->userModel->authenticate($username, $password);
if (!$user) {
$this->auditService->logLogin($username, false, 'Invalid credentials');
Session::setFlash('error', 'Invalid username or password.');
$this->view->redirect('/login');
}
Session::regenerate(true);
Session::set('user_id', $user['id']);
Session::set('username', $user['username']);
Session::set('user_role', $user['role']);
Session::set('user_email', $user['email']);
Session::set('login_time', time());
$lifetime = App::getConfig()['session']['lifetime'] ?? 1440;
Session::set('session_expiry', time() + $lifetime);
$this->auditService->logLogin($username, true);
$intended = Session::get('intended_url', '/dashboard');
Session::remove('intended_url');
$this->view->redirect($intended);
}
public function logout(): void
{
$this->auditService->log(
'logout',
'user',
Session::get('user_id'),
['username' => Session::get('username')]
);
Session::destroy();
$this->view->redirect('/login');
}
public function profile(): void
{
$userId = Session::get('user_id');
$user = $this->userModel->findById($userId);
if (!$user) {
$this->view->error(404);
}
$this->view->display('auth.profile', [
'title' => 'My Profile - ServerManager',
'user' => $user,
]);
}
public function updateProfile(): void
{
$userId = Session::get('user_id');
$validator = new Validator();
$rules = [
'email' => 'required|email',
'current_password' => 'required',
'new_password' => 'min:8',
'confirm_password' => 'match:new_password',
];
if (!$validator->validate($_POST, $rules)) {
Session::setFlash('error', $validator->getFirstError());
$this->view->redirect('/profile');
}
$user = $this->userModel->findById($userId);
$security = new \ServerManager\Core\Security();
if (!$security->verifyPassword($_POST['current_password'], $user['password_hash'])) {
Session::setFlash('error', 'Current password is incorrect.');
$this->view->redirect('/profile');
}
$updateData = ['email' => $_POST['email']];
if (!empty($_POST['new_password'])) {
$updateData['password'] = $_POST['new_password'];
}
$this->userModel->update($userId, $updateData);
$this->auditService->logUserManagement($userId, 'profile_updated');
Session::setFlash('success', 'Profile updated successfully.');
$this->view->redirect('/profile');
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace ServerManager\Controllers;
use ServerManager\Core\App;
use ServerManager\Models\Server;
use ServerManager\Services\MonitoringService;
use ServerManager\Services\AuditService;
class DashboardController
{
private \ServerManager\Core\View $view;
private MonitoringService $monitoringService;
private AuditService $auditService;
public function __construct()
{
$this->view = App::getInstance()->getView();
$this->monitoringService = new MonitoringService();
$this->auditService = new AuditService();
}
public function index(): void
{
$stats = $this->monitoringService->getDashboardStats();
$recentActivity = $this->auditService->getRecentActivity(10);
$serverModel = new Server();
$servers = $serverModel->findAll(true);
$this->view->display('dashboard.index', [
'title' => 'Dashboard - ServerManager',
'stats' => $stats,
'servers' => $servers,
'recentActivity' => $recentActivity,
]);
}
public function stats(): void
{
$stats = $this->monitoringService->getDashboardStats();
$this->view->json([
'success' => true,
'data' => $stats,
]);
}
public function refreshMetrics(): void
{
$this->monitoringService->collectAllMetrics();
$this->view->json([
'success' => true,
'message' => 'Metrics refreshed',
]);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace ServerManager\Controllers;
use ServerManager\Core\App;
use ServerManager\Core\Session;
use ServerManager\Models\Server;
use ServerManager\Services\SSHService;
use ServerManager\Services\AuditService;
use ServerManager\Models\CommandHistory;
class TerminalController
{
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(int $id): void
{
$server = $this->serverModel->findById($id);
if (!$server) {
$this->view->error(404);
}
$commandHistory = new CommandHistory();
$history = $commandHistory->getByServer($id, 50);
$this->view->display('terminal.index', [
'title' => 'Terminal - ' . $server['name'] . ' - ServerManager',
'server' => $server,
'commandHistory' => $history,
]);
}
public function execute(int $id): void
{
$server = $this->serverModel->findById($id);
if (!$server) {
$this->view->json(['success' => false, 'message' => 'Server not found'], 404);
}
$command = $_POST['command'] ?? '';
if (empty($command)) {
$this->view->json(['success' => false, 'message' => 'Command is required']);
}
$blockedCommands = ['rm -rf /', 'mkfs', 'dd if=', ':(){ :|:& };:'];
foreach ($blockedCommands as $blocked) {
if (stripos($command, $blocked) !== false) {
$this->auditService->log('blocked_command', 'server', $id, [
'command' => $command,
'reason' => 'Blocked dangerous command pattern',
]);
$this->view->json([
'success' => false,
'message' => 'This command has been blocked for safety reasons.',
]);
}
}
try {
$ssh = new SSHService();
if (!$ssh->connect($server)) {
throw new \RuntimeException('Could not establish SSH connection');
}
$this->auditService->logCredentialAccess($id, 'Terminal command execution');
$result = $ssh->exec($command);
$ssh->disconnect();
$commandHistory = new CommandHistory();
$commandHistory->log(
$id,
Session::get('user_id'),
$command,
$result['output'],
$result['exit_status'] ?? 0
);
$this->auditService->logServerAction($id, 'command_executed', $command);
$this->view->json([
'success' => true,
'output' => $result['output'],
'exit_status' => $result['exit_status'],
'stderr' => $result['stderr'],
]);
} catch (\Throwable $e) {
$this->view->json([
'success' => false,
'message' => 'Failed to execute command: ' . $e->getMessage(),
]);
}
}
public function history(int $id): void
{
$commandHistory = new CommandHistory();
$history = $commandHistory->getByServer($id, 100);
$this->view->json([
'success' => true,
'data' => $history,
]);
}
public function clearHistory(int $id): void
{
$commandHistory = new CommandHistory();
$commandHistory->clearHistory($id);
$this->auditService->log('command_history_cleared', 'server', $id);
$this->view->json([
'success' => true,
'message' => 'Command history cleared.',
]);
}
}

159
src/Core/App.php Executable file
View File

@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace ServerManager\Core;
use Dotenv\Dotenv;
class App
{
private static ?App $instance = null;
private Router $router;
private Security $security;
private View $view;
private array $config;
private Encryption $encryption;
private bool $booted = false;
private function __construct()
{
}
public static function getInstance(): self
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public static function getConfig(): array
{
return self::getInstance()->config;
}
public static function getEncryption(): Encryption
{
return self::getInstance()->encryption;
}
public function boot(string $basePath): void
{
if ($this->booted) {
return;
}
$this->loadEnvironment($basePath);
$this->setErrorHandling();
$this->config = require $basePath . '/config/config.php';
date_default_timezone_set($this->config['app']['timezone'] ?? 'UTC');
Database::getInstance($this->config['database']);
Session::init($this->config['session']);
Logger::init(
$basePath . '/' . ($this->config['log']['path'] ?? 'logs/'),
$this->config['log']['level'] ?? 'warning'
);
$this->security = new Security();
$this->encryption = new Encryption($this->config['encryption']);
$this->view = new View($this->security);
$this->router = new Router();
$this->loadRoutes($basePath . '/routes/');
$this->booted = true;
}
public function run(): void
{
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$uri = $_SERVER['REQUEST_URI'] ?? '/';
try {
$this->router->dispatch($method, $uri);
} catch (\Throwable $e) {
Logger::getInstance()->error($e->getMessage(), [
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => $e->getTraceAsString(),
]);
if ($this->config['app']['debug'] ?? false) {
throw $e;
}
$this->view->error(500, 'An internal server error occurred.');
}
}
public function getRouter(): Router
{
return $this->router;
}
public function getSecurity(): Security
{
return $this->security;
}
public function getView(): View
{
return $this->view;
}
private function loadEnvironment(string $basePath): void
{
if (file_exists($basePath . '/.env')) {
$dotenv = Dotenv::createImmutable($basePath);
$dotenv->load();
$dotenv->required([
'APP_NAME',
'DB_HOST',
'DB_DATABASE',
'DB_USERNAME',
]);
}
}
private function setErrorHandling(): void
{
error_reporting(E_ALL);
ini_set('display_errors', '0');
ini_set('log_errors', '1');
set_exception_handler(function (\Throwable $e) {
Logger::getInstance()->critical($e->getMessage(), [
'file' => $e->getFile(),
'line' => $e->getLine(),
'code' => $e->getCode(),
]);
http_response_code(500);
echo json_encode(['error' => 'Internal Server Error']);
exit;
});
set_error_handler(function ($severity, $message, $file, $line) {
if (!(error_reporting() & $severity)) {
return false;
}
if ($severity === E_DEPRECATED || $severity === E_USER_DEPRECATED) {
return false;
}
throw new \ErrorException($message, 0, $severity, $file, $line);
});
}
private function loadRoutes(string $routesPath): void
{
if (is_dir($routesPath)) {
foreach (glob($routesPath . '*.php') as $routeFile) {
$route = $this->router;
require $routeFile;
}
}
}
}

122
src/Core/Database.php Executable file
View File

@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace ServerManager\Core;
use PDO;
use PDOException;
class Database
{
private static ?Database $instance = null;
private PDO $pdo;
private function __construct(array $config)
{
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$config['host'],
$config['port'],
$config['database'],
$config['charset'] ?? 'utf8mb4'
);
$initCommand = PHP_VERSION_ID >= 80500
? \Pdo\Mysql::ATTR_INIT_COMMAND
: \PDO::MYSQL_ATTR_INIT_COMMAND;
try {
$this->pdo = new PDO($dsn, $config['username'], $config['password'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
$initCommand => 'SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci',
]);
} catch (PDOException $e) {
throw new \RuntimeException('Database connection failed: ' . $e->getMessage());
}
}
public static function getInstance(array $config = []): self
{
if (self::$instance === null) {
if (empty($config)) {
throw new \RuntimeException('Database configuration is required for first initialization');
}
self::$instance = new self($config);
}
return self::$instance;
}
public static function resetInstance(): void
{
self::$instance = null;
}
public function getPdo(): PDO
{
return $this->pdo;
}
public function query(string $sql, array $params = []): \PDOStatement
{
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt;
}
public function fetch(string $sql, array $params = []): ?array
{
$result = $this->query($sql, $params)->fetch();
return $result ?: null;
}
public function fetchAll(string $sql, array $params = []): array
{
return $this->query($sql, $params)->fetchAll();
}
public function insert(string $table, array $data): int
{
$columns = implode(', ', array_keys($data));
$placeholders = implode(', ', array_fill(0, count($data), '?'));
$sql = "INSERT INTO {$table} ({$columns}) VALUES ({$placeholders})";
$this->query($sql, array_values($data));
return (int) $this->pdo->lastInsertId();
}
public function update(string $table, array $data, string $where, array $whereParams = []): int
{
$sets = implode(', ', array_map(fn($col) => "{$col} = ?", array_keys($data)));
$sql = "UPDATE {$table} SET {$sets} WHERE {$where}";
$stmt = $this->query($sql, array_merge(array_values($data), $whereParams));
return $stmt->rowCount();
}
public function delete(string $table, string $where, array $params = []): int
{
$sql = "DELETE FROM {$table} WHERE {$where}";
$stmt = $this->query($sql, $params);
return $stmt->rowCount();
}
public function beginTransaction(): bool
{
return $this->pdo->beginTransaction();
}
public function commit(): bool
{
return $this->pdo->commit();
}
public function rollback(): bool
{
return $this->pdo->rollBack();
}
}

92
src/Core/Encryption.php Executable file
View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace ServerManager\Core;
class Encryption
{
private string $method;
private string $masterKey;
private const KEY_HASH_ALGO = 'sha256';
public function __construct(array $config)
{
$this->method = $config['method'] ?? 'aes-256-cbc';
if (!empty($config['master_key'])) {
$this->masterKey = $config['master_key'];
} elseif (!empty($config['master_key_file']) && file_exists($config['master_key_file'])) {
$this->masterKey = trim(file_get_contents($config['master_key_file']));
} else {
$keyFile = $config['master_key_file'] ?? '/etc/servermanager/master.key';
if (file_exists($keyFile)) {
$this->masterKey = trim(file_get_contents($keyFile));
} else {
throw new \RuntimeException(
'Master encryption key not found. Generate one with: php bin/install.php --generate-key'
);
}
}
if (strlen($this->masterKey) < 32) {
throw new \RuntimeException('Master encryption key must be at least 32 characters (256 bits)');
}
}
public function encrypt(string $plaintext): string
{
$ivLength = openssl_cipher_iv_length($this->method);
$iv = random_bytes($ivLength);
$key = hash(self::KEY_HASH_ALGO, $this->masterKey, true);
$encrypted = openssl_encrypt($plaintext, $this->method, $key, OPENSSL_RAW_DATA, $iv);
if ($encrypted === false) {
throw new \RuntimeException('Encryption failed');
}
return base64_encode($iv . $encrypted);
}
public function decrypt(string $ciphertext): string
{
$data = base64_decode($ciphertext);
$ivLength = openssl_cipher_iv_length($this->method);
$iv = substr($data, 0, $ivLength);
$encrypted = substr($data, $ivLength);
$key = hash(self::KEY_HASH_ALGO, $this->masterKey, true);
$decrypted = openssl_decrypt($encrypted, $this->method, $key, OPENSSL_RAW_DATA, $iv);
if ($decrypted === false) {
throw new \RuntimeException('Decryption failed');
}
return $decrypted;
}
public function encryptArray(array $data, array $fields): array
{
foreach ($fields as $field) {
if (!empty($data[$field])) {
$data[$field] = $this->encrypt($data[$field]);
}
}
return $data;
}
public function decryptArray(array $data, array $fields): array
{
foreach ($fields as $field) {
if (!empty($data[$field])) {
try {
$data[$field] = $this->decrypt($data[$field]);
} catch (\RuntimeException $e) {
$data[$field] = null;
}
}
}
return $data;
}
}

148
src/Core/Logger.php Executable file
View File

@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace ServerManager\Core;
class Logger
{
private static ?Logger $instance = null;
private string $logPath;
private string $logLevel;
private array $levels = [
'debug' => 0,
'info' => 1,
'notice' => 2,
'warning' => 3,
'error' => 4,
'critical' => 5,
'alert' => 6,
'emergency' => 7,
];
private function __construct()
{
}
public static function getInstance(): self
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public static function init(string $path, string $level = 'warning'): void
{
$instance = self::getInstance();
$instance->logPath = rtrim($path, '/') . '/';
$instance->logLevel = $level;
if (!is_dir($instance->logPath)) {
mkdir($instance->logPath, 0750, true);
}
if (!is_writable($instance->logPath)) {
throw new \RuntimeException("Log path is not writable: {$instance->logPath}");
}
}
public function log(string $level, string $message, array $context = []): void
{
if (!$this->shouldLog($level)) {
return;
}
$timestamp = date('Y-m-d H:i:s');
$contextJson = !empty($context) ? ' ' . json_encode($context, JSON_UNESCAPED_SLASHES) : '';
$userInfo = $this->getUserInfo();
$logEntry = sprintf(
"[%s] %s.%s: %s%s %s\n",
$timestamp,
strtoupper($level),
$userInfo,
$message,
$contextJson,
$this->getRequestInfo()
);
$this->write($level, $logEntry);
}
public function debug(string $message, array $context = []): void
{
$this->log('debug', $message, $context);
}
public function info(string $message, array $context = []): void
{
$this->log('info', $message, $context);
}
public function notice(string $message, array $context = []): void
{
$this->log('notice', $message, $context);
}
public function warning(string $message, array $context = []): void
{
$this->log('warning', $message, $context);
}
public function error(string $message, array $context = []): void
{
$this->log('error', $message, $context);
}
public function critical(string $message, array $context = []): void
{
$this->log('critical', $message, $context);
}
public function alert(string $message, array $context = []): void
{
$this->log('alert', $message, $context);
}
public function emergency(string $message, array $context = []): void
{
$this->log('emergency', $message, $context);
}
private function shouldLog(string $level): bool
{
$currentLevel = $this->levels[$this->logLevel] ?? 3;
$messageLevel = $this->levels[$level] ?? 3;
return $messageLevel >= $currentLevel;
}
private function write(string $level, string $entry): void
{
$filename = $this->logPath . 'app-' . date('Y-m-d') . '.log';
file_put_contents($filename, $entry, FILE_APPEND | LOCK_EX);
if (in_array($level, ['error', 'critical', 'alert', 'emergency'], true)) {
$errorFile = $this->logPath . 'error-' . date('Y-m-d') . '.log';
file_put_contents($errorFile, $entry, FILE_APPEND | LOCK_EX);
}
}
private function getUserInfo(): string
{
$userId = Session::get('user_id');
$username = Session::get('username');
if ($userId) {
return "[User:{$userId}:{$username}]";
}
return '[Guest]';
}
private function getRequestInfo(): string
{
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$method = $_SERVER['REQUEST_METHOD'] ?? 'CLI';
$uri = $_SERVER['REQUEST_URI'] ?? '';
return "[{$ip}] [{$method} {$uri}]";
}
}

143
src/Core/Router.php Executable file
View File

@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace ServerManager\Core;
class Router
{
private array $routes = [];
private array $groupStack = [];
private ?string $currentGroup = null;
private array $groupMiddlewares = [];
private array $middlewares = [];
private array $patterns = [
':id' => '(\d+)',
':uuid' => '([a-f0-9\-]{36})',
':slug' => '([a-zA-Z0-9\-]+)',
':any' => '([^/]+)',
':all' => '(.*)',
];
public function get(string $path, callable|array $handler, array $middleware = []): self
{
return $this->addRoute('GET', $path, $handler, $middleware);
}
public function post(string $path, callable|array $handler, array $middleware = []): self
{
return $this->addRoute('POST', $path, $handler, $middleware);
}
public function put(string $path, callable|array $handler, array $middleware = []): self
{
return $this->addRoute('PUT', $path, $handler, $middleware);
}
public function delete(string $path, callable|array $handler, array $middleware = []): self
{
return $this->addRoute('DELETE', $path, $handler, $middleware);
}
public function patch(string $path, callable|array $handler, array $middleware = []): self
{
return $this->addRoute('PATCH', $path, $handler, $middleware);
}
public function group(array $properties, callable $callback): void
{
$previousGroup = $this->currentGroup;
$previousMiddlewares = $this->groupMiddlewares;
$this->currentGroup = $properties['prefix'] ?? '';
$this->groupMiddlewares = array_merge(
$this->groupMiddlewares,
$properties['middleware'] ?? []
);
$callback($this);
$this->currentGroup = $previousGroup;
$this->groupMiddlewares = $previousMiddlewares;
}
public function middleware(array $middleware): self
{
$this->middlewares = $middleware;
return $this;
}
private function addRoute(string $method, string $path, callable|array $handler, array $middleware = []): self
{
$prefix = $this->currentGroup ? '/' . trim($this->currentGroup, '/') : '';
$fullPath = $prefix . '/' . trim($path, '/');
$fullPath = $fullPath === '/' ? '/' : '/' . trim($fullPath, '/');
$allMiddleware = array_merge($this->groupMiddlewares, $this->middlewares, $middleware);
$this->routes[] = [
'method' => $method,
'path' => $fullPath,
'handler' => $handler,
'middleware' => $allMiddleware,
'pattern' => $this->compilePattern($fullPath),
];
$this->middlewares = [];
return $this;
}
private function compilePattern(string $path): string
{
$pattern = preg_quote($path, '#');
foreach ($this->patterns as $key => $regex) {
$pattern = str_replace(preg_quote($key, '#'), $regex, $pattern);
}
return '#^' . $pattern . '$#';
}
public function dispatch(string $method, string $uri): mixed
{
$uri = rawurldecode($uri);
$uri = parse_url($uri, PHP_URL_PATH);
$uri = '/' . trim($uri, '/');
if ($method === 'POST' && isset($_POST['_method'])) {
$method = strtoupper($_POST['_method']);
}
foreach ($this->routes as $route) {
if ($route['method'] !== $method) {
continue;
}
if (preg_match($route['pattern'], $uri, $matches)) {
array_shift($matches);
$params = array_map(function ($param) {
return ctype_digit($param) ? (int) $param : $param;
}, array_values($matches));
$middlewares = array_unique($route['middleware']);
foreach ($middlewares as $mwClass) {
$middleware = new $mwClass();
$result = $middleware->handle();
if ($result !== null) {
return $result;
}
}
$handler = $route['handler'];
if (is_array($handler)) {
[$class, $method] = $handler;
$controller = new $class();
return $controller->{$method}(...$params);
}
return $handler(...$params);
}
}
$view = new View(new Security());
$view->error(404);
}
}

124
src/Core/Security.php Executable file
View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace ServerManager\Core;
use ServerManager\Services\AuditService;
class Security
{
private Logger $logger;
private AuditService $auditService;
public function __construct()
{
$this->logger = Logger::getInstance();
}
public function setAuditService(AuditService $auditService): void
{
$this->auditService = $auditService;
}
public function hashPassword(string $password): string
{
return password_hash($password, PASSWORD_ARGON2ID, [
'memory_cost' => 65536,
'time_cost' => 4,
'threads' => 3,
]);
}
public function verifyPassword(string $password, string $hash): bool
{
return password_verify($password, $hash);
}
public function generateCSRFToken(): string
{
$token = bin2hex(random_bytes(32));
Session::set('csrf_token', $token);
Session::set('csrf_token_time', time());
return $token;
}
public function validateCSRFToken(string $token): bool
{
$storedToken = Session::get('csrf_token');
$tokenTime = Session::get('csrf_token_time');
if (!$storedToken || !$tokenTime) {
return false;
}
$expiry = App::getConfig()['csrf']['token_expiry'] ?? 7200;
if (time() - $tokenTime > $expiry) {
return false;
}
return hash_equals($storedToken, $token);
}
public function purify(string $input): string
{
return htmlspecialchars($input, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
public function purifyArray(array $input): array
{
return array_map(function ($value) {
if (is_string($value)) {
return $this->purify($value);
}
if (is_array($value)) {
return $this->purifyArray($value);
}
return $value;
}, $input);
}
public function generateApiToken(): string
{
return bin2hex(random_bytes(32));
}
public function generateMasterKey(): string
{
return bin2hex(random_bytes(32));
}
public function sanitizeFilename(string $filename): string
{
$filename = preg_replace('/[^a-zA-Z0-9._-]/', '_', $filename);
return preg_replace('/_+/', '_', $filename);
}
public function isValidIP(string $ip): bool
{
return filter_var($ip, FILTER_VALIDATE_IP) !== false;
}
public function isValidHostname(string $hostname): bool
{
return (bool) preg_match('/^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/', $hostname);
}
public function isValidPort(int $port): bool
{
return $port > 0 && $port <= 65535;
}
public function isValidEmail(string $email): bool
{
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}
public function generateUUID(): string
{
$data = random_bytes(16);
$data[6] = chr(ord($data[6]) & 0x0f | 0x40);
$data[8] = chr(ord($data[8]) & 0x3f | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
}
}

103
src/Core/Session.php Executable file
View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace ServerManager\Core;
class Session
{
private static bool $started = false;
public static function init(array $config): void
{
if (self::$started) {
return;
}
if (session_status() === PHP_SESSION_NONE) {
session_set_cookie_params([
'lifetime' => $config['lifetime'] ?? 1440,
'path' => '/',
'domain' => '',
'secure' => $config['secure'] ?? true,
'httponly' => $config['http_only'] ?? true,
'samesite' => $config['same_site'] ?? 'Lax',
]);
session_name('SM_SESSION');
session_start();
}
self::$started = true;
if (!isset($_SESSION['_init'])) {
$_SESSION['_init'] = true;
static::regenerate();
}
}
public static function set(string $key, mixed $value): void
{
$_SESSION[$key] = $value;
}
public static function get(string $key, mixed $default = null): mixed
{
return $_SESSION[$key] ?? $default;
}
public static function has(string $key): bool
{
return isset($_SESSION[$key]);
}
public static function remove(string $key): void
{
unset($_SESSION[$key]);
}
public static function regenerate(bool $deleteOld = true): void
{
session_regenerate_id($deleteOld);
}
public static function destroy(): void
{
if (session_status() === PHP_SESSION_ACTIVE) {
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params();
setcookie(
session_name(),
'',
time() - 42000,
$params['path'],
$params['domain'],
$params['secure'],
$params['httponly']
);
}
session_destroy();
}
self::$started = false;
}
public static function setFlash(string $type, string $message): void
{
$_SESSION['_flash'][$type][] = $message;
}
public static function getFlash(string $type): array
{
$messages = $_SESSION['_flash'][$type] ?? [];
unset($_SESSION['_flash'][$type]);
return $messages;
}
public static function getAllFlashes(): array
{
$flashes = $_SESSION['_flash'] ?? [];
unset($_SESSION['_flash']);
return $flashes;
}
}

160
src/Core/Validator.php Executable file
View File

@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace ServerManager\Core;
class Validator
{
private array $errors = [];
private array $data;
private array $rules = [];
public function __construct(array $data = [])
{
$this->data = $data;
}
public function validate(array $data, array $rules): bool
{
$this->data = $data;
$this->errors = [];
$this->rules = $rules;
foreach ($rules as $field => $fieldRules) {
$fieldRules = is_string($fieldRules) ? explode('|', $fieldRules) : $fieldRules;
$value = $data[$field] ?? null;
foreach ($fieldRules as $rule) {
$params = [];
if (str_contains($rule, ':')) {
[$rule, $paramStr] = explode(':', $rule, 2);
$params = explode(',', $paramStr);
}
$method = 'validate' . ucfirst($rule);
if (method_exists($this, $method)) {
$this->{$method}($field, $value, $params);
}
}
}
return empty($this->errors);
}
public function getErrors(): array
{
return $this->errors;
}
public function getFirstError(): ?string
{
if (empty($this->errors)) {
return null;
}
$first = reset($this->errors);
return is_array($first) ? reset($first) : $first;
}
public function addError(string $field, string $message): void
{
$this->errors[$field][] = $message;
}
private function validateRequired(string $field, mixed $value, array $params): void
{
if ($value === null || $value === '' || (is_array($value) && empty($value))) {
$this->errors[$field][] = "The {$field} field is required.";
}
}
private function validateEmail(string $field, mixed $value, array $params): void
{
if ($value !== null && $value !== '' && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
$this->errors[$field][] = "The {$field} field must be a valid email address.";
}
}
private function validateMin(string $field, mixed $value, array $params): void
{
$min = (int) ($params[0] ?? 0);
if (is_string($value) && mb_strlen($value) < $min) {
$this->errors[$field][] = "The {$field} field must be at least {$min} characters.";
} elseif (is_numeric($value) && $value < $min) {
$this->errors[$field][] = "The {$field} field must be at least {$min}.";
}
}
private function validateMax(string $field, mixed $value, array $params): void
{
$max = (int) ($params[0] ?? 0);
if (is_string($value) && mb_strlen($value) > $max) {
$this->errors[$field][] = "The {$field} field must not exceed {$max} characters.";
} elseif (is_numeric($value) && $value > $max) {
$this->errors[$field][] = "The {$field} field must not exceed {$max}.";
}
}
private function validatesNumeric(string $field, mixed $value, array $params): void
{
if ($value !== null && $value !== '' && !is_numeric($value)) {
$this->errors[$field][] = "The {$field} field must be a number.";
}
}
private function validatesInteger(string $field, mixed $value, array $params): void
{
if ($value !== null && $value !== '' && filter_var($value, FILTER_VALIDATE_INT) === false) {
$this->errors[$field][] = "The {$field} field must be an integer.";
}
}
private function validatesIp(string $field, mixed $value, array $params): void
{
if ($value !== null && $value !== '' && !filter_var($value, FILTER_VALIDATE_IP)) {
$this->errors[$field][] = "The {$field} field must be a valid IP address or hostname.";
}
}
private function validatesPort(string $field, mixed $value, array $params): void
{
if ($value !== null && $value !== '') {
$port = (int) $value;
if ($port < 1 || $port > 65535) {
$this->errors[$field][] = "The {$field} field must be a valid port (1-65535).";
}
}
}
private function validatesAlphaNum(string $field, mixed $value, array $params): void
{
if ($value !== null && $value !== '' && !preg_match('/^[a-zA-Z0-9_\-]+$/', $value)) {
$this->errors[$field][] = "The {$field} field may only contain letters, numbers, dashes and underscores.";
}
}
private function validatesMatch(string $field, mixed $value, array $params): void
{
$otherField = $params[0] ?? null;
if ($otherField && ($this->data[$otherField] ?? null) !== $value) {
$this->errors[$field][] = "The {$field} field does not match {$otherField}.";
}
}
private function validatesIn(string $field, mixed $value, array $params): void
{
if ($value !== null && $value !== '' && !in_array($value, $params, true)) {
$this->errors[$field][] = "The selected {$field} is invalid.";
}
}
private function validatesJson(string $field, mixed $value, array $params): void
{
if ($value !== null && $value !== '' && is_string($value)) {
json_decode($value);
if (json_last_error() !== JSON_ERROR_NONE) {
$this->errors[$field][] = "The {$field} field must be valid JSON.";
}
}
}
}

134
src/Core/View.php Executable file
View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace ServerManager\Core;
class View
{
private string $viewsPath;
private string $layout = 'main';
private array $data = [];
private Security $security;
public function __construct(Security $security)
{
$this->viewsPath = dirname(__DIR__, 2) . '/views/';
$this->security = $security;
}
public function setLayout(string $layout): self
{
$this->layout = $layout;
return $this;
}
public function assign(string $key, mixed $value): self
{
$this->data[$key] = $value;
return $this;
}
public function assignMultiple(array $data): self
{
$this->data = array_merge($this->data, $data);
return $this;
}
public function render(string $view, array $data = []): string
{
$data = array_merge($this->data, $data);
$content = $this->renderView($view, $data);
if ($this->layout !== null) {
$data['content'] = $content;
$content = $this->renderView('layouts/' . $this->layout, $data);
}
return $content;
}
public function renderView(string $view, array $data = []): string
{
$viewFile = $this->viewsPath . str_replace('.', '/', $view) . '.php';
if (!file_exists($viewFile)) {
throw new \RuntimeException("View not found: {$view}");
}
extract($data, EXTR_SKIP);
ob_start();
include $viewFile;
return ob_get_clean();
}
public function renderPartial(string $partial, array $data = []): string
{
return $this->renderView('partials/' . $partial, $data);
}
public function display(string $view, array $data = []): void
{
echo $this->render($view, $data);
}
public function escape(string $value): string
{
return $this->security->purify($value);
}
public function csrfToken(): string
{
return $this->security->generateCSRFToken();
}
public function csrfField(): string
{
$token = $this->csrfToken();
return '<input type="hidden" name="_csrf_token" value="' . $this->escape($token) . '">';
}
public function redirect(string $url, int $statusCode = 302): never
{
http_response_code($statusCode);
header("Location: {$url}");
exit;
}
public function json(mixed $data, int $statusCode = 200): never
{
http_response_code($statusCode);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
exit;
}
public function error(int $statusCode, string $message = ''): never
{
http_response_code($statusCode);
if (str_starts_with($_SERVER['HTTP_ACCEPT'] ?? '', 'application/json')) {
$this->json(['error' => true, 'message' => $message ?: $this->getStatusMessage($statusCode)], $statusCode);
}
$this->display("errors.{$statusCode}", [
'code' => $statusCode,
'message' => $message ?: $this->getStatusMessage($statusCode),
]);
exit;
}
private function getStatusMessage(int $code): string
{
return match($code) {
400 => 'Bad Request',
401 => 'Unauthorized',
403 => 'Forbidden',
404 => 'Not Found',
405 => 'Method Not Allowed',
429 => 'Too Many Requests',
500 => 'Internal Server Error',
503 => 'Service Unavailable',
default => 'Error',
};
}
}

203
src/Helpers/functions.php Executable file
View File

@@ -0,0 +1,203 @@
<?php
declare(strict_types=1);
if (!function_exists('env')) {
function env(string $key, mixed $default = null): mixed
{
$value = $_ENV[$key] ?? getenv($key);
if ($value === false || $value === null) {
return $default;
}
switch (strtolower($value)) {
case 'true':
case '(true)':
return true;
case 'false':
case '(false)':
return false;
case 'null':
case '(null)':
return null;
}
return $value;
}
}
if (!function_exists('e')) {
function e(?string $value): string
{
return htmlspecialchars($value ?? '', ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
}
if (!function_exists('old')) {
function old(string $key, string $default = ''): string
{
return isset($_POST[$key]) ? e($_POST[$key]) : $default;
}
}
if (!function_exists('asset')) {
function asset(string $path): string
{
return '/assets/' . ltrim($path, '/');
}
}
if (!function_exists('url')) {
function url(string $path = ''): string
{
$baseUrl = $_ENV['APP_URL'] ?? 'http://localhost';
return rtrim($baseUrl, '/') . '/' . ltrim($path, '/');
}
}
if (!function_exists('redirect')) {
function redirect(string $url, int $statusCode = 302): never
{
http_response_code($statusCode);
header("Location: {$url}");
exit;
}
}
if (!function_exists('csrf_token')) {
function csrf_token(): string
{
return \ServerManager\Core\Session::get('csrf_token', '');
}
}
if (!function_exists('csrf_field')) {
function csrf_field(): string
{
$token = csrf_token();
if (empty($token)) {
$security = new \ServerManager\Core\Security();
$token = $security->generateCSRFToken();
}
return '<input type="hidden" name="_csrf_token" value="' . e($token) . '">';
}
}
if (!function_exists('auth')) {
function auth(): ?array
{
if (!\ServerManager\Core\Session::has('user_id')) {
return null;
}
return [
'id' => \ServerManager\Core\Session::get('user_id'),
'username' => \ServerManager\Core\Session::get('username'),
'role' => \ServerManager\Core\Session::get('user_role'),
'email' => \ServerManager\Core\Session::get('user_email'),
];
}
}
if (!function_exists('is_super_admin')) {
function is_super_admin(): bool
{
return \ServerManager\Core\Session::get('user_role') === 'super_admin';
}
}
if (!function_exists('is_admin')) {
function is_admin(): bool
{
$role = \ServerManager\Core\Session::get('user_role');
return $role === 'super_admin' || $role === 'admin';
}
}
if (!function_exists('is_operator')) {
function is_operator(): bool
{
$role = \ServerManager\Core\Session::get('user_role');
return in_array($role, ['super_admin', 'admin', 'operator'], true);
}
}
if (!function_exists('format_bytes')) {
function format_bytes(int $bytes, int $precision = 2): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= (1 << (10 * $pow));
return round($bytes, $precision) . ' ' . $units[$pow];
}
}
if (!function_exists('format_uptime')) {
function format_uptime(string $uptime): string
{
return trim(str_replace('up ', '', $uptime));
}
}
if (!function_exists('str_contains')) {
function str_contains(string $haystack, string $needle): bool
{
return $needle === '' || strpos($haystack, $needle) !== false;
}
}
if (!function_exists('str_starts_with')) {
function str_starts_with(string $haystack, string $needle): bool
{
return strncmp($haystack, $needle, strlen($needle)) === 0;
}
}
if (!function_exists('str_ends_with')) {
function str_ends_with(string $haystack, string $needle): bool
{
return $needle === '' || substr($haystack, -strlen($needle)) === $needle;
}
}
if (!function_exists('time_ago')) {
function time_ago(string $datetime): string
{
$timestamp = strtotime($datetime);
$diff = time() - $timestamp;
if ($diff < 60) {
return 'just now';
}
$intervals = [
31536000 => 'year',
2592000 => 'month',
604800 => 'week',
86400 => 'day',
3600 => 'hour',
60 => 'minute',
];
foreach ($intervals as $seconds => $label) {
$count = floor($diff / $seconds);
if ($count >= 1) {
return $count . ' ' . $label . ($count > 1 ? 's' : '') . ' ago';
}
}
return 'just now';
}
}
if (!function_exists('json_response')) {
function json_response(mixed $data, int $statusCode = 200): never
{
http_response_code($statusCode);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
exit;
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace ServerManager\Middleware;
use ServerManager\Core\Session;
class AuthMiddleware
{
public function handle(): mixed
{
if (!Session::has('user_id')) {
Session::setFlash('error', 'You must be logged in to access this page.');
$this->redirect('/login');
}
if (Session::get('session_expiry') && time() > Session::get('session_expiry')) {
Session::destroy();
Session::setFlash('error', 'Your session has expired. Please log in again.');
$this->redirect('/login');
}
return null;
}
private function redirect(string $url): never
{
if (!headers_sent()) {
header("Location: {$url}");
} else {
echo '<script>window.location.href="' . $url . '";</script>';
}
exit;
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace ServerManager\Middleware;
use ServerManager\Core\Session;
use ServerManager\Core\Security;
class CSRFMiddleware
{
public function handle(): mixed
{
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
return null;
}
$token = $_POST['_csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
$security = new Security();
if (!$security->validateCSRFToken($token)) {
if ($this->isApiRequest()) {
http_response_code(419);
header('Content-Type: application/json');
echo json_encode(['error' => 'CSRF token mismatch']);
exit;
}
Session::setFlash('error', 'The form has expired. Please try again.');
header('Location: ' . ($_SERVER['HTTP_REFERER'] ?? '/'));
exit;
}
return null;
}
private function isApiRequest(): bool
{
return str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/api/') ||
str_starts_with($_SERVER['HTTP_ACCEPT'] ?? '', 'application/json');
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace ServerManager\Middleware;
use ServerManager\Core\Session;
use ServerManager\Core\Database;
use ServerManager\Core\App;
class RateLimitMiddleware
{
public function handle(): mixed
{
$config = App::getConfig()['rate_limit'];
$maxAttempts = $config['max_attempts'] ?? 5;
$decayMinutes = $config['decay_minutes'] ?? 15;
$ip = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
$key = 'rate_limit_' . $ip . '_' . ($_SERVER['REQUEST_URI'] ?? '/');
$attempts = Session::get($key, ['count' => 0, 'first_attempt' => 0]);
if ($attempts['first_attempt'] && (time() - $attempts['first_attempt']) > ($decayMinutes * 60)) {
$attempts = ['count' => 0, 'first_attempt' => 0];
}
$attempts['count']++;
if ($attempts['first_attempt'] === 0) {
$attempts['first_attempt'] = time();
}
Session::set($key, $attempts);
if ($attempts['count'] > $maxAttempts) {
$retryAfter = ($decayMinutes * 60) - (time() - $attempts['first_attempt']);
if ($this->isApiRequest()) {
http_response_code(429);
header('Content-Type: application/json');
header("Retry-After: {$retryAfter}");
echo json_encode([
'error' => 'Too many requests. Please try again later.',
'retry_after' => $retryAfter,
]);
exit;
}
http_response_code(429);
header("Retry-After: {$retryAfter}");
echo '<h1>429 Too Many Requests</h1><p>Please wait ' . ceil($retryAfter / 60) . ' minutes before trying again.</p>';
exit;
}
return null;
}
private function isApiRequest(): bool
{
return str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/api/');
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace ServerManager\Middleware;
use ServerManager\Core\Session;
class RoleMiddleware
{
private string $requiredRole;
public function __construct(string $requiredRole = 'admin')
{
$this->requiredRole = $requiredRole;
}
public function handle(): mixed
{
$userRole = Session::get('user_role');
$roleHierarchy = [
'super_admin' => 3,
'admin' => 2,
'operator' => 1,
];
$requiredLevel = $roleHierarchy[$this->requiredRole] ?? 0;
$userLevel = $roleHierarchy[$userRole] ?? 0;
if ($userLevel < $requiredLevel) {
if ($this->isApiRequest()) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['error' => 'Forbidden: insufficient permissions']);
exit;
}
Session::setFlash('error', 'You do not have permission to access this area.');
header('Location: /dashboard');
exit;
}
return null;
}
private function isApiRequest(): bool
{
return str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/api/') ||
str_starts_with($_SERVER['HTTP_ACCEPT'] ?? '', 'application/json');
}
public static function superAdmin(): self
{
return new self('super_admin');
}
public static function admin(): self
{
return new self('admin');
}
public static function operator(): self
{
return new self('operator');
}
}

75
src/Models/CommandHistory.php Executable file
View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace ServerManager\Models;
use ServerManager\Core\Database;
class CommandHistory
{
private Database $db;
public function __construct()
{
$this->db = Database::getInstance();
}
public function log(int $serverId, int $userId, string $command, ?string $output, int $exitStatus): int
{
return $this->db->insert('command_history', [
'server_id' => $serverId,
'user_id' => $userId,
'command' => $command,
'output' => mb_substr($output ?? '', 0, 65535),
'exit_status' => $exitStatus,
'executed_at' => date('Y-m-d H:i:s'),
]);
}
public function getByServer(int $serverId, int $limit = 50): array
{
return $this->db->fetchAll(
'SELECT ch.*, u.username
FROM command_history ch
LEFT JOIN users u ON ch.user_id = u.id
WHERE ch.server_id = ?
ORDER BY ch.executed_at DESC
LIMIT ?',
[$serverId, $limit]
);
}
public function getRecent(int $limit = 10): array
{
return $this->db->fetchAll(
'SELECT ch.*, u.username, s.name as server_name
FROM command_history ch
LEFT JOIN users u ON ch.user_id = u.id
LEFT JOIN servers s ON ch.server_id = s.id
ORDER BY ch.executed_at DESC
LIMIT ?',
[$limit]
);
}
public function getById(int $id): ?array
{
return $this->db->fetch(
'SELECT ch.*, u.username, s.name as server_name
FROM command_history ch
LEFT JOIN users u ON ch.user_id = u.id
LEFT JOIN servers s ON ch.server_id = s.id
WHERE ch.id = ?',
[$id]
);
}
public function clearHistory(int $serverId = null): int
{
if ($serverId) {
return $this->db->delete('command_history', 'server_id = ?', [$serverId]);
}
return $this->db->delete('command_history', '1=1');
}
}

189
src/Models/Server.php Executable file
View File

@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace ServerManager\Models;
use ServerManager\Core\Database;
use ServerManager\Core\App;
use ServerManager\Core\Encryption;
class Server
{
private Database $db;
private Encryption $encryption;
private array $encryptedFields = ['ssh_password', 'ssh_key'];
public function __construct()
{
$this->db = Database::getInstance();
$this->encryption = App::getEncryption();
}
public function findById(int $id): ?array
{
$server = $this->db->fetch('SELECT * FROM servers WHERE id = ?', [$id]);
if ($server) {
$server = $this->encryption->decryptArray($server, $this->encryptedFields);
}
return $server;
}
public function findAll(bool $activeOnly = false): array
{
$sql = 'SELECT * FROM servers';
$params = [];
if ($activeOnly) {
$sql .= ' WHERE is_active = 1';
}
$sql .= ' ORDER BY name ASC';
$servers = $this->db->fetchAll($sql, $params);
return array_map(function ($server) {
return $this->encryption->decryptArray($server, $this->encryptedFields);
}, $servers);
}
public function getAll(int $page = 1, int $perPage = 20, array $filters = []): array
{
$where = [];
$params = [];
if (!empty($filters['search'])) {
$where[] = '(s.name LIKE ? OR s.ip_address LIKE ? OR s.description LIKE ?)';
$search = '%' . $filters['search'] . '%';
$params[] = $search;
$params[] = $search;
$params[] = $search;
}
if (isset($filters['is_active']) && $filters['is_active'] !== '') {
$where[] = 's.is_active = ?';
$params[] = (int) $filters['is_active'];
}
if (!empty($filters['group_name'])) {
$where[] = 's.group_name = ?';
$params[] = $filters['group_name'];
}
if (!empty($filters['status'])) {
$where[] = 's.current_status = ?';
$params[] = $filters['status'];
}
$whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : '';
$offset = ($page - 1) * $perPage;
$total = $this->db->fetch(
"SELECT COUNT(*) as total FROM servers s {$whereClause}",
$params
);
$servers = $this->db->fetchAll(
"SELECT s.* FROM servers s {$whereClause} ORDER BY s.name ASC LIMIT ? OFFSET ?",
array_merge($params, [$perPage, $offset])
);
$servers = array_map(function ($server) {
return $this->encryption->decryptArray($server, $this->encryptedFields);
}, $servers);
return [
'data' => $servers,
'total' => (int) ($total['total'] ?? 0),
'page' => $page,
'per_page' => $perPage,
'total_pages' => ceil((int) ($total['total'] ?? 0) / $perPage),
];
}
public function create(array $data): int
{
if (!empty($data['ssh_password'])) {
$data['ssh_password'] = $this->encryption->encrypt($data['ssh_password']);
} else {
$data['ssh_password'] = null;
}
if (!empty($data['ssh_key'])) {
$data['ssh_key'] = $this->encryption->encrypt($data['ssh_key']);
} else {
$data['ssh_key'] = null;
}
$data['created_at'] = date('Y-m-d H:i:s');
$data['updated_at'] = date('Y-m-d H:i:s');
if (empty($data['current_status'])) {
$data['current_status'] = 'unknown';
}
return $this->db->insert('servers', $data);
}
public function update(int $id, array $data): bool
{
if (array_key_exists('ssh_password', $data)) {
if (!empty($data['ssh_password'])) {
$data['ssh_password'] = $this->encryption->encrypt($data['ssh_password']);
} else {
unset($data['ssh_password']);
}
}
if (array_key_exists('ssh_key', $data)) {
if (!empty($data['ssh_key'])) {
$data['ssh_key'] = $this->encryption->encrypt($data['ssh_key']);
} else {
unset($data['ssh_key']);
}
}
$data['updated_at'] = date('Y-m-d H:i:s');
return $this->db->update('servers', $data, 'id = ?', [$id]) > 0;
}
public function delete(int $id): bool
{
return $this->db->delete('servers', 'id = ?', [$id]) > 0;
}
public function toggleActive(int $id): ?bool
{
$server = $this->db->fetch('SELECT is_active FROM servers WHERE id = ?', [$id]);
if (!$server) {
return null;
}
$newState = $server['is_active'] ? 0 : 1;
$this->db->update('servers', ['is_active' => $newState, 'updated_at' => date('Y-m-d H:i:s')], 'id = ?', [$id]);
return (bool) $newState;
}
public function getGroups(): array
{
$groups = $this->db->fetchAll(
'SELECT DISTINCT group_name FROM servers WHERE group_name IS NOT NULL AND group_name != "" ORDER BY group_name'
);
return array_column($groups, 'group_name');
}
public function countAll(): int
{
$result = $this->db->fetch("SELECT COUNT(*) as total FROM servers");
return (int) ($result['total'] ?? 0);
}
public function countOnline(): int
{
$result = $this->db->fetch(
"SELECT COUNT(*) as total FROM servers WHERE current_status = 'online' AND is_active = 1"
);
return (int) ($result['total'] ?? 0);
}
}

124
src/Models/User.php Executable file
View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace ServerManager\Models;
use ServerManager\Core\Database;
use ServerManager\Core\Security;
class User
{
private Database $db;
private Security $security;
public function __construct()
{
$this->db = Database::getInstance();
$this->security = new Security();
}
public function findById(int $id): ?array
{
return $this->db->fetch('SELECT * FROM users WHERE id = ?', [$id]);
}
public function findByUsername(string $username): ?array
{
return $this->db->fetch('SELECT * FROM users WHERE username = ?', [$username]);
}
public function findByEmail(string $email): ?array
{
return $this->db->fetch('SELECT * FROM users WHERE email = ?', [$email]);
}
public function authenticate(string $username, string $password): ?array
{
$user = $this->findByUsername($username);
if (!$user) {
return null;
}
if (!$user['is_active']) {
return null;
}
if (!$this->security->verifyPassword($password, $user['password_hash'])) {
return null;
}
$this->updateLastLogin($user['id']);
return $user;
}
public function create(array $data): int
{
$data['password_hash'] = $this->security->hashPassword($data['password']);
unset($data['password']);
$data['created_at'] = date('Y-m-d H:i:s');
$data['updated_at'] = date('Y-m-d H:i:s');
if (empty($data['api_token'])) {
$data['api_token'] = $this->security->generateApiToken();
}
return $this->db->insert('users', $data);
}
public function update(int $id, array $data): bool
{
if (isset($data['password'])) {
$data['password_hash'] = $this->security->hashPassword($data['password']);
unset($data['password']);
}
$data['updated_at'] = date('Y-m-d H:i:s');
return $this->db->update('users', $data, 'id = ?', [$id]) > 0;
}
public function delete(int $id): bool
{
return $this->db->delete('users', 'id = ?', [$id]) > 0;
}
public function getAll(int $page = 1, int $perPage = 20): array
{
$offset = ($page - 1) * $perPage;
$total = $this->db->fetch("SELECT COUNT(*) as total FROM users");
$users = $this->db->fetchAll(
'SELECT id, username, email, role, is_active, last_login_at, created_at
FROM users ORDER BY created_at DESC LIMIT ? OFFSET ?',
[$perPage, $offset]
);
return [
'data' => $users,
'total' => (int) ($total['total'] ?? 0),
'page' => $page,
'per_page' => $perPage,
'total_pages' => ceil((int) ($total['total'] ?? 0) / $perPage),
];
}
public function findByApiToken(string $token): ?array
{
return $this->db->fetch('SELECT * FROM users WHERE api_token = ? AND is_active = 1', [$token]);
}
public function updateLastLogin(int $id): void
{
$this->db->update('users', ['last_login_at' => date('Y-m-d H:i:s')], 'id = ?', [$id]);
}
public function countAll(): int
{
$result = $this->db->fetch("SELECT COUNT(*) as total FROM users");
return (int) ($result['total'] ?? 0);
}
}

195
src/Services/AuditService.php Executable file
View File

@@ -0,0 +1,195 @@
<?php
declare(strict_types=1);
namespace ServerManager\Services;
use ServerManager\Core\Database;
use ServerManager\Core\Session;
use ServerManager\Core\Logger;
class AuditService
{
private Database $db;
private Logger $logger;
public function __construct()
{
$this->db = Database::getInstance();
$this->logger = Logger::getInstance();
}
public function log(
string $action,
string $entityType,
?int $entityId,
?array $details = null,
?array $metadata = null
): void {
$userId = Session::get('user_id');
$username = Session::get('username');
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? 'unknown';
$now = date('Y-m-d H:i:s');
$this->db->insert('audit_logs', [
'user_id' => $userId,
'username' => $username ?? 'system',
'action' => $action,
'entity_type' => $entityType,
'entity_id' => $entityId,
'details' => $details ? json_encode($details, JSON_UNESCAPED_SLASHES) : null,
'ip_address' => $ip,
'user_agent' => $userAgent,
'created_at' => $now,
]);
$logMessage = sprintf(
'[AUDIT] %s | %s | %s:%s | User: %s (%d)',
$action,
$entityType,
$entityId ?? 'N/A',
$ip,
$username ?? 'system',
$userId ?? 0
);
$this->logger->info($logMessage);
if (in_array($action, ['credential_access', 'credential_decrypt', 'credential_use'])) {
$this->logger->warning("[CREDENTIAL_ACCESS] {$logMessage}");
}
}
public function logCredentialAccess(int $serverId, string $reason): void
{
$server = $this->db->fetch('SELECT name, ip_address FROM servers WHERE id = ?', [$serverId]);
$serverName = $server['name'] ?? 'Unknown';
$this->log(
'credential_access',
'server_credentials',
$serverId,
[
'server_name' => $serverName,
'ip_address' => $server['ip_address'] ?? 'unknown',
'reason' => $reason,
'timestamp' => date('c'),
]
);
}
public function logLogin(string $username, bool $success, ?string $reason = null): void
{
$this->log(
$success ? 'login_success' : 'login_failed',
'user',
null,
[
'username' => $username,
'success' => $success,
'reason' => $reason,
]
);
if (!$success) {
$this->logger->warning("[SECURITY] Failed login attempt for username: {$username}", [
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
]);
}
}
public function logServerAction(int $serverId, string $action, string $command): void
{
$this->log(
'server_action',
'server',
$serverId,
[
'action' => $action,
'command' => $command,
]
);
}
public function logUserManagement(int $userId, string $action): void
{
$this->log(
$action,
'user',
$userId,
[
'performed_by' => Session::get('username'),
]
);
}
public function getRecentActivity(int $limit = 20): array
{
return $this->db->fetchAll(
'SELECT al.*, u.username as actor_name
FROM audit_logs al
LEFT JOIN users u ON al.user_id = u.id
ORDER BY al.created_at DESC
LIMIT ?',
[$limit]
);
}
public function getAuditLogs(int $page = 1, int $perPage = 50, array $filters = []): array
{
$where = [];
$params = [];
if (!empty($filters['action'])) {
$where[] = 'al.action = ?';
$params[] = $filters['action'];
}
if (!empty($filters['user_id'])) {
$where[] = 'al.user_id = ?';
$params[] = $filters['user_id'];
}
if (!empty($filters['entity_type'])) {
$where[] = 'al.entity_type = ?';
$params[] = $filters['entity_type'];
}
if (!empty($filters['date_from'])) {
$where[] = 'al.created_at >= ?';
$params[] = $filters['date_from'];
}
if (!empty($filters['date_to'])) {
$where[] = 'al.created_at <= ?';
$params[] = $filters['date_to'];
}
$whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : '';
$offset = ($page - 1) * $perPage;
$total = $this->db->fetch(
"SELECT COUNT(*) as total FROM audit_logs al {$whereClause}",
$params
);
$logs = $this->db->fetchAll(
"SELECT al.*, u.username as actor_name
FROM audit_logs al
LEFT JOIN users u ON al.user_id = u.id
{$whereClause}
ORDER BY al.created_at DESC
LIMIT ? OFFSET ?",
array_merge($params, [$perPage, $offset])
);
return [
'data' => $logs,
'total' => (int) ($total['total'] ?? 0),
'page' => $page,
'per_page' => $perPage,
'total_pages' => ceil((int) ($total['total'] ?? 0) / $perPage),
];
}
}

View File

@@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
namespace ServerManager\Services;
use ServerManager\Core\Database;
use ServerManager\Core\Logger;
use ServerManager\Core\App;
use ServerManager\Services\SSHService;
class MonitoringService
{
private Database $db;
private Logger $logger;
public function __construct()
{
$this->db = Database::getInstance();
$this->logger = Logger::getInstance();
}
public function collectMetrics(int $serverId): ?array
{
$server = $this->db->fetch('SELECT * FROM servers WHERE id = ?', [$serverId]);
if (!$server || !$server['is_active']) {
return null;
}
$server = App::getEncryption()->decryptArray($server, ['ssh_password', 'ssh_key']);
try {
$ssh = new SSHService();
if (!$ssh->connect($server, true)) {
return ['status' => 'offline'];
}
$metrics = [
'server_id' => $serverId,
'status' => 'online',
'cpu_usage' => $this->getCPUUsage($ssh),
'ram_usage' => $this->getRAMUsage($ssh),
'disk_usage' => $this->getDiskUsage($ssh),
'load_average' => $this->getLoadAverage($ssh),
'uptime' => $this->getUptime($ssh),
];
$this->saveMetrics($metrics);
$ssh->disconnect();
return $metrics;
} catch (\Throwable $e) {
$this->logger->warning("Failed to collect metrics for server {$serverId}: " . $e->getMessage());
$this->saveMetrics([
'server_id' => $serverId,
'status' => 'offline',
'cpu_usage' => 0,
'ram_usage' => 0,
'disk_usage' => 0,
'load_average' => 0,
'uptime' => '',
]);
return ['status' => 'offline'];
}
}
public function collectAllMetrics(): array
{
$servers = $this->db->fetchAll('SELECT * FROM servers WHERE is_active = 1');
$results = [];
foreach ($servers as $server) {
$results[$server['id']] = $this->collectMetrics($server['id']);
}
return $results;
}
private function getCPUUsage(SSHService $ssh): float
{
$result = $ssh->exec("top -bn1 | grep 'Cpu(s)' | awk '{print $2}' | cut -d'%' -f1");
return (float) trim($result['output']);
}
private function getRAMUsage(SSHService $ssh): float
{
$result = $ssh->exec("free | grep Mem | awk '{printf \"%.2f\", $3/$2 * 100}'");
return (float) trim($result['output']);
}
private function getDiskUsage(SSHService $ssh): float
{
$result = $ssh->exec("df / | tail -1 | awk '{print $5}' | sed 's/%//'");
return (float) trim($result['output']);
}
private function getLoadAverage(SSHService $ssh): float
{
$result = $ssh->exec("cat /proc/loadavg | awk '{print $1}'");
return (float) trim($result['output']);
}
private function getUptime(SSHService $ssh): string
{
$result = $ssh->exec("uptime -p");
return trim(str_replace('up ', '', $result['output']));
}
private function saveMetrics(array $metrics): void
{
$now = date('Y-m-d H:i:s');
$this->db->insert('monitoring_history', [
'server_id' => $metrics['server_id'],
'status' => $metrics['status'],
'cpu_usage' => $metrics['cpu_usage'],
'ram_usage' => $metrics['ram_usage'],
'disk_usage' => $metrics['disk_usage'],
'load_average' => $metrics['load_average'],
'uptime' => $metrics['uptime'] ?? '',
'created_at' => $now,
]);
$this->db->query(
"UPDATE servers SET
last_check_at = ?,
current_status = ?,
cpu_usage = ?,
ram_usage = ?,
disk_usage = ?,
load_average = ?,
uptime = ?
WHERE id = ?",
[
$now,
$metrics['status'],
$metrics['cpu_usage'],
$metrics['ram_usage'],
$metrics['disk_usage'],
$metrics['load_average'],
$metrics['uptime'] ?? '',
$metrics['server_id'],
]
);
$retention = App::getConfig()['monitoring']['retention_days'] ?? 30;
$this->db->query(
"DELETE FROM monitoring_history WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)",
[$retention]
);
}
public function getLatestMetrics(int $serverId): ?array
{
return $this->db->fetch(
'SELECT * FROM monitoring_history WHERE server_id = ? ORDER BY created_at DESC LIMIT 1',
[$serverId]
);
}
public function getHistoricalMetrics(int $serverId, int $hours = 24): array
{
return $this->db->fetchAll(
'SELECT * FROM monitoring_history
WHERE server_id = ? AND created_at >= DATE_SUB(NOW(), INTERVAL ? HOUR)
ORDER BY created_at ASC',
[$serverId, $hours]
);
}
public function getDashboardStats(): array
{
$totalServers = $this->db->fetch("SELECT COUNT(*) as total FROM servers");
$onlineServers = $this->db->fetch("SELECT COUNT(*) as total FROM servers WHERE current_status = 'online' AND is_active = 1");
$offlineServers = $this->db->fetch("SELECT COUNT(*) as total FROM servers WHERE current_status = 'offline' AND is_active = 1");
$avgCpu = $this->db->fetch(
"SELECT AVG(cpu_usage) as avg FROM servers WHERE is_active = 1 AND current_status = 'online'"
);
$avgRam = $this->db->fetch(
"SELECT AVG(ram_usage) as avg FROM servers WHERE is_active = 1 AND current_status = 'online'"
);
$avgDisk = $this->db->fetch(
"SELECT AVG(disk_usage) as avg FROM servers WHERE is_active = 1 AND current_status = 'online'"
);
return [
'total_servers' => (int) ($totalServers['total'] ?? 0),
'online_servers' => (int) ($onlineServers['total'] ?? 0),
'offline_servers' => (int) ($offlineServers['total'] ?? 0),
'avg_cpu' => round((float) ($avgCpu['avg'] ?? 0), 1),
'avg_ram' => round((float) ($avgRam['avg'] ?? 0), 1),
'avg_disk' => round((float) ($avgDisk['avg'] ?? 0), 1),
];
}
}

168
src/Services/SSHService.php Executable file
View File

@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace ServerManager\Services;
use phpseclib3\Net\SSH2;
use phpseclib3\Crypt\PublicKeyLoader;
use ServerManager\Core\App;
use ServerManager\Core\Encryption;
use ServerManager\Core\Logger;
class SSHService
{
private ?SSH2 $connection = null;
private array $serverConfig;
private Logger $logger;
public function __construct()
{
$this->logger = Logger::getInstance();
}
public function connect(array $server, bool $forceReconnect = false): bool
{
if ($this->connection !== null && !$forceReconnect) {
return $this->connection->isConnected();
}
$this->serverConfig = $server;
$config = App::getConfig()['ssh'];
try {
$host = $server['ip_address'] ?? $server['hostname'];
$port = (int) ($server['ssh_port'] ?? 22);
$this->connection = new SSH2($host, $port, $config['timeout'] ?? 30);
if (!empty($server['ssh_key'])) {
$key = PublicKeyLoader::load($server['ssh_key']);
$result = $this->connection->login($server['ssh_user'], $key);
} elseif (!empty($server['ssh_password'])) {
$result = $this->connection->login($server['ssh_user'], $server['ssh_password']);
} else {
throw new \RuntimeException('No authentication method configured');
}
if (!$result) {
throw new \RuntimeException('SSH authentication failed for ' . $host);
}
if ($config['keepalive_interval'] ?? 0 > 0) {
$this->connection->setKeepAlive($config['keepalive_interval']);
}
$this->logger->info("SSH connection established to {$host}");
return true;
} catch (\Throwable $e) {
$this->logger->error("SSH connection failed: " . $e->getMessage(), [
'host' => $host ?? 'unknown',
'port' => $port ?? 22,
]);
$this->connection = null;
return false;
}
}
public function disconnect(): void
{
if ($this->connection !== null) {
$this->connection->disconnect();
$this->connection = null;
}
}
public function exec(string $command, int $timeout = 0): array
{
if ($this->connection === null || !$this->connection->isConnected()) {
throw new \RuntimeException('No active SSH connection');
}
$timeout = $timeout ?: (App::getConfig()['ssh']['command_timeout'] ?? 60);
$this->connection->setTimeout($timeout);
$output = $this->connection->exec($command);
$exitStatus = $this->connection->getExitStatus();
$stderr = $this->connection->getStdError() ?? '';
$this->logger->info("SSH command executed", [
'command' => $command,
'exit_status' => $exitStatus,
]);
return [
'output' => $output,
'exit_status' => $exitStatus,
'stderr' => $stderr,
];
}
public function write(string $data): void
{
if ($this->connection !== null && $this->connection->isConnected()) {
$this->connection->write($data);
}
}
public function read(string $expected = '', int $mode = SSH2::READ_REGEX): string
{
if ($this->connection === null || !$this->connection->isConnected()) {
throw new \RuntimeException('No active SSH connection');
}
return $this->connection->read($expected, $mode);
}
public function getInteractiveShell(): ?SSH2
{
return $this->connection;
}
public function testConnection(array $server): bool
{
try {
$result = $this->connect($server, true);
if ($result) {
$this->disconnect();
return true;
}
return false;
} catch (\Throwable $e) {
$this->logger->warning("SSH connection test failed", ['error' => $e->getMessage()]);
return false;
}
}
public function getSystemInfo(): array
{
$commands = [
'hostname' => 'hostname',
'os' => 'cat /etc/os-release | head -1',
'kernel' => 'uname -r',
'uptime' => 'uptime -p',
'cpu_model' => 'cat /proc/cpuinfo | grep "model name" | head -1',
'cpu_cores' => 'nproc',
'mem_total' => 'free -b | grep Mem | awk \'{print $2}\'',
'mem_used' => "free -b | grep Mem | awk '{print $3}'",
'mem_avail' => "free -b | grep Mem | awk '{print $7}'",
'disk_total' => "df -B1 / | tail -1 | awk '{print $2}'",
'disk_used' => "df -B1 / | tail -1 | awk '{print $3}'",
'disk_avail' => "df -B1 / | tail -1 | awk '{print $4}'",
'disk_percent' => "df / | tail -1 | awk '{print $5}' | sed 's/%//'",
];
$info = [];
foreach ($commands as $key => $cmd) {
$result = $this->exec($cmd);
$info[$key] = trim($result['output']);
}
return $info;
}
public function __destruct()
{
$this->disconnect();
}
}