ServerManager project files
This commit is contained in:
166
src/Controllers/AdminController.php
Executable file
166
src/Controllers/AdminController.php
Executable 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
283
src/Controllers/ApiController.php
Executable 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
149
src/Controllers/AuthController.php
Executable file
149
src/Controllers/AuthController.php
Executable 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');
|
||||
}
|
||||
}
|
||||
60
src/Controllers/DashboardController.php
Executable file
60
src/Controllers/DashboardController.php
Executable 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
1488
src/Controllers/ServerController.php
Executable file
1488
src/Controllers/ServerController.php
Executable file
File diff suppressed because it is too large
Load Diff
132
src/Controllers/TerminalController.php
Executable file
132
src/Controllers/TerminalController.php
Executable 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
159
src/Core/App.php
Executable 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
122
src/Core/Database.php
Executable 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
92
src/Core/Encryption.php
Executable 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
148
src/Core/Logger.php
Executable 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
143
src/Core/Router.php
Executable 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
124
src/Core/Security.php
Executable 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
103
src/Core/Session.php
Executable 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
160
src/Core/Validator.php
Executable 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
134
src/Core/View.php
Executable 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
203
src/Helpers/functions.php
Executable 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;
|
||||
}
|
||||
}
|
||||
36
src/Middleware/AuthMiddleware.php
Executable file
36
src/Middleware/AuthMiddleware.php
Executable 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;
|
||||
}
|
||||
}
|
||||
43
src/Middleware/CSRFMiddleware.php
Executable file
43
src/Middleware/CSRFMiddleware.php
Executable 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');
|
||||
}
|
||||
}
|
||||
63
src/Middleware/RateLimitMiddleware.php
Executable file
63
src/Middleware/RateLimitMiddleware.php
Executable 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/');
|
||||
}
|
||||
}
|
||||
67
src/Middleware/RoleMiddleware.php
Executable file
67
src/Middleware/RoleMiddleware.php
Executable 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
75
src/Models/CommandHistory.php
Executable 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
189
src/Models/Server.php
Executable 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
124
src/Models/User.php
Executable 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
195
src/Services/AuditService.php
Executable 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
201
src/Services/MonitoringService.php
Executable file
201
src/Services/MonitoringService.php
Executable 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
168
src/Services/SSHService.php
Executable 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user