servercontrol/src/Controller/ServerController.php

471 lines
19 KiB
PHP

<?php
/**
* @file ServerController.php
* @brief Controleur principal pour la gestion des serveurs Linux distants
*
* Gere toutes les operations sur les serveurs : CRUD, actions SSH sur les services,
* terminal interactif, gestion des acces operateurs et des services systemd.
* Toutes les routes necesite au minimum ROLE_OPERATOR, certaines ROLE_ADMIN.
*
* @package App\Controller
* @author Yasser
*/
namespace App\Controller;
use App\Entity\Server;
use App\Entity\ServerAccess;
use App\Entity\Service;
use App\Form\ServerType;
use App\Repository\ServerAccessRepository;
use App\Repository\ServerRepository;
use App\Repository\UserRepository;
use App\Service\SshCommandService;
use Doctrine\ORM\EntityManagerInterface;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* @class ServerController
* @brief Controller principal de l'application pour la gestion des serveurs
*
* Regroupe toutes les actions liées aux serveurs. Les admins voient tout les
* serveurs, les opérateurs voient uniquement ceux auxquels ils ont acces.
* Les actions SSH (start/stop/restart) sont loggées via SshCommandService.
*/
#[Route('/server')]
#[IsGranted('ROLE_OPERATOR')]
class ServerController extends AbstractController
{
/**
* @brief Liste paginée des serveurs avec filtres de recherche
*
* Les admins voient tout les serveurs, les opérateurs seulement
* ceux qui leurs sont assignés via ServerAccess. La pagination
* est de 10 elements par page.
*
* @param Request $request Requete avec les parametres de filtre (search, status, page)
* @param ServerRepository $serverRepo
* @param PaginatorInterface $paginator KnpPaginator
* @return Response La vue avec la liste des serveurs
*/
#[Route('/', name: 'app_server_index')]
public function index(Request $request, ServerRepository $serverRepo, PaginatorInterface $paginator): Response
{
$search = $request->query->get('search');
$status = $request->query->get('status');
$currentUser = $this->getUser();
$query = $serverRepo->findByFilters($search, $status, $currentUser)->getQuery();
$servers = $paginator->paginate($query, $request->query->getInt('page', 1), 10);
return $this->render('server/index.html.twig', [
'servers' => $servers,
'search' => $search,
'status' => $status,
]);
}
/**
* @brief Formulaire de creation d'un nouveau serveur
*
* Apres la creation, on scan automatiquement les services systemd actifs
* sur le serveur via SSH et on les enregistre en base de donnée.
* Si le scan echoue (SSH KO) le serveur est quand meme crée.
*
* @param Request $request
* @param EntityManagerInterface $em
* @param SshCommandService $sshService Pour le scan automatique des services
* @return Response Le formulaire ou redirection apres succes
*/
#[Route('/new', name: 'app_server_new')]
#[IsGranted('ROLE_ADMIN')]
public function new(Request $request, EntityManagerInterface $em, SshCommandService $sshService): Response
{
$server = new Server();
$form = $this->createForm(ServerType::class, $server);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$server->setOwner($this->getUser());
$em->persist($server);
$em->flush();
// scan des services actifs sur le serveur apres l'ajout
foreach ($sshService->scanServices($server) as $serviceName) {
$service = new Service();
$service->setName($serviceName);
$service->setDisplayName(ucfirst($serviceName));
$service->setServer($server);
$em->persist($service);
}
$em->flush();
$this->addFlash('success', 'Serveur ajouté avec succès.');
return $this->redirectToRoute('app_server_index');
}
return $this->render('server/new.html.twig', ['form' => $form]);
}
/**
* @brief Page de detail d'un serveur avec ses services et acces
*
* Verifie que l'utilisateur courant a bien acces a ce serveur
* via assertServerAccess(). Passe aussi la liste des operateurs
* qui peuvent etre assignés (ceux pas encore assignés).
*
* @param Server $server Le serveur a afficher (resolu par ParamConverter)
* @param UserRepository $userRepo Pour trouver les operateurs assignables
* @return Response La page de detail du serveur
*/
#[Route('/{id}', name: 'app_server_show', requirements: ['id' => '\d+'])]
public function show(Server $server, UserRepository $userRepo): Response
{
$this->assertServerAccess($server);
$assignedIds = $server->getServerAccesses()->map(fn($a) => $a->getUser()->getId())->toArray();
$assignableUsers = $userRepo->findOperatorsExcluding($assignedIds);
return $this->render('server/show.html.twig', [
'server' => $server,
'assignableUsers' => $assignableUsers,
]);
}
/**
* @brief Formulaire d'edition d'un serveur
*
* Si le champ mot de passe SSH est laissé vide, on fait un refresh()
* pour pas ecraser le mot de passe existant en base.
*
* @param Request $request
* @param Server $server Le serveur a modifier
* @param EntityManagerInterface $em
* @return Response Le formulaire d'edition ou redirection
*/
#[Route('/{id}/edit', name: 'app_server_edit', requirements: ['id' => '\d+'])]
#[IsGranted('ROLE_ADMIN')]
public function edit(Request $request, Server $server, EntityManagerInterface $em): Response
{
$form = $this->createForm(ServerType::class, $server);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// si le mdp est vide on recharge l'anciene valeur pour pas l'effacer
if (!$form->get('sshPassword')->getData()) {
$em->refresh($server);
}
$em->flush();
$this->addFlash('success', 'Server updated successfully.');
return $this->redirectToRoute('app_server_show', ['id' => $server->getId()]);
}
return $this->render('server/edit.html.twig', ['form' => $form, 'server' => $server]);
}
/**
* @brief Suppression d'un serveur
*
* La suppression est en cascade sur les services, logs et acces associés
* (configuré dans les entités). Le token CSRF est verifié avant suppression.
*
* @param Request $request
* @param Server $server Le serveur a supprimer
* @param EntityManagerInterface $em
* @return Response Redirection vers la liste des serveurs
*/
#[Route('/{id}/delete', name: 'app_server_delete', methods: ['POST'], requirements: ['id' => '\d+'])]
#[IsGranted('ROLE_ADMIN')]
public function delete(Request $request, Server $server, EntityManagerInterface $em): Response
{
if ($this->isCsrfTokenValid('delete_server_' . $server->getId(), $request->request->get('_token'))) {
$em->remove($server);
$em->flush();
$this->addFlash('success', 'Server deleted.');
}
return $this->redirectToRoute('app_server_index');
}
/**
* @brief Execute une commande libre via le terminal SSH interactif
*
* Recoit une commande en POST et la transmet via SshCommandService.
* Si la commande contient 'sudo' et que le mot de passe est necessaire,
* retourne needs_sudo=true pour que le front affiche la modal de saisie.
*
* @param Request $request Requete avec 'command', optionnellement 'sudo_password'
* @param Server $server Le serveur cible
* @param SshCommandService $sshService
* @return JsonResponse Resultat de la commande avec output et exit_code
*/
#[Route('/{id}/terminal', name: 'app_server_terminal', methods: ['POST'], requirements: ['id' => '\d+'])]
public function terminal(Request $request, Server $server, SshCommandService $sshService): JsonResponse
{
$this->assertServerAccess($server);
if (!$this->isCsrfTokenValid('terminal_' . $server->getId(), $request->request->get('_token'))) {
return $this->json(['error' => 'Invalid CSRF token'], 403);
}
$command = trim($request->request->get('command', ''));
if ($command === '') {
return $this->json(['success' => true, 'output' => '', 'needs_sudo' => false, 'exit_code' => 0]);
}
$sudoPassword = $request->request->get('sudo_password') ?: null;
$currentUser = $this->getUser();
$result = $sshService->runCommand($server, $command, $sudoPassword, $currentUser);
return $this->json($result);
}
/**
* @brief Execute une action systemd (start/stop/restart/status) sur un service
*
* L'action et le nom du service sont passés en POST. L'action est
* loggée dans ActionLog peu importe le resultat (succes ou echec).
* Retourne needs_sudo si le mot de passe sudo est requis.
*
* @param Request $request Requete avec 'action', 'service', optionnellement 'sudo_password'
* @param Server $server Le serveur cible
* @param SshCommandService $sshService
* @return JsonResponse Statut, output et needs_sudo
*/
#[Route('/{id}/action', name: 'app_server_action', methods: ['POST'], requirements: ['id' => '\d+'])]
public function action(Request $request, Server $server, SshCommandService $sshService): JsonResponse
{
$this->assertServerAccess($server);
if (!$this->isCsrfTokenValid('server_action', $request->request->get('_token'))) {
return $this->json(['error' => 'Invalid CSRF token'], 403);
}
$action = $request->request->get('action');
$serviceName = $request->request->get('service');
if (!$action || !$serviceName) {
return $this->json(['error' => 'Missing parameters'], 400);
}
$sudoPassword = $request->request->get('sudo_password') ?: null;
$log = $sshService->executeAction($server, $action, $serviceName, $this->getUser(), $sudoPassword);
return $this->json([
'status' => $log->getStatus(),
'output' => $log->getOutput(),
'needs_sudo' => $log->getStatus() === 'needs_sudo',
]);
}
/**
* @brief Verifie le statut (active/inactive) de tout les services d'un serveur
*
* Utilisé par le front en AJAX pour mettre a jour les badges de statut
* sur la page de detail du serveur sans recharger la page.
*
* @param Server $server
* @param SshCommandService $sshService
* @return JsonResponse Tableau associatif nom_service => 'active'|'inactive'
*/
#[Route('/{id}/services/status', name: 'app_services_status', methods: ['GET'], requirements: ['id' => '\d+'])]
public function servicesStatus(Server $server, SshCommandService $sshService): JsonResponse
{
$this->assertServerAccess($server);
$names = $server->getServices()->map(fn($s) => $s->getName())->toArray();
if (empty($names)) {
return $this->json([]);
}
return $this->json($sshService->checkServicesStatus($server, $names));
}
/**
* @brief Verifie si le serveur est accessible via SSH et met a jour son statut
*
* Tente une connexion SSH avec un timeout court. Met a jour lastStatus
* en base et retourne 'online' ou 'offline'.
*
* @param Server $server
* @param SshCommandService $sshService
* @param EntityManagerInterface $em Pour persister le nouveau statut
* @return JsonResponse ['status' => 'online'|'offline']
*/
#[Route('/{id}/status', name: 'app_server_status', methods: ['GET'], requirements: ['id' => '\d+'])]
public function status(Server $server, SshCommandService $sshService, EntityManagerInterface $em): JsonResponse
{
$this->assertServerAccess($server);
$status = $sshService->checkStatus($server);
$server->setLastStatus($status);
$em->flush();
return $this->json(['status' => $status]);
}
/**
* @brief Ajoute un acces operateur a un serveur depuis la page du serveur
*
* Cree un ServerAccess pour l'utilisateur selectionné avec les credentials SSH
* fournis. Double check que l'acces n'existe pas deja avant de creer.
*
* @param Request $request
* @param int $serverId
* @param EntityManagerInterface $em
* @param ServerRepository $serverRepo
* @param UserRepository $userRepo
* @param ServerAccessRepository $accessRepo
* @return Response Redirection vers la page du serveur
*/
#[Route('/{serverId}/access/new', name: 'app_access_new', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function addAccess(Request $request, int $serverId, EntityManagerInterface $em, ServerRepository $serverRepo, UserRepository $userRepo, ServerAccessRepository $accessRepo): Response
{
$server = $serverRepo->find($serverId);
if (!$server) {
throw $this->createNotFoundException();
}
if ($this->isCsrfTokenValid('add_access_' . $serverId, $request->request->get('_token'))) {
$user = $userRepo->find($request->request->get('user_id'));
if ($user && !$accessRepo->findOneBy(['server' => $server, 'user' => $user])) {
$access = new ServerAccess();
$access->setServer($server);
$access->setUser($user);
$access->setSshUser($request->request->get('ssh_user'));
$access->setSshPassword($request->request->get('ssh_password'));
$em->persist($access);
$em->flush();
$this->addFlash('success', 'Access granted to ' . $user->getPseudo());
}
}
return $this->redirectToRoute('app_server_show', ['id' => $serverId]);
}
/**
* @brief Supprime un acces operateur depuis la page du serveur
*
* @param Request $request
* @param int $serverId Id du serveur (pour verifier la cohérence)
* @param int $accessId Id du ServerAccess a supprimer
* @param EntityManagerInterface $em
* @param ServerAccessRepository $accessRepo
* @return Response Redirection vers la page du serveur
*/
#[Route('/{serverId}/access/{accessId}/delete', name: 'app_access_delete', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function deleteAccess(Request $request, int $serverId, int $accessId, EntityManagerInterface $em, ServerAccessRepository $accessRepo): Response
{
$access = $accessRepo->find($accessId);
if ($access && $access->getServer()->getId() === $serverId
&& $this->isCsrfTokenValid('delete_access_' . $accessId, $request->request->get('_token'))) {
$em->remove($access);
$em->flush();
$this->addFlash('success', 'Access revoked.');
}
return $this->redirectToRoute('app_server_show', ['id' => $serverId]);
}
/**
* @brief Supprime un service systemd de la liste surveillée
*
* Ne supprime pas le service sur le serveur Linux, juste
* l'enregistrement en base qui indique qu'on le surveille.
*
* @param Request $request
* @param int $serverId
* @param int $serviceId Id du Service a supprimer
* @param EntityManagerInterface $em
* @param ServerRepository $serverRepo
* @return Response Redirection vers la page du serveur
*/
#[Route('/{serverId}/service/{serviceId}/delete', name: 'app_service_delete', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function deleteService(Request $request, int $serverId, int $serviceId, EntityManagerInterface $em, ServerRepository $serverRepo): Response
{
$server = $serverRepo->find($serverId);
if (!$server) {
throw $this->createNotFoundException();
}
if ($this->isCsrfTokenValid('delete_service_' . $serviceId, $request->request->get('_token'))) {
foreach ($server->getServices() as $service) {
if ($service->getId() === $serviceId) {
$em->remove($service);
$em->flush();
$this->addFlash('success', 'Service deleted.');
break;
}
}
}
return $this->redirectToRoute('app_server_show', ['id' => $serverId]);
}
/**
* @brief Ajoute manuellement un service systemd a surveiller
*
* L'admin peut ajouter un service qui n'a pas été detecté automatiquement
* lors du scan initial ou qui a été installé apres l'ajout du serveur.
*
* @param Request $request
* @param int $serverId
* @param EntityManagerInterface $em
* @param ServerRepository $serverRepo
* @return Response Redirection vers la page du serveur
*/
#[Route('/{serverId}/service/new', name: 'app_service_new', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function addService(Request $request, int $serverId, EntityManagerInterface $em, ServerRepository $serverRepo): Response
{
$server = $serverRepo->find($serverId);
if (!$server) {
throw $this->createNotFoundException();
}
if ($this->isCsrfTokenValid('add_service_' . $serverId, $request->request->get('_token'))) {
$service = new Service();
$service->setName($request->request->get('name'));
$service->setDisplayName($request->request->get('display_name'));
$service->setServer($server);
$em->persist($service);
$em->flush();
$this->addFlash('success', 'Service added.');
}
return $this->redirectToRoute('app_server_show', ['id' => $serverId]);
}
/**
* @brief Verifie que l'utilisateur courant a bien acces a ce serveur
*
* Les admins ont acces a tout les serveurs. Pour les opérateurs,
* on verifie qu'il existe un ServerAccess pour cet user et ce serveur.
* Lance une AccessDeniedException si l'acces est refusé.
*
* @param Server $server Le serveur dont on verifie l'acces
* @throws \Symfony\Component\Security\Core\Exception\AccessDeniedException
*/
private function assertServerAccess(Server $server): void
{
if ($this->isGranted('ROLE_ADMIN')) {
return;
}
foreach ($server->getServerAccesses() as $access) {
if ($access->getUser() === $this->getUser()) {
return;
}
}
throw $this->createAccessDeniedException('You do not have access to this server.');
}
}