commit 52ecb4204c69c7a4d8af23f99b1d8f46ce7936f5 Author: Yasser Boulayoune Date: Tue Apr 7 10:12:00 2026 +0200 Squelette Symfony, entités Server et Action diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6699076 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +# editorconfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[{compose.yaml,compose.*.yaml}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/assets/app.js b/assets/app.js new file mode 100644 index 0000000..41fd71a --- /dev/null +++ b/assets/app.js @@ -0,0 +1,2 @@ +import './stimulus_bootstrap.js'; +import './styles/app.css'; diff --git a/assets/controllers.json b/assets/controllers.json new file mode 100644 index 0000000..29ea244 --- /dev/null +++ b/assets/controllers.json @@ -0,0 +1,15 @@ +{ + "controllers": { + "@symfony/ux-turbo": { + "turbo-core": { + "enabled": true, + "fetch": "eager" + }, + "mercure-turbo-stream": { + "enabled": false, + "fetch": "eager" + } + } + }, + "entrypoints": [] +} diff --git a/assets/controllers/csrf_protection_controller.js b/assets/controllers/csrf_protection_controller.js new file mode 100644 index 0000000..511fffa --- /dev/null +++ b/assets/controllers/csrf_protection_controller.js @@ -0,0 +1,81 @@ +const nameCheck = /^[-_a-zA-Z0-9]{4,22}$/; +const tokenCheck = /^[-_/+a-zA-Z0-9]{24,}$/; + +// Generate and double-submit a CSRF token in a form field and a cookie, as defined by Symfony's SameOriginCsrfTokenManager +// Use `form.requestSubmit()` to ensure that the submit event is triggered. Using `form.submit()` will not trigger the event +// and thus this event-listener will not be executed. +document.addEventListener('submit', function (event) { + generateCsrfToken(event.target); +}, true); + +// When @hotwired/turbo handles form submissions, send the CSRF token in a header in addition to a cookie +// The `framework.csrf_protection.check_header` config option needs to be enabled for the header to be checked +document.addEventListener('turbo:submit-start', function (event) { + const h = generateCsrfHeaders(event.detail.formSubmission.formElement); + Object.keys(h).map(function (k) { + event.detail.formSubmission.fetchRequest.headers[k] = h[k]; + }); +}); + +// When @hotwired/turbo handles form submissions, remove the CSRF cookie once a form has been submitted +document.addEventListener('turbo:submit-end', function (event) { + removeCsrfToken(event.detail.formSubmission.formElement); +}); + +export function generateCsrfToken (formElement) { + const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]'); + + if (!csrfField) { + return; + } + + let csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value'); + let csrfToken = csrfField.value; + + if (!csrfCookie && nameCheck.test(csrfToken)) { + csrfField.setAttribute('data-csrf-protection-cookie-value', csrfCookie = csrfToken); + csrfField.defaultValue = csrfToken = btoa(String.fromCharCode.apply(null, (window.crypto || window.msCrypto).getRandomValues(new Uint8Array(18)))); + } + csrfField.dispatchEvent(new Event('change', { bubbles: true })); + + if (csrfCookie && tokenCheck.test(csrfToken)) { + const cookie = csrfCookie + '_' + csrfToken + '=' + csrfCookie + '; path=/; samesite=strict'; + document.cookie = window.location.protocol === 'https:' ? '__Host-' + cookie + '; secure' : cookie; + } +} + +export function generateCsrfHeaders (formElement) { + const headers = {}; + const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]'); + + if (!csrfField) { + return headers; + } + + const csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value'); + + if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) { + headers[csrfCookie] = csrfField.value; + } + + return headers; +} + +export function removeCsrfToken (formElement) { + const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]'); + + if (!csrfField) { + return; + } + + const csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value'); + + if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) { + const cookie = csrfCookie + '_' + csrfField.value + '=0; path=/; samesite=strict; max-age=0'; + + document.cookie = window.location.protocol === 'https:' ? '__Host-' + cookie + '; secure' : cookie; + } +} + +/* stimulusFetch: 'lazy' */ +export default 'csrf-protection-controller'; diff --git a/assets/controllers/hello_controller.js b/assets/controllers/hello_controller.js new file mode 100644 index 0000000..df997e6 --- /dev/null +++ b/assets/controllers/hello_controller.js @@ -0,0 +1,7 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + connect() { + this.element.textContent = 'Hello Stimulus! Edit me in assets/controllers/hello_controller.js'; + } +} diff --git a/assets/stimulus_bootstrap.js b/assets/stimulus_bootstrap.js new file mode 100644 index 0000000..d4e50c9 --- /dev/null +++ b/assets/stimulus_bootstrap.js @@ -0,0 +1,5 @@ +import { startStimulusApp } from '@symfony/stimulus-bundle'; + +const app = startStimulusApp(); +// register any custom, 3rd party controllers here +// app.register('some_controller_name', SomeImportedController); diff --git a/assets/styles/app.css b/assets/styles/app.css new file mode 100644 index 0000000..7f39c1b --- /dev/null +++ b/assets/styles/app.css @@ -0,0 +1,405 @@ +/* ── Layout ────────────────────────────────────────────────── */ +.app-wrapper { + display: flex; + min-height: 100vh; +} + +.main-column { + flex: 1; + display: flex; + flex-direction: column; + min-height: 100vh; + overflow-x: hidden; +} + +/* ── Sidebar ───────────────────────────────────────────────── */ +.sidebar { + width: 220px; + flex-shrink: 0; + min-height: 100vh; + padding: 1rem; + display: flex; + flex-direction: column; +} + +.sidebar .brand { + font-size: 1.05rem; + font-weight: 700; + text-decoration: none; + display: block; + margin-bottom: 1.25rem; + padding: .3rem .6rem; + border-radius: 6px; + letter-spacing: -.01em; +} + +.sidebar .nav-link { + border-radius: 6px; + padding: .45rem .75rem; + font-size: .88rem; + transition: background .12s, color .12s; +} + +.sidebar-section-label { + font-size: .63rem; + font-weight: 700; + letter-spacing: .1em; + text-transform: uppercase; + padding: .3rem .75rem .1rem; + display: block; +} + +/* ── Sidebar footer ────────────────────────────────────────── */ +.sidebar-footer { + padding-top: .75rem; + border-top: 1px solid transparent; +} + +.sidebar-user { + display: flex; + align-items: center; + gap: .5rem; + padding: .3rem .5rem; + margin-bottom: .4rem; + border-radius: 6px; +} + +.user-avatar { + width: 26px; + height: 26px; + border-radius: 50%; + background: #5865f2; + color: #fff; + font-size: .65rem; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.user-name { + font-size: .82rem; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sidebar-controls { + display: flex; + align-items: center; + gap: .4rem; + padding: .2rem .5rem; +} + +/* ── Theme switch (iOS style) ──────────────────────────────── */ +.theme-switch-wrap { + display: flex; + align-items: center; + gap: .35rem; + font-size: .72rem; +} + +.theme-switch { + position: relative; + display: inline-block; + cursor: pointer; + margin: 0; +} + +#theme-checkbox { + opacity: 0; + position: absolute; + z-index: -1; + width: 0; + height: 0; +} + +.theme-track { + display: inline-block; + width: 38px; + height: 22px; + border-radius: 22px; + background: #9da3ae; + position: relative; + transition: background .22s ease-in-out; + vertical-align: middle; +} + +.theme-track::after { + content: ''; + position: absolute; + top: 3px; + left: 3px; + width: 16px; + height: 16px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 4px rgba(0,0,0,.35); + transition: left .22s ease-in-out, transform .22s ease-in-out; +} + +#theme-checkbox:checked + .theme-track { + background: #5865f2; +} + +#theme-checkbox:checked + .theme-track::after { + left: calc(100% - 3px); + transform: translateX(-100%); +} + +.theme-icon-sun { color: #f0a30c; } +.theme-icon-moon { color: #9da3ae; } + +/* ── Logout button ─────────────────────────────────────────── */ +.btn-logout { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + text-decoration: none; + font-size: .85rem; + transition: background .12s, color .12s; + flex-shrink: 0; +} + +/* ── Dark mode ─────────────────────────────────────────────── */ +[data-bs-theme="dark"] body { + background-color: #313338; + color: #dbdee1; +} + +[data-bs-theme="dark"] .sidebar { + background: #2b2d31; + border-right: 1px solid #1e1f22; +} +[data-bs-theme="dark"] .sidebar .brand { color: #fff; } +[data-bs-theme="dark"] .sidebar .nav-link { color: #b5bac1; } +[data-bs-theme="dark"] .sidebar .nav-link:hover { color: #dde0e4; background: rgba(255,255,255,.05); } +[data-bs-theme="dark"] .sidebar .nav-link.active { + color: #fff; + background: rgba(88,101,242,.18); + box-shadow: inset 3px 0 0 #5865f2; +} +[data-bs-theme="dark"] .sidebar-section-label { color: #72767d; } + +[data-bs-theme="dark"] .sidebar-footer { border-top-color: #1e1f22; } +[data-bs-theme="dark"] .sidebar-user { color: #dbdee1; } +[data-bs-theme="dark"] .user-name { color: #dbdee1; } +[data-bs-theme="dark"] .btn-logout { color: #72767d; } +[data-bs-theme="dark"] .btn-logout:hover { background: rgba(242,63,67,.12); color: #f23f43; } + +[data-bs-theme="dark"] .card { + background: #2b2d31 !important; + border: 1px solid #1e1f22 !important; + box-shadow: 0 2px 8px rgba(0,0,0,.4); +} +[data-bs-theme="dark"] .card-header { + background: rgba(0,0,0,.22) !important; + border-bottom: 1px solid #1e1f22 !important; + color: #e0e2e5; +} +[data-bs-theme="dark"] .card-footer { + background: rgba(0,0,0,.14) !important; + border-top-color: #1e1f22 !important; +} + +[data-bs-theme="dark"] .table { color: #dbdee1; border-color: #3a3d44; } +[data-bs-theme="dark"] .table th { color: #9ea3ab; } +[data-bs-theme="dark"] .table-light, +[data-bs-theme="dark"] .table-light th, +[data-bs-theme="dark"] .table-light td { color: #212529 !important; } + +[data-bs-theme="dark"] .form-control, +[data-bs-theme="dark"] .form-select { + background-color: #1e1f22; + border-color: #3a3d44; + color: #dbdee1; +} +[data-bs-theme="dark"] .form-control:focus, +[data-bs-theme="dark"] .form-select:focus { + border-color: #5865f2; + box-shadow: 0 0 0 3px rgba(88,101,242,.2); + background-color: #1e1f22; + color: #dbdee1; +} +[data-bs-theme="dark"] .input-group-text { + background-color: #1e1f22; + border-color: #3a3d44; + color: #72767d; +} +[data-bs-theme="dark"] .modal-content { + background: #2b2d31; + border-color: #1e1f22; +} +[data-bs-theme="dark"] .modal-header, +[data-bs-theme="dark"] .modal-footer { border-color: #3a3d44; } + +/* ── Light mode ────────────────────────────────────────────── */ +[data-bs-theme="light"] body { + background-color: #f2f3f5; + color: #2e3338; +} + +[data-bs-theme="light"] .sidebar { + background: #f2f3f5; + border-right: 1px solid #e3e5e8; +} +[data-bs-theme="light"] .sidebar .brand { color: #1a1c1e; } +[data-bs-theme="light"] .sidebar .nav-link { color: #4e5058; } +[data-bs-theme="light"] .sidebar .nav-link:hover { color: #1a1c1e; background: rgba(0,0,0,.05); } +[data-bs-theme="light"] .sidebar .nav-link.active { + color: #5865f2; + background: rgba(88,101,242,.1); + box-shadow: inset 3px 0 0 #5865f2; +} +[data-bs-theme="light"] .sidebar-section-label { color: #747f8d; } + +[data-bs-theme="light"] .sidebar-footer { border-top-color: #e3e5e8; } +[data-bs-theme="light"] .sidebar-user { color: #2e3338; } +[data-bs-theme="light"] .user-name { color: #2e3338; } +[data-bs-theme="light"] .btn-logout { color: #9da3ae; } +[data-bs-theme="light"] .btn-logout:hover { background: rgba(242,63,67,.08); color: #f23f43; } + +[data-bs-theme="light"] .card { + background: #fff !important; + border: 1px solid #e3e5e8 !important; + box-shadow: 0 1px 4px rgba(0,0,0,.06); +} +[data-bs-theme="light"] .card-header { + background: #f7f8f9 !important; + border-bottom: 1px solid #e3e5e8 !important; + color: #1a1c1e; +} +[data-bs-theme="light"] .card-footer { + background: #f7f8f9 !important; + border-top-color: #e3e5e8 !important; +} + +[data-bs-theme="light"] .form-control:focus, +[data-bs-theme="light"] .form-select:focus { + border-color: #5865f2; + box-shadow: 0 0 0 3px rgba(88,101,242,.15); +} +[data-bs-theme="light"] .text-white-50 { color: rgba(0,0,0,.45) !important; } + +/* ── Status ────────────────────────────────────────────────── */ +.status-online { color: #23a55a; } +.status-offline { color: #f23f43; } +.status-unknown { color: #80848e; } + +/* ── Tables ────────────────────────────────────────────────── */ +thead th { border-bottom: 2px solid var(--bs-border-color); font-size: .82rem; letter-spacing: .02em; } + +/* ── Terminal ──────────────────────────────────────────────── */ +.terminal-titlebar { + background: #161b22; + padding: 7px 14px; + border-radius: 8px 8px 0 0; + display: flex; + align-items: center; + user-select: none; + border-bottom: 1px solid #21262d; +} +.terminal-host { + color: #58a6ff; + font-size: .78rem; + font-family: 'Courier New', monospace; +} + +.terminal-body { + padding: 12px 14px; + height: 380px; + overflow-y: auto; + color: #c9d1d9; + background: #0d1117; + font-size: .82rem; + line-height: 1.65; +} +.terminal-body::-webkit-scrollbar { width: 5px; } +.terminal-body::-webkit-scrollbar-thumb { background: #30363d; border-radius: 3px; } + +.term-line { white-space: pre-wrap; word-break: break-all; } +.term-prompt { color: #79c0ff; } +.term-success { color: #56d364; } +.term-error { color: #ff7b72; } +.term-info { color: #8b949e; font-style: italic; } +.term-warn { color: #e3b341; } + +.terminal-input-row { + display: flex; + align-items: center; + background: #0d1117; + border-top: 1px solid #21262d; + padding: 8px 14px; + border-radius: 0 0 8px 8px; +} +.terminal-ps1 { + color: #79c0ff; + font-family: 'Courier New', monospace; + font-size: .82rem; + white-space: nowrap; + margin-right: 6px; +} +.terminal-cmd { + flex: 1; + background: transparent; + border: none; + outline: none; + color: #c9d1d9; + font-family: 'Courier New', Courier, monospace; + font-size: .82rem; + caret-color: #79c0ff; +} +.terminal-cmd::placeholder { color: #3d444d; } + +#terminal-card { display: flex; flex-direction: column; } +#terminal-card .card-body { display: flex; flex-direction: column; background: #0d1117 !important; padding: 0 !important; } + +/* ── Service cards ─────────────────────────────────────────── */ +.service-card { border: 1px solid var(--bs-border-color) !important; box-shadow: 0 1px 3px rgba(0,0,0,.1) !important; } +.service-card .card-footer { background: transparent !important; border-top: 1px solid var(--bs-border-color) !important; } + +/* ── Forms ─────────────────────────────────────────────────── */ +.form-section-title { + font-size: .7rem; + font-weight: 700; + letter-spacing: .1em; + text-transform: uppercase; + color: #80848e; + margin-bottom: .75rem; + padding-bottom: .4rem; + border-bottom: 1px solid rgba(128,132,142,.2); +} +.field-icon { color: #80848e; } +.input-group-text { background: transparent; } +.role-badge { font-size: .75rem; padding: .3em .6em; } + +/* ── Server picker ─────────────────────────────────────────── */ +.server-search-wrap { position: relative; } +.server-search-wrap .bi-search { + position: absolute; left: 10px; top: 50%; transform: translateY(-50%); + color: #80848e; pointer-events: none; +} +#server-search { padding-left: 2rem; } + +#server-list { max-height: 340px; overflow-y: auto; } +#server-list::-webkit-scrollbar { width: 5px; } +#server-list::-webkit-scrollbar-thumb { background: rgba(128,132,142,.3); border-radius: 3px; } + +.server-item { transition: background .12s; } +.server-item.hidden { display: none !important; } + +.grant-form { + display: none; + background: rgba(var(--bs-primary-rgb), .05); + border-top: 1px solid rgba(var(--bs-primary-rgb), .15); + padding: .75rem 1rem; + gap: .5rem; +} +.server-item.open .grant-form { display: flex; flex-wrap: wrap; align-items: flex-end; } +.grant-form .form-label { font-size: .78rem; font-weight: 600; margin-bottom: .2rem; } +.grant-form .form-control { font-size: .82rem; } diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..11122db --- /dev/null +++ b/composer.json @@ -0,0 +1,113 @@ +{ + "type": "project", + "license": "proprietary", + "minimum-stability": "stable", + "prefer-stable": true, + "require": { + "php": ">=8.4", + "ext-ctype": "*", + "ext-iconv": "*", + "doctrine/doctrine-bundle": "^3.2", + "doctrine/doctrine-migrations-bundle": "^4.0", + "doctrine/orm": "^3.6", + "knplabs/knp-paginator-bundle": "^6.10", + "phpdocumentor/reflection-docblock": "^6.0", + "phpseclib/phpseclib": "^3.0", + "phpstan/phpdoc-parser": "^2.3", + "symfony/asset": "8.0.*", + "symfony/asset-mapper": "8.0.*", + "symfony/console": "8.0.*", + "symfony/doctrine-messenger": "8.0.*", + "symfony/dotenv": "8.0.*", + "symfony/expression-language": "8.0.*", + "symfony/flex": "^2", + "symfony/form": "8.0.*", + "symfony/framework-bundle": "8.0.*", + "symfony/http-client": "8.0.*", + "symfony/intl": "8.0.*", + "symfony/mailer": "8.0.*", + "symfony/mime": "8.0.*", + "symfony/monolog-bundle": "^3.0|^4.0", + "symfony/notifier": "8.0.*", + "symfony/process": "8.0.*", + "symfony/property-access": "8.0.*", + "symfony/property-info": "8.0.*", + "symfony/runtime": "8.0.*", + "symfony/security-bundle": "8.0.*", + "symfony/serializer": "8.0.*", + "symfony/stimulus-bundle": "^3.0", + "symfony/string": "8.0.*", + "symfony/translation": "8.0.*", + "symfony/twig-bundle": "8.0.*", + "symfony/ux-turbo": "^3.0", + "symfony/validator": "8.0.*", + "symfony/web-link": "8.0.*", + "symfony/yaml": "8.0.*", + "symfonycasts/reset-password-bundle": "^1.25", + "twig/extra-bundle": "^2.12|^3.0", + "twig/twig": "^2.12|^3.0" + }, + "config": { + "allow-plugins": { + "php-http/discovery": true, + "symfony/flex": true, + "symfony/runtime": true + }, + "bump-after-update": true, + "sort-packages": true + }, + "autoload": { + "psr-4": { + "App\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "App\\Tests\\": "tests/" + } + }, + "replace": { + "symfony/polyfill-ctype": "*", + "symfony/polyfill-iconv": "*", + "symfony/polyfill-php72": "*", + "symfony/polyfill-php73": "*", + "symfony/polyfill-php74": "*", + "symfony/polyfill-php80": "*", + "symfony/polyfill-php81": "*", + "symfony/polyfill-php82": "*", + "symfony/polyfill-php83": "*", + "symfony/polyfill-php84": "*" + }, + "scripts": { + "auto-scripts": { + "cache:clear": "symfony-cmd", + "assets:install %PUBLIC_DIR%": "symfony-cmd", + "importmap:install": "symfony-cmd" + }, + "post-install-cmd": [ + "@auto-scripts" + ], + "post-update-cmd": [ + "@auto-scripts" + ] + }, + "conflict": { + "symfony/symfony": "*" + }, + "extra": { + "symfony": { + "allow-contrib": false, + "require": "8.0.*" + } + }, + "require-dev": { + "doctrine/doctrine-fixtures-bundle": "^4.3", + "phpunit/phpunit": "^13.1", + "symfony/browser-kit": "8.0.*", + "symfony/css-selector": "8.0.*", + "symfony/debug-bundle": "8.0.*", + "symfony/maker-bundle": "^1.0", + "symfony/stopwatch": "8.0.*", + "symfony/web-profiler-bundle": "8.0.*" + } +} diff --git a/migrations/.gitignore b/migrations/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/migrations/Version20260417000001.php b/migrations/Version20260417000001.php new file mode 100644 index 0000000..d4184ec --- /dev/null +++ b/migrations/Version20260417000001.php @@ -0,0 +1,86 @@ +addSql('CREATE TABLE app_user ( + user_id SERIAL NOT NULL, + email VARCHAR(180) NOT NULL, + pseudo VARCHAR(50) NOT NULL, + roles JSON NOT NULL, + password VARCHAR(255) NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + reset_token VARCHAR(100) DEFAULT NULL, + reset_token_expires_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, + PRIMARY KEY(user_id) + )'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_app_user_email ON app_user (email)'); + $this->addSql('COMMENT ON COLUMN app_user.created_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN app_user.reset_token_expires_at IS \'(DC2Type:datetime_immutable)\''); + + $this->addSql('CREATE TABLE server ( + server_id SERIAL NOT NULL, + owner_id INT NOT NULL, + name VARCHAR(100) NOT NULL, + ip_address VARCHAR(45) NOT NULL, + ssh_port SMALLINT NOT NULL DEFAULT 22, + ssh_user VARCHAR(50) NOT NULL, + ssh_password TEXT NOT NULL, + description TEXT DEFAULT NULL, + last_status VARCHAR(10) NOT NULL DEFAULT \'unknown\', + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + PRIMARY KEY(server_id) + )'); + $this->addSql('COMMENT ON COLUMN server.created_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE server ADD CONSTRAINT FK_server_owner FOREIGN KEY (owner_id) REFERENCES app_user (user_id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + + $this->addSql('CREATE TABLE service ( + service_id SERIAL NOT NULL, + server_id INT NOT NULL, + name VARCHAR(50) NOT NULL, + display_name VARCHAR(100) NOT NULL, + PRIMARY KEY(service_id) + )'); + $this->addSql('ALTER TABLE service ADD CONSTRAINT FK_service_server FOREIGN KEY (server_id) REFERENCES server (server_id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + + $this->addSql('CREATE TABLE action_log ( + log_id SERIAL NOT NULL, + user_id INT DEFAULT NULL, + server_id INT NOT NULL, + action_type VARCHAR(20) NOT NULL, + status VARCHAR(10) NOT NULL, + output TEXT DEFAULT NULL, + executed_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + PRIMARY KEY(log_id) + )'); + $this->addSql('COMMENT ON COLUMN action_log.executed_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE action_log ADD CONSTRAINT FK_log_user FOREIGN KEY (user_id) REFERENCES app_user (user_id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE action_log ADD CONSTRAINT FK_log_server FOREIGN KEY (server_id) REFERENCES server (server_id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE action_log DROP CONSTRAINT FK_log_user'); + $this->addSql('ALTER TABLE action_log DROP CONSTRAINT FK_log_server'); + $this->addSql('ALTER TABLE service DROP CONSTRAINT FK_service_server'); + $this->addSql('ALTER TABLE server DROP CONSTRAINT FK_server_owner'); + $this->addSql('DROP TABLE action_log'); + $this->addSql('DROP TABLE service'); + $this->addSql('DROP TABLE server'); + $this->addSql('DROP TABLE app_user'); + } +} diff --git a/migrations/Version20260417000002.php b/migrations/Version20260417000002.php new file mode 100644 index 0000000..f218516 --- /dev/null +++ b/migrations/Version20260417000002.php @@ -0,0 +1,38 @@ +addSql('CREATE TABLE server_access ( + access_id SERIAL NOT NULL, + server_id INT NOT NULL, + user_id INT NOT NULL, + ssh_user VARCHAR(50) NOT NULL, + ssh_password TEXT NOT NULL, + PRIMARY KEY(access_id), + CONSTRAINT uq_server_user UNIQUE (server_id, user_id) + )'); + $this->addSql('ALTER TABLE server_access + ADD CONSTRAINT FK_sa_server FOREIGN KEY (server_id) REFERENCES server (server_id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE server_access + ADD CONSTRAINT FK_sa_user FOREIGN KEY (user_id) REFERENCES app_user (user_id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE server_access'); + } +} diff --git a/src/Controller/.gitignore b/src/Controller/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/src/Controller/ActionLogController.php b/src/Controller/ActionLogController.php new file mode 100644 index 0000000..f5cc927 --- /dev/null +++ b/src/Controller/ActionLogController.php @@ -0,0 +1,77 @@ +query->getInt('server') ?: null; + $userId = $request->query->getInt('user') ?: null; + $dateFrom = $request->query->get('date_from'); + $dateTo = $request->query->get('date_to'); + + $query = $logRepo->findByFilters($serverId, $userId, $dateFrom, $dateTo)->getQuery(); + $logs = $paginator->paginate($query, $request->query->getInt('page', 1), 20); + + return $this->render('logs/index.html.twig', [ + 'logs' => $logs, + 'servers' => $serverRepo->findAll(), + // la liste des users dans les filtres est reservée aux admins + 'users' => $this->isGranted('ROLE_ADMIN') ? $userRepo->findAll() : [], + 'filters' => compact('serverId', 'userId', 'dateFrom', 'dateTo'), + ]); + } +} diff --git a/src/Controller/AdminController.php b/src/Controller/AdminController.php new file mode 100644 index 0000000..6a8580e --- /dev/null +++ b/src/Controller/AdminController.php @@ -0,0 +1,306 @@ +render('admin/users.html.twig', [ + 'users' => $userRepo->findBy([], ['createdAt' => 'DESC']), + ]); + } + + /** + * @brief Edition d'un compte utilisateur (roles, statut actif) + * + * Contrairement a avant, un admin peut maintenant editer son propre compte. + * Dans ce cas on passe isSelf=true au template pour griser les roles/statut + * et empecher l'admin de se retirer ses propres privileges par accident. + * + * @param Request $request + * @param User $user L'utilisateur a modifier (resolu par Doctrine) + * @param EntityManagerInterface $em + * @param ServerRepository $serverRepo Pour afficher les serveurs disponibles + * @param ServerAccessRepository $accessRepo Pour afficher les acces existants + * @return Response Le formulaire d'edition ou redirection + */ + #[Route('/users/{id}/edit', name: 'app_admin_user_edit', requirements: ['id' => '\d+'])] + public function editUser( + Request $request, + User $user, + EntityManagerInterface $em, + ServerRepository $serverRepo, + ServerAccessRepository $accessRepo, + ): Response { + $isSelf = ($user === $this->getUser()); + + // formulaire roles/statut - soumis seulement si pas soi meme + $form = $this->createForm(UserType::class, $user); + + // formulaire email - onglet 2 + $emailForm = $this->createForm(AdminEmailType::class, $user, [ + 'action' => $this->generateUrl('app_admin_user_change_email', ['id' => $user->getId()]), + ]); + + // formulaire mot de passe - onglet 3 + $passwordForm = $this->createForm(AdminPasswordChangeType::class, null, [ + 'action' => $this->generateUrl('app_admin_user_change_password', ['id' => $user->getId()]), + ]); + + if (!$isSelf) { + $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + $em->flush(); + $this->addFlash('success', 'Utilisateur mis a jour.'); + return $this->redirectToRoute('app_admin_user_edit', ['id' => $user->getId()]); + } + } + + $accesses = $accessRepo->findBy(['user' => $user]); + $assignedIds = array_map(fn($a) => $a->getServer()->getId(), $accesses); + $allServers = $serverRepo->findBy([], ['name' => 'ASC']); + + return $this->render('admin/edit_user.html.twig', [ + 'form' => $form, + 'emailForm' => $emailForm, + 'passwordForm' => $passwordForm, + 'user' => $user, + 'accesses' => $accesses, + 'allServers' => $allServers, + 'assignedIds' => $assignedIds, + 'isSelf' => $isSelf, + // onglet actif selon la query string pour rester sur le bon apres soumission + 'activeTab' => $request->query->get('tab', 'roles'), + ]); + } + + /** + * @brief Change l'adresse email d'un utilisateur + * + * Route dediée pour le formulaire email de l'onglet "Email". + * Verifie que le nouvel email n'est pas deja utilisé par un autre compte + * avant de sauvegarder. L'admin peut changer son propre email aussi. + * + * @param Request $request + * @param User $user L'utilisateur dont on change l'email + * @param EntityManagerInterface $em + * @param UserRepository $userRepo Pour verifier l'unicité de l'email + * @return Response Redirection vers la page d'edition avec l'onglet email actif + */ + #[Route('/users/{id}/change-email', name: 'app_admin_user_change_email', methods: ['POST'], requirements: ['id' => '\d+'])] + public function changeUserEmail( + Request $request, + User $user, + EntityManagerInterface $em, + UserRepository $userRepo, + ): Response { + $emailForm = $this->createForm(AdminEmailType::class, $user); + $emailForm->handleRequest($request); + + if ($emailForm->isSubmitted() && $emailForm->isValid()) { + $newEmail = $emailForm->get('email')->getData(); + + // verifier que cet email est pas deja pris par quelqu'un d'autre + $existing = $userRepo->findOneBy(['email' => $newEmail]); + if ($existing && $existing->getId() !== $user->getId()) { + $this->addFlash('danger', 'Cet email est déjà utilisé par un autre compte.'); + return $this->redirectToRoute('app_admin_user_edit', ['id' => $user->getId(), 'tab' => 'email']); + } + + $em->flush(); + $this->addFlash('success', 'Email mis a jour.'); + } else { + $this->addFlash('danger', 'Email invalide.'); + } + + return $this->redirectToRoute('app_admin_user_edit', ['id' => $user->getId(), 'tab' => 'email']); + } + + /** + * @brief Change le mot de passe d'un utilisateur (privilege admin) + * + * L'admin n'a pas besoin de connaitre l'ancien mot de passe. + * Le nouveau mot de passe est hashé avant d'etre persisté. + * + * @param Request $request + * @param User $user L'utilisateur dont on change le mot de passe + * @param EntityManagerInterface $em + * @param UserPasswordHasherInterface $passwordHasher + * @return Response Redirection vers la page d'edition avec l'onglet password actif + */ + #[Route('/users/{id}/change-password', name: 'app_admin_user_change_password', methods: ['POST'], requirements: ['id' => '\d+'])] + public function changeUserPassword( + Request $request, + User $user, + EntityManagerInterface $em, + UserPasswordHasherInterface $passwordHasher, + ): Response { + $passwordForm = $this->createForm(AdminPasswordChangeType::class); + $passwordForm->handleRequest($request); + + if ($passwordForm->isSubmitted() && $passwordForm->isValid()) { + $newPassword = $passwordForm->get('newPassword')->getData(); + $user->setPassword($passwordHasher->hashPassword($user, $newPassword)); + $em->flush(); + $this->addFlash('success', 'Mot de passe mis a jour pour ' . $user->getPseudo() . '.'); + } else { + $this->addFlash('danger', 'Formulaire invalide, verifie les champs.'); + } + + return $this->redirectToRoute('app_admin_user_edit', ['id' => $user->getId(), 'tab' => 'password']); + } + + /** + * @brief Attribue l'accés a un serveur pour un operateur + * + * Cree un ServerAccess avec les credentials SSH specifique a cet operateur. + * Verifie le token CSRF et que l'acces n'existe pas deja pour eviter les doublons. + * + * @param Request $request + * @param User $user L'operateur qui recoit l'acces + * @param EntityManagerInterface $em + * @param ServerRepository $serverRepo + * @param ServerAccessRepository $accessRepo Pour verifier si l'acces existe déja + * @return Response Redirection vers la page d'edition de l'utilisateur + */ + #[Route('/users/{id}/access/grant', name: 'app_admin_grant_access', methods: ['POST'], requirements: ['id' => '\d+'])] + public function grantAccess( + Request $request, + User $user, + EntityManagerInterface $em, + ServerRepository $serverRepo, + ServerAccessRepository $accessRepo, + ): Response { + if (!$this->isCsrfTokenValid('grant_access_' . $user->getId(), $request->request->get('_token'))) { + $this->addFlash('error', 'Invalid token.'); + return $this->redirectToRoute('app_admin_user_edit', ['id' => $user->getId()]); + } + + $server = $serverRepo->find($request->request->get('server_id')); + if (!$server || $accessRepo->findOneBy(['server' => $server, 'user' => $user])) { + $this->addFlash('warning', 'Access already exists or server not found.'); + return $this->redirectToRoute('app_admin_user_edit', ['id' => $user->getId()]); + } + + $access = new ServerAccess(); + $access->setServer($server)->setUser($user) + ->setSshUser($request->request->get('ssh_user')) + ->setSshPassword($request->request->get('ssh_password')); + $em->persist($access); + $em->flush(); + + $this->addFlash('success', $user->getPseudo() . ' can now access ' . $server->getName() . '.'); + return $this->redirectToRoute('app_admin_user_edit', ['id' => $user->getId()]); + } + + /** + * @brief Revoque l'acces d'un operateur a un serveur + * + * Supprime l'entité ServerAccess correspondante. Verifie le CSRF + * et que l'acces appartient bien a l'utilisateur en parametre. + * + * @param Request $request + * @param int $userId Id de l'utilisateur concerne + * @param int $accessId Id du ServerAccess a supprimer + * @param EntityManagerInterface $em + * @param ServerAccessRepository $accessRepo + * @return Response Redirection vers la page d'edition de l'utilisateur + */ + #[Route('/users/{userId}/access/{accessId}/revoke', name: 'app_admin_revoke_access', methods: ['POST'])] + public function revokeAccess( + Request $request, + int $userId, + int $accessId, + EntityManagerInterface $em, + ServerAccessRepository $accessRepo, + ): Response { + $access = $accessRepo->find($accessId); + if ($access && $access->getUser()->getId() === $userId + && $this->isCsrfTokenValid('revoke_access_' . $accessId, $request->request->get('_token'))) { + $em->remove($access); + $em->flush(); + $this->addFlash('success', 'Access revoked.'); + } + + return $this->redirectToRoute('app_admin_user_edit', ['id' => $userId]); + } + + /** + * @brief Active ou desactive un compte utilisateur + * + * Inverse l'etat isActive de l'utilisateur. Un admin ne peut pas + * se desactiver lui meme (protection contre les erreur). + * Le token CSRF est verifié avant de modifier l'etat. + * + * @param Request $request + * @param User $user L'utilisateur a activer/desactiver + * @param EntityManagerInterface $em + * @return Response Redirection vers la liste des utilisateurs + */ + #[Route('/users/{id}/toggle', name: 'app_admin_user_toggle', methods: ['POST'], requirements: ['id' => '\d+'])] + public function toggleUser(Request $request, User $user, EntityManagerInterface $em): Response + { + if ($user === $this->getUser()) { + $this->addFlash('error', 'You cannot deactivate yourself.'); + return $this->redirectToRoute('app_admin_users'); + } + + if ($this->isCsrfTokenValid('toggle_user_' . $user->getId(), $request->request->get('_token'))) { + $user->setIsActive(!$user->isActive()); + $em->flush(); + $this->addFlash('success', 'User status updated.'); + } + + return $this->redirectToRoute('app_admin_users'); + } +} diff --git a/src/Controller/PasswordResetController.php b/src/Controller/PasswordResetController.php new file mode 100644 index 0000000..0c01fe5 --- /dev/null +++ b/src/Controller/PasswordResetController.php @@ -0,0 +1,162 @@ +getUser()) { + return $this->redirectToRoute('app_home'); + } + + $form = $this->createForm(ForgotPasswordType::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $email = $form->get('email')->getData(); + $user = $userRepo->findOneBy(['email' => $email]); + + // on envoie l'email uniquement si le compte existe + if ($user) { + $token = bin2hex(random_bytes(32)); + $expiresAt = new \DateTimeImmutable('+1 hour'); + + $user->setResetToken($token); + $user->setResetTokenExpiresAt($expiresAt); + $em->flush(); + + $resetUrl = $this->generateUrl( + 'app_reset_password', + ['token' => $token], + UrlGeneratorInterface::ABSOLUTE_URL + ); + + $message = (new Email()) + ->from('noreply@servercontrol.local') + ->to($user->getEmail()) + ->subject('ServerControl - Reinitialisation de mot de passe') + ->html($this->renderView('emails/reset_password.html.twig', [ + 'user' => $user, + 'resetUrl' => $resetUrl, + ])); + + $mailer->send($message); + } + + // on affiche toujours le meme message pour pas divulguer si l'email existe + $this->addFlash('success', 'Si un compte correspond a cet email, vous allez recevoir un lien de reinitialisation.'); + return $this->redirectToRoute('app_forgot_password'); + } + + return $this->render('security/forgot_password.html.twig', ['form' => $form]); + } + + /** + * @brief Etape 2 : validation du token et saisie du nouveau mot de passe + * + * Verifie que le token existe en base et qu'il n'est pas expiré. + * Si valide, affiche le formulaire de nouveau mot de passe. + * Apres succes, le token est effacé et l'utilisateur est redirigé vers le login. + * + * @param string $token Le token de reset depuis l'URL + * @param Request $request + * @param UserRepository $userRepo + * @param EntityManagerInterface $em + * @param UserPasswordHasherInterface $passwordHasher + * @return Response Le formulaire de reset ou redirection + */ + #[Route('/reset-password/{token}', name: 'app_reset_password')] + public function resetPassword( + string $token, + Request $request, + UserRepository $userRepo, + EntityManagerInterface $em, + UserPasswordHasherInterface $passwordHasher, + ): Response { + $user = $userRepo->findOneBy(['resetToken' => $token]); + + // token invalide ou expiré + if (!$user || !$user->getResetTokenExpiresAt() || $user->getResetTokenExpiresAt() < new \DateTimeImmutable()) { + $this->addFlash('danger', 'Ce lien de reinitialisation est invalide ou a expiré.'); + return $this->redirectToRoute('app_forgot_password'); + } + + $form = $this->createForm(ResetPasswordType::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $newPassword = $form->get('plainPassword')->getData(); + + $user->setPassword($passwordHasher->hashPassword($user, $newPassword)); + // on efface le token apres utilisation pour qu'il ne peut plus etre reutilisé + $user->setResetToken(null); + $user->setResetTokenExpiresAt(null); + $em->flush(); + + $this->addFlash('success', 'Votre mot de passe a bien été mis a jour. Vous pouvez vous connecter.'); + return $this->redirectToRoute('app_login'); + } + + return $this->render('security/reset_password.html.twig', [ + 'form' => $form, + 'token' => $token, + ]); + } +} diff --git a/src/Controller/ProfileController.php b/src/Controller/ProfileController.php new file mode 100644 index 0000000..33c603c --- /dev/null +++ b/src/Controller/ProfileController.php @@ -0,0 +1,80 @@ +getUser(); + $form = $this->createForm(ChangePasswordType::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $currentPassword = $form->get('currentPassword')->getData(); + $newPassword = $form->get('newPassword')->getData(); + + // verifier que l'ancien mot de passe est correcte avant de changer + if (!$passwordHasher->isPasswordValid($user, $currentPassword)) { + $this->addFlash('danger', 'Mot de passe actuel incorrect.'); + return $this->redirectToRoute('app_profile'); + } + + $user->setPassword($passwordHasher->hashPassword($user, $newPassword)); + $em->flush(); + + $this->addFlash('success', 'Votre mot de passe a bien été mis a jour.'); + return $this->redirectToRoute('app_profile'); + } + + return $this->render('security/profile.html.twig', [ + 'form' => $form, + ]); + } +} diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php new file mode 100644 index 0000000..5e54c28 --- /dev/null +++ b/src/Controller/SecurityController.php @@ -0,0 +1,147 @@ +getUser()) { + return $this->isGranted('ROLE_OPERATOR') + ? $this->redirectToRoute('app_server_index') + : $this->redirectToRoute('app_pending'); + } + + return $this->redirectToRoute('app_login'); + } + + /** + * @brief Page d'attente pour les comptes non encore promu par un admin + * + * Affiché aux utilisateurs avec ROLE_USER uniquement. + * Si l'utilisateur est déja opérateur il est redirigé automatiquement. + * + * @return Response La page d'attente ou une redirection + */ + #[Route('/pending', name: 'app_pending')] + public function pending(): Response + { + if ($this->isGranted('ROLE_OPERATOR')) { + return $this->redirectToRoute('app_server_index'); + } + + return $this->render('security/pending.html.twig'); + } + + /** + * @brief Page de connexion + * + * Affiche le formulaire de login. Si l'utilisateur est déja connecté + * on le redirige directement vers la home page. + * Symfony gere la soumission du form via le firewall, pas ici. + * + * @param AuthenticationUtils $authenticationUtils Utilitaire Symfony pour recuperer les erreurs + * @return Response La page de login avec eventuellement un message d'erreur + */ + #[Route('/login', name: 'app_login')] + public function login(AuthenticationUtils $authenticationUtils): Response + { + if ($this->getUser()) { + return $this->redirectToRoute('app_home'); + } + + return $this->render('security/login.html.twig', [ + 'last_username' => $authenticationUtils->getLastUsername(), + 'error' => $authenticationUtils->getLastAuthenticationError(), + ]); + } + + /** + * @brief Route de deconnexion - géré automatiquement par Symfony + * + * Cette methode est vide car Symfony intercepte la route /logout + * via la configuration du firewall avant que le code soit executé. + */ + #[Route('/logout', name: 'app_logout')] + public function logout(): void {} + + /** + * @brief Page et traitement du formulaire d'inscription + * + * Cree un nouveau compte avec ROLE_USER par defaut. Le mot de passe + * est hashé avec le PasswordHasher de Symfony avant d'etre persisté. + * Le compte devra etre validé par un admin pour avoir acces aux serveurs. + * + * @param Request $request La requete HTTP + * @param UserPasswordHasherInterface $passwordHasher Pour hasher le mot de passe + * @param EntityManagerInterface $em Pour persister l'utilisateur en base + * @return Response Le formulaire d'inscription ou une redirection apres succes + */ + #[Route('/register', name: 'app_register')] + public function register( + Request $request, + UserPasswordHasherInterface $passwordHasher, + EntityManagerInterface $em, + ): Response { + if ($this->getUser()) { + return $this->redirectToRoute('app_home'); + } + + $user = new User(); + $form = $this->createForm(RegistrationFormType::class, $user); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $user->setPassword( + $passwordHasher->hashPassword($user, $form->get('plainPassword')->getData()) + ); + $user->setRoles(['ROLE_USER']); + $em->persist($user); + $em->flush(); + + $this->addFlash('success', 'Account created! You can now log in.'); + return $this->redirectToRoute('app_login'); + } + + return $this->render('security/register.html.twig', ['form' => $form]); + } +} diff --git a/src/Controller/ServerController.php b/src/Controller/ServerController.php new file mode 100644 index 0000000..d342396 --- /dev/null +++ b/src/Controller/ServerController.php @@ -0,0 +1,471 @@ +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.'); + } +} diff --git a/src/DataFixtures/AppFixtures.php b/src/DataFixtures/AppFixtures.php new file mode 100644 index 0000000..26f3bda --- /dev/null +++ b/src/DataFixtures/AppFixtures.php @@ -0,0 +1,109 @@ +setEmail('admin@servercontrol.local') + ->setPseudo('Admin') + ->setRoles(['ROLE_ADMIN']) + ->setPassword($this->passwordHasher->hashPassword($admin, 'Admin1234!')); + $manager->persist($admin); + + $operator = new User(); + $operator->setEmail('operator@servercontrol.local') + ->setPseudo('Operator') + ->setRoles(['ROLE_OPERATOR']) + ->setPassword($this->passwordHasher->hashPassword($operator, 'Operator1234!')); + $manager->persist($operator); + + $server1 = new Server(); + $server1->setName('Prod Web Server') + ->setIpAddress('192.168.1.10') + ->setSshPort(22) + ->setSshUser('deploy') + ->setSshPassword('secret123') + ->setDescription('Main production web server') + ->setLastStatus('unknown') + ->setOwner($admin); + $manager->persist($server1); + + $server2 = new Server(); + $server2->setName('Database Server') + ->setIpAddress('192.168.1.11') + ->setSshPort(2222) + ->setSshUser('ubuntu') + ->setSshPassword('secret456') + ->setDescription('PostgreSQL database server') + ->setLastStatus('unknown') + ->setOwner($admin); + $manager->persist($server2); + + foreach ([['nginx', 'Nginx Web Server'], ['php8.3-fpm', 'PHP-FPM']] as [$name, $display]) { + $service = new Service(); + $service->setName($name)->setDisplayName($display)->setServer($server1); + $manager->persist($service); + } + + $pgService = new Service(); + $pgService->setName('postgresql')->setDisplayName('PostgreSQL')->setServer($server2); + $manager->persist($pgService); + + foreach ([['start', 'success', 'Service started.'], ['stop', 'failure', 'Connection refused'], ['restart', 'success', 'Service restarted.']] as [$action, $status, $output]) { + $log = new ActionLog(); + $log->setActionType($action)->setStatus($status)->setOutput($output) + ->setServer($server1)->setUser($operator); + $manager->persist($log); + } + + $manager->flush(); + } +} diff --git a/src/Entity/.gitignore b/src/Entity/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/src/Entity/ActionLog.php b/src/Entity/ActionLog.php new file mode 100644 index 0000000..a75b50e --- /dev/null +++ b/src/Entity/ActionLog.php @@ -0,0 +1,131 @@ +executedAt = new \DateTimeImmutable(); + } + + /** + * @return int|null Identifiant du log + */ + public function getId(): ?int { return $this->id; } + + /** + * @brief Retourne le type d'action effectué + * @return string|null 'start', 'stop', 'restart', 'status' ou 'command' + */ + public function getActionType(): ?string { return $this->actionType; } + /** + * @param string $actionType Le type d'action + * @return static + */ + public function setActionType(string $actionType): static { $this->actionType = $actionType; return $this; } + + /** + * @brief Retourne le statut de l'action + * @return string|null 'success', 'failure' ou 'needs_sudo' + */ + public function getStatus(): ?string { return $this->status; } + /** @param string $status @return static */ + public function setStatus(string $status): static { $this->status = $status; return $this; } + + /** @return string|null La sortie de la commande SSH */ + public function getOutput(): ?string { return $this->output; } + /** @param string|null $output @return static */ + public function setOutput(?string $output): static { $this->output = $output; return $this; } + + /** + * @brief Retourne la date d'execution du log + * @return \DateTimeImmutable|null + */ + public function getExecutedAt(): ?\DateTimeImmutable { return $this->executedAt; } + + /** @return User|null L'utilisateur qui a effectué l'action */ + public function getUser(): ?User { return $this->user; } + /** @param User|null $user @return static */ + public function setUser(?User $user): static { $this->user = $user; return $this; } + + /** @return Server|null Le serveur cible de l'action */ + public function getServer(): ?Server { return $this->server; } + /** @param Server|null $server @return static */ + public function setServer(?Server $server): static { $this->server = $server; return $this; } +} diff --git a/src/Entity/Server.php b/src/Entity/Server.php new file mode 100644 index 0000000..9edd273 --- /dev/null +++ b/src/Entity/Server.php @@ -0,0 +1,230 @@ +createdAt = new \DateTimeImmutable(); + $this->services = new ArrayCollection(); + $this->actionLogs = new ArrayCollection(); + $this->serverAccesses = new ArrayCollection(); + } + + /** + * @brief Retourne l'identifiant du serveur + * @return int|null L'id en base de donnée, null si pas encore persisté + */ + public function getId(): ?int { return $this->id; } + + /** + * @brief Retourne le nom du serveur + * @return string|null Le nom du serveur + */ + public function getName(): ?string { return $this->name; } + + /** + * @brief Definit le nom du serveur + * @param string $name Nouveau nom a assigner + * @return static Pour le chaining de méthodes + */ + public function setName(string $name): static { $this->name = $name; return $this; } + + /** @return string|null Adresse IP du serveur */ + public function getIpAddress(): ?string { return $this->ipAddress; } + + /** + * @param string $ipAddress Adresse IP (IPv4 ou IPv6) + * @return static + */ + public function setIpAddress(string $ipAddress): static { $this->ipAddress = $ipAddress; return $this; } + + /** @return int Numero de port SSH (defaut 22) */ + public function getSshPort(): int { return $this->sshPort; } + + /** @param int $sshPort Port entre 1 et 65535 @return static */ + public function setSshPort(int $sshPort): static { $this->sshPort = $sshPort; return $this; } + + /** @return string|null Nom d'utilisateur pour la connexion SSH */ + public function getSshUser(): ?string { return $this->sshUser; } + /** @param string $sshUser @return static */ + public function setSshUser(string $sshUser): static { $this->sshUser = $sshUser; return $this; } + + /** @return string|null Mot de passe SSH (attention donnée sensible) */ + public function getSshPassword(): ?string { return $this->sshPassword; } + /** @param string $sshPassword @return static */ + public function setSshPassword(string $sshPassword): static { $this->sshPassword = $sshPassword; return $this; } + + /** @return string|null Description optionel du serveur */ + public function getDescription(): ?string { return $this->description; } + /** @param string|null $description Peut etre null @return static */ + public function setDescription(?string $description): static { $this->description = $description; return $this; } + + /** + * @brief Retourne le dernier statut connu du serveur + * @return string 'online', 'offline' ou 'unknown' + */ + public function getLastStatus(): string { return $this->lastStatus; } + + /** + * @brief Met a jour le statut du serveur apres une vérification SSH + * @param string $lastStatus Le nouveau statut a stocker + * @return static + */ + public function setLastStatus(string $lastStatus): static { $this->lastStatus = $lastStatus; return $this; } + + /** @return \DateTimeImmutable|null Date de creation de cet enregistrement */ + public function getCreatedAt(): ?\DateTimeImmutable { return $this->createdAt; } + + /** @return User|null Le proprietaire administrateur du serveur */ + public function getOwner(): ?User { return $this->owner; } + + /** + * @param User|null $owner L'utilisateur propriétaire, doit avoir ROLE_ADMIN + * @return static + */ + public function setOwner(?User $owner): static { $this->owner = $owner; return $this; } + + /** + * @brief Retourne la collection de services systemd du serveur + * @return Collection Collection d'objets Service + */ + public function getServices(): Collection { return $this->services; } + + /** + * @brief Retourne l'historique des actions sur ce serveur + * @return Collection Collection d'objets ActionLog + */ + public function getActionLogs(): Collection { return $this->actionLogs; } + + /** + * @brief Retourne les accés configurés pour les opérateurs + * @return Collection Collection d'objets ServerAccess + */ + public function getServerAccesses(): Collection { return $this->serverAccesses; } +} diff --git a/src/Entity/ServerAccess.php b/src/Entity/ServerAccess.php new file mode 100644 index 0000000..ffac7a5 --- /dev/null +++ b/src/Entity/ServerAccess.php @@ -0,0 +1,98 @@ +id; } + + /** + * @brief Retourne le serveur cible de cet accés + * @return Server|null + */ + public function getServer(): ?Server { return $this->server; } + /** @param Server $server @return static */ + public function setServer(Server $server): static { $this->server = $server; return $this; } + + /** + * @brief Retourne l'opérateur qui possede cet accés + * @return User|null + */ + public function getUser(): ?User { return $this->user; } + /** @param User $user @return static */ + public function setUser(User $user): static { $this->user = $user; return $this; } + + /** @return string|null Identifiant SSH de l'opérateur */ + public function getSshUser(): ?string { return $this->sshUser; } + /** @param string $sshUser @return static */ + public function setSshUser(string $sshUser): static { $this->sshUser = $sshUser; return $this; } + + /** @return string|null Mot de passe SSH de l'opérateur */ + public function getSshPassword(): ?string { return $this->sshPassword; } + /** @param string $sshPassword @return static */ + public function setSshPassword(string $sshPassword): static { $this->sshPassword = $sshPassword; return $this; } +} diff --git a/src/Entity/Service.php b/src/Entity/Service.php new file mode 100644 index 0000000..a0c3bfb --- /dev/null +++ b/src/Entity/Service.php @@ -0,0 +1,93 @@ +id; } + + /** + * @brief Retourne le nom systemd du service + * @return string|null Ex: 'nginx', 'php8.3-fpm' + */ + public function getName(): ?string { return $this->name; } + + /** + * @param string $name Nom systemd du service + * @return static + */ + public function setName(string $name): static { $this->name = $name; return $this; } + + /** @return string|null Nom d'affichage dans l'interface */ + public function getDisplayName(): ?string { return $this->displayName; } + /** @param string $displayName @return static */ + public function setDisplayName(string $displayName): static { $this->displayName = $displayName; return $this; } + + /** + * @brief Retourne le serveur associé a ce service + * @return Server|null + */ + public function getServer(): ?Server { return $this->server; } + /** @param Server|null $server @return static */ + public function setServer(?Server $server): static { $this->server = $server; return $this; } +} diff --git a/src/Entity/User.php b/src/Entity/User.php new file mode 100644 index 0000000..79f28ff --- /dev/null +++ b/src/Entity/User.php @@ -0,0 +1,226 @@ +createdAt = new \DateTimeImmutable(); + $this->servers = new ArrayCollection(); + $this->actionLogs = new ArrayCollection(); + $this->serverAccesses = new ArrayCollection(); + } + + /** + * @brief Retourne l'identifiant de l'utilisateur + * @return int|null L'id, null si pas encore persisté en base + */ + public function getId(): ?int { return $this->id; } + + /** @return string|null Email de l'utilisateur */ + public function getEmail(): ?string { return $this->email; } + /** @param string $email @return static */ + public function setEmail(string $email): static { $this->email = $email; return $this; } + + /** @return string|null Pseudo de l'utilisateur */ + public function getPseudo(): ?string { return $this->pseudo; } + /** @param string $pseudo @return static */ + public function setPseudo(string $pseudo): static { $this->pseudo = $pseudo; return $this; } + + /** + * @brief Retourne l'identifiant de connexion (email) pour Symfony Security + * @return string L'email comme identifiant unique + */ + public function getUserIdentifier(): string { return (string) $this->email; } + + /** + * @brief Retourne les roles de l'utilisateur + * + * ROLE_USER est toujours inclus automatiquement pour garantir + * que tout les utilisateurs aient au moin un role de base. + * + * @return array Tableau de roles uniques + */ + public function getRoles(): array + { + $roles = $this->roles; + $roles[] = 'ROLE_USER'; + return array_unique($roles); + } + + /** + * @param array $roles Les roles a assigner (ex: ['ROLE_OPERATOR']) + * @return static + */ + public function setRoles(array $roles): static { $this->roles = $roles; return $this; } + + /** @return string|null Mot de passe hashé */ + public function getPassword(): ?string { return $this->password; } + /** @param string $password Le hash du mot de passe @return static */ + public function setPassword(string $password): static { $this->password = $password; return $this; } + + /** + * @brief Verifie si le compte est actif + * @return bool true si actif, false si désactivé par un admin + */ + public function isActive(): bool { return $this->isActive; } + /** @param bool $isActive @return static */ + public function setIsActive(bool $isActive): static { $this->isActive = $isActive; return $this; } + + /** @return \DateTimeImmutable|null Date d'inscription */ + public function getCreatedAt(): ?\DateTimeImmutable { return $this->createdAt; } + + /** @return string|null Token de reinitialisation de mot de passe */ + public function getResetToken(): ?string { return $this->resetToken; } + /** @param string|null $resetToken null pour effacer le token @return static */ + public function setResetToken(?string $resetToken): static { $this->resetToken = $resetToken; return $this; } + + /** @return \DateTimeImmutable|null Date d'expiration du token */ + public function getResetTokenExpiresAt(): ?\DateTimeImmutable { return $this->resetTokenExpiresAt; } + + /** + * @param \DateTimeImmutable|null $resetTokenExpiresAt + * @return static + */ + public function setResetTokenExpiresAt(?\DateTimeImmutable $resetTokenExpiresAt): static + { + $this->resetTokenExpiresAt = $resetTokenExpiresAt; + return $this; + } + + /** + * @brief Efface les données sensibles de l'utilisateur apres authentification + * Requis par l'interface UserInterface, on laisse vide car on utilise pas plainPassword + */ + public function eraseCredentials(): void {} + + /** @return Collection Serveurs propriéte de cet utilisateur */ + public function getServers(): Collection { return $this->servers; } + /** @return Collection Logs des actions de cet utilisateur */ + public function getActionLogs(): Collection { return $this->actionLogs; } + /** @return Collection Accès aux serveurs assignés */ + public function getServerAccesses(): Collection { return $this->serverAccesses; } + + /** + * @brief Vérifie si l'utilisateur a le role admin + * @return bool true si ROLE_ADMIN est dans les roles + */ + public function isAdmin(): bool { return in_array('ROLE_ADMIN', $this->getRoles()); } + + /** + * @brief Vérifie si l'utilisateur est au moins opérateur + * @return bool true si ROLE_OPERATOR ou ROLE_ADMIN + */ + public function isOperator(): bool { return $this->isAdmin() || in_array('ROLE_OPERATOR', $this->getRoles()); } +} diff --git a/src/Form/AdminEmailType.php b/src/Form/AdminEmailType.php new file mode 100644 index 0000000..f4eb563 --- /dev/null +++ b/src/Form/AdminEmailType.php @@ -0,0 +1,50 @@ +add('email', EmailType::class, [ + 'label' => 'Adresse email', + 'attr' => ['placeholder' => 'user@example.com'], + ]); + } + + /** + * @brief Lie le formulaire a l'entité User pour que l'email soit pre-rempli + * @param OptionsResolver $resolver + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults(['data_class' => User::class]); + } +} diff --git a/src/Form/AdminPasswordChangeType.php b/src/Form/AdminPasswordChangeType.php new file mode 100644 index 0000000..3809eaf --- /dev/null +++ b/src/Form/AdminPasswordChangeType.php @@ -0,0 +1,64 @@ +add('newPassword', RepeatedType::class, [ + 'type' => PasswordType::class, + 'mapped' => false, + 'first_options' => ['label' => 'Nouveau mot de passe'], + 'second_options' => ['label' => 'Confirmer le mot de passe'], + 'invalid_message' => 'Les mots de passe ne correspondent pas.', + 'constraints' => [ + new NotBlank(message: 'Veuillez saisir un mot de passe'), + new Length( + min: 8, + minMessage: 'Le mot de passe doit faire au moin {{ limit }} caractères' + ), + ], + ]); + } + + /** + * @brief Pas de data_class, le mot de passe est traité manuellement dans le controleur + * @param OptionsResolver $resolver + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([]); + } +} diff --git a/src/Form/ChangePasswordType.php b/src/Form/ChangePasswordType.php new file mode 100644 index 0000000..790dc24 --- /dev/null +++ b/src/Form/ChangePasswordType.php @@ -0,0 +1,81 @@ +add('currentPassword', PasswordType::class, [ + 'label' => 'Mot de passe actuel', + 'mapped' => false, + 'attr' => ['autocomplete' => 'current-password'], + 'constraints' => [ + new NotBlank(message: 'Veuillez saisir votre mot de passe actuel'), + ], + ]) + ->add('newPassword', RepeatedType::class, [ + 'type' => PasswordType::class, + 'mapped' => false, + 'first_options' => [ + 'label' => 'Nouveau mot de passe', + 'attr' => ['autocomplete' => 'new-password'], + ], + 'second_options' => [ + 'label' => 'Confirmer le nouveau mot de passe', + 'attr' => ['autocomplete' => 'new-password'], + ], + 'invalid_message' => 'Les mots de passe ne correspondent pas.', + 'constraints' => [ + new NotBlank(message: 'Veuillez saisir un nouveau mot de passe'), + new Length( + min: 8, + minMessage: 'Le mot de passe doit faire au moin {{ limit }} caractères' + ), + ], + ]); + } + + /** + * @brief Pas de data_class car tout les champs sont unmapped + * @param OptionsResolver $resolver + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([]); + } +} diff --git a/src/Form/ForgotPasswordType.php b/src/Form/ForgotPasswordType.php new file mode 100644 index 0000000..f12cbc2 --- /dev/null +++ b/src/Form/ForgotPasswordType.php @@ -0,0 +1,54 @@ +add('email', EmailType::class, [ + 'label' => 'Votre adresse email', + 'attr' => ['placeholder' => 'exemple@email.com'], + 'constraints' => [ + new NotBlank(message: 'Please enter your email'), + new Email(message: 'Please enter a valid email'), + ], + ]); + } + + /** + * @brief Pas de data_class car on verifie l'email manuellement dans le controleur + * @param OptionsResolver $resolver + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([]); + } +} diff --git a/src/Form/RegistrationFormType.php b/src/Form/RegistrationFormType.php new file mode 100644 index 0000000..83ed676 --- /dev/null +++ b/src/Form/RegistrationFormType.php @@ -0,0 +1,75 @@ + false car il n'est pas + * directement dans l'entité User. Le controleur recupere sa valeur + * manuellement et hash le mot de passe avant de le sauvegarder. + */ +class RegistrationFormType extends AbstractType +{ + /** + * @brief Construit le formulaire avec email, pseudo et mot de passe (avec confirmation) + * + * @param FormBuilderInterface $builder + * @param array $options + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('email', EmailType::class, ['label' => 'Email']) + ->add('pseudo', TextType::class, [ + 'label' => 'Pseudo', + 'constraints' => [ + new NotBlank(message: 'Please enter a pseudo'), + new Length(min: 2, max: 50), + ], + ]) + ->add('plainPassword', RepeatedType::class, [ + 'type' => PasswordType::class, + 'mapped' => false, + 'first_options' => ['label' => 'Password'], + 'second_options' => ['label' => 'Confirm Password'], + 'constraints' => [ + new NotBlank(message: 'Please enter a password'), + new Length(min: 8, minMessage: 'Password must be at least {{ limit }} characters'), + ], + ]); + } + + /** + * @brief Lie le formulaire a l'entité User + * @param OptionsResolver $resolver + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults(['data_class' => User::class]); + } +} diff --git a/src/Form/ResetPasswordType.php b/src/Form/ResetPasswordType.php new file mode 100644 index 0000000..dc0a8bf --- /dev/null +++ b/src/Form/ResetPasswordType.php @@ -0,0 +1,64 @@ +add('plainPassword', RepeatedType::class, [ + 'type' => PasswordType::class, + 'mapped' => false, + 'first_options' => ['label' => 'New Password'], + 'second_options' => ['label' => 'Confirm Password'], + 'invalid_message' => 'The passwords do not match.', + 'constraints' => [ + new NotBlank(message: 'Please enter a password'), + new Length( + min: 8, + minMessage: 'Your password must be at least {{ limit }} characters' + ), + ], + ]); + } + + /** + * @brief Pas de data_class, le mot de passe est traité manuellement + * @param OptionsResolver $resolver + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([]); + } +} diff --git a/src/Form/ServerType.php b/src/Form/ServerType.php new file mode 100644 index 0000000..d428823 --- /dev/null +++ b/src/Form/ServerType.php @@ -0,0 +1,61 @@ +add('name', TextType::class, ['label' => 'Server Name']) + ->add('ipAddress', TextType::class, ['label' => 'IP Address']) + ->add('sshPort', IntegerType::class, ['label' => 'SSH Port', 'data' => 22]) + ->add('sshUser', TextType::class, ['label' => 'SSH User']) + ->add('sshPassword', PasswordType::class, [ + 'label' => 'SSH Password', + 'always_empty' => false, + 'required' => false, + ]) + ->add('description', TextareaType::class, ['label' => 'Description', 'required' => false]); + } + + /** + * @brief Configure les options du formulaire, lie l'entité Server + * @param OptionsResolver $resolver + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults(['data_class' => Server::class]); + } +} diff --git a/src/Form/UserType.php b/src/Form/UserType.php new file mode 100644 index 0000000..7228598 --- /dev/null +++ b/src/Form/UserType.php @@ -0,0 +1,63 @@ +add('roles', ChoiceType::class, [ + 'choices' => [ + 'User' => 'ROLE_USER', + 'Operator' => 'ROLE_OPERATOR', + 'Admin' => 'ROLE_ADMIN', + ], + 'multiple' => true, + 'expanded' => true, + 'label' => 'Roles', + ]) + ->add('isActive', CheckboxType::class, ['label' => 'Active', 'required' => false]); + } + + /** + * @brief Lie le formulaire a l'entité User + * @param OptionsResolver $resolver + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults(['data_class' => User::class]); + } +} diff --git a/src/Kernel.php b/src/Kernel.php new file mode 100644 index 0000000..f7e87de --- /dev/null +++ b/src/Kernel.php @@ -0,0 +1,26 @@ + + */ +class ActionLogRepository extends ServiceEntityRepository +{ + /** + * @brief Constructeur standard du repository Doctrine + * @param ManagerRegistry $registry + */ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ActionLog::class); + } + + /** + * @brief Construit une requete filtrée pour l'historique des actions + * + * Tout les filtres sont optionels, si aucun filtre n'est fourni + * on retourne tout les logs trié par date decroissante. + * Les jointures sur user et server sont faites en LEFT JOIN pour + * inclure les logs sans utilisateur (actions systeme). + * + * @param int|null $serverId Filtrer par serveur + * @param int|null $userId Filtrer par utilisateur + * @param string|null $dateFrom Date de debut (format Y-m-d) + * @param string|null $dateTo Date de fin (format Y-m-d), inclus jusqu'a 23:59:59 + * @return QueryBuilder Le query builder pour la pagination + */ + public function findByFilters(?int $serverId, ?int $userId, ?string $dateFrom, ?string $dateTo): QueryBuilder + { + $qb = $this->createQueryBuilder('a') + ->leftJoin('a.user', 'u') + ->leftJoin('a.server', 's') + ->addSelect('u', 's') + ->orderBy('a.executedAt', 'DESC'); + + if ($serverId) { + $qb->andWhere('s.id = :serverId')->setParameter('serverId', $serverId); + } + if ($userId) { + $qb->andWhere('u.id = :userId')->setParameter('userId', $userId); + } + if ($dateFrom) { + $qb->andWhere('a.executedAt >= :dateFrom') + ->setParameter('dateFrom', new \DateTimeImmutable($dateFrom)); + } + if ($dateTo) { + $qb->andWhere('a.executedAt <= :dateTo') + ->setParameter('dateTo', new \DateTimeImmutable($dateTo . ' 23:59:59')); + } + + return $qb; + } +} diff --git a/src/Repository/ServerAccessRepository.php b/src/Repository/ServerAccessRepository.php new file mode 100644 index 0000000..ed4659c --- /dev/null +++ b/src/Repository/ServerAccessRepository.php @@ -0,0 +1,28 @@ + ..., 'user' => ...]) + * pour verifier si un acces existe deja avant d'en creer un nouveau. + * + * @extends ServiceEntityRepository + */ +class ServerAccessRepository extends ServiceEntityRepository +{ + /** + * @brief Constructeur standard du repository Doctrine + * @param ManagerRegistry $registry + */ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ServerAccess::class); + } +} diff --git a/src/Repository/ServerRepository.php b/src/Repository/ServerRepository.php new file mode 100644 index 0000000..da29038 --- /dev/null +++ b/src/Repository/ServerRepository.php @@ -0,0 +1,73 @@ + + */ +class ServerRepository extends ServiceEntityRepository +{ + /** + * @brief Constructeur standard du repository Doctrine + * @param ManagerRegistry $registry + */ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Server::class); + } + + /** + * @brief Construit une requete filtrée pour la liste des serveurs + * + * Filtre par nom/IP (recherche partielle), statut et utilisateur courant. + * Pour les opérateurs (non admin), filtre avec un JOIN sur ServerAccess + * pour n'afficher que les serveurs auxquels ils ont acces. + * + * @param string|null $search Terme de recherche sur le nom ou l'IP + * @param string|null $status Filtre sur le statut ('online', 'offline', 'unknown') + * @param User|null $currentUser L'utilisateur courant pour filtrer selon son role + * @return QueryBuilder Le query builder pret a etre paginé + */ + public function findByFilters(?string $search, ?string $status, ?User $currentUser = null): QueryBuilder + { + $qb = $this->createQueryBuilder('s') + ->leftJoin('s.owner', 'o') + ->addSelect('o') + ->orderBy('s.createdAt', 'DESC'); + + if ($currentUser !== null && !$currentUser->isAdmin()) { + $qb->innerJoin('s.serverAccesses', 'sa') + ->andWhere('sa.user = :currentUser') + ->setParameter('currentUser', $currentUser); + } + + if ($search) { + $qb->andWhere('s.name LIKE :search OR s.ipAddress LIKE :search') + ->setParameter('search', '%' . $search . '%'); + } + + if ($status && in_array($status, ['online', 'offline', 'unknown'])) { + $qb->andWhere('s.lastStatus = :status') + ->setParameter('status', $status); + } + + return $qb; + } +} diff --git a/src/Repository/ServiceRepository.php b/src/Repository/ServiceRepository.php new file mode 100644 index 0000000..610e0cb --- /dev/null +++ b/src/Repository/ServiceRepository.php @@ -0,0 +1,28 @@ + + */ +class ServiceRepository extends ServiceEntityRepository +{ + /** + * @brief Constructeur standard du repository Doctrine + * @param ManagerRegistry $registry + */ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Service::class); + } +} diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php new file mode 100644 index 0000000..a19bf6a --- /dev/null +++ b/src/Repository/UserRepository.php @@ -0,0 +1,100 @@ + + */ +class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface +{ + /** + * @brief Constructeur standard du repository Doctrine + * @param ManagerRegistry $registry + */ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, User::class); + } + + /** + * @brief Met a jour le hash du mot de passe d'un utilisateur + * + * Appelé automatiquement par Symfony si le hash du mot de passe + * doit etre mis a jour (ex: changement d'algorithme). Requis par + * l'interface PasswordUpgraderInterface. + * + * @param PasswordAuthenticatedUserInterface $user L'utilisateur dont on met a jour le mdp + * @param string $newHashedPassword Le nouveau hash a stocker + * @throws UnsupportedUserException Si l'utilisateur n'est pas une instance de User + */ + public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void + { + if (!$user instanceof User) { + throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', $user::class)); + } + $user->setPassword($newHashedPassword); + $this->getEntityManager()->persist($user); + $this->getEntityManager()->flush(); + } + + /** + * @brief Retourne tout les utilisateurs avec un compte actif + * + * @return array Tableau d'objets User trié par date d'inscription (plus recent en premier) + */ + public function findAllActiveUsers(): array + { + return $this->createQueryBuilder('u') + ->andWhere('u.isActive = true') + ->orderBy('u.createdAt', 'DESC') + ->getQuery() + ->getResult(); + } + + /** + * @brief Retourne les operateurs actifs en excluant ceux deja assignés a un serveur + * + * Utilisé dans la page de detail d'un serveur pour afficher la liste + * des operateurs qui peuvent encore etre assignés (ceux pas encore dans + * ServerAccess pour ce serveur). Les admins sont aussi exclus. + * + * @param array $excludeIds Tableau des ids d'utilisateurs a exclure + * @return array Tableau d'objets User (operateurs uniquement, pas admins) + */ + public function findOperatorsExcluding(array $excludeIds): array + { + $qb = $this->createQueryBuilder('u') + ->andWhere('u.isActive = true') + ->orderBy('u.pseudo', 'ASC'); + + if (!empty($excludeIds)) { + $qb->andWhere('u.id NOT IN (:ids)')->setParameter('ids', $excludeIds); + } + + return array_values(array_filter( + $qb->getQuery()->getResult(), + fn($u) => !$u->isAdmin() + )); + } +} diff --git a/src/Security/AccessDeniedHandler.php b/src/Security/AccessDeniedHandler.php new file mode 100644 index 0000000..8799fec --- /dev/null +++ b/src/Security/AccessDeniedHandler.php @@ -0,0 +1,66 @@ +security->getUser() && !$this->security->isGranted('ROLE_OPERATOR')) { + return new RedirectResponse($this->router->generate('app_pending')); + } + + return null; + } +} diff --git a/src/Security/UserChecker.php b/src/Security/UserChecker.php new file mode 100644 index 0000000..b70ac7a --- /dev/null +++ b/src/Security/UserChecker.php @@ -0,0 +1,58 @@ +isActive()) { + throw new CustomUserMessageAuthenticationException( + 'Your account has been disabled. Please contact an administrator.' + ); + } + } + + /** + * @brief Verifie le compte apres l'authentification (non utilisé ici) + * + * @param UserInterface $user + * @param TokenInterface|null $token + */ + public function checkPostAuth(UserInterface $user, ?TokenInterface $token = null): void {} +} diff --git a/src/Service/SshCommandService.php b/src/Service/SshCommandService.php new file mode 100644 index 0000000..23e56b9 --- /dev/null +++ b/src/Service/SshCommandService.php @@ -0,0 +1,333 @@ +setActionType($action); + $log->setServer($server); + $log->setUser($user); + + try { + $ssh = $this->connect($server, $user); + + if ($sudoPassword !== null) { + $cmd = sprintf('echo %s | sudo -S systemctl %s %s 2>&1', + escapeshellarg($sudoPassword), $action, escapeshellarg($serviceName)); + ['output' => $output, 'exit_code' => $exitCode] = $this->execPty($ssh, $cmd); + + if ($this->isWrongPassword($output)) { + $log->setStatus('needs_sudo'); + $log->setOutput(''); + } else { + $log->setStatus($exitCode === 0 ? 'success' : 'failure'); + $log->setOutput($output); + $server->setLastStatus('online'); + } + } else { + $cmd = sprintf('sudo systemctl %s %s 2>&1', $action, escapeshellarg($serviceName)); + $output = (string) $ssh->exec($cmd); + + if ($this->needsSudoPassword($output)) { + $log->setStatus('needs_sudo'); + $log->setOutput(''); + } else { + $exitCode = (int) ($ssh->getExitStatus() ?? 1); + $log->setStatus($exitCode === 0 ? 'success' : 'failure'); + $log->setOutput($output); + $server->setLastStatus('online'); + } + } + } catch (\Throwable $e) { + $log->setStatus('failure'); + $log->setOutput($e->getMessage()); + } + + $this->entityManager->persist($log); + $this->entityManager->flush(); + + return $log; + } + + /** + * @brief Execute une commande libre sur le serveur via SSH (terminal interactif) + * + * Utilisé par le terminal dans l'interface. Si la commande contient 'sudo' + * et que le mot de passe est fourni, on utilise execPty() pour avoir + * un vrai pseudo-terminal. Pas de log pour les commandes libres. + * + * @param Server $server Le serveur cible + * @param string $command La commande a executer + * @param string|null $sudoPassword Mot de passe sudo si la commande en a besoin + * @param User|null $currentUser L'utilisateur courant (pour choisir les bons credentials) + * @return array ['success' => bool, 'output' => string, 'needs_sudo' => bool, 'exit_code' => int] + */ + public function runCommand(Server $server, string $command, ?string $sudoPassword = null, ?User $currentUser = null): array + { + try { + $ssh = $this->connect($server, $currentUser); + $hasSudo = (bool) preg_match('/\bsudo\b/', $command); + + if ($sudoPassword !== null && $hasSudo) { + $inner = preg_replace('/^\s*sudo\s+/', '', $command); + $cmd = sprintf('echo %s | sudo -S %s 2>&1', escapeshellarg($sudoPassword), $inner); + ['output' => $output, 'exit_code' => $exitCode] = $this->execPty($ssh, $cmd); + + if ($this->isWrongPassword($output)) { + return ['success' => false, 'output' => '', 'needs_sudo' => true, 'exit_code' => 1]; + } + + return ['success' => $exitCode === 0, 'output' => $output, 'needs_sudo' => false, 'exit_code' => $exitCode]; + } + + $output = (string) $ssh->exec($command . ' 2>&1'); + $exitCode = (int) ($ssh->getExitStatus() ?? 0); + + if ($this->needsSudoPassword($output)) { + return ['success' => false, 'output' => '', 'needs_sudo' => true, 'exit_code' => 1]; + } + + return ['success' => $exitCode === 0, 'output' => $output, 'needs_sudo' => false, 'exit_code' => $exitCode]; + } catch (\Throwable $e) { + return ['success' => false, 'output' => 'Error: ' . $e->getMessage(), 'needs_sudo' => false, 'exit_code' => 1]; + } + } + + /** + * @brief Verifie si le serveur est accessible en tentant une connexion SSH rapide + * + * Utilise un timeout de 5 secondes pour pas bloquer l'interface trop longtemps. + * Retourne 'offline' si la connexion echoue ou si une exception est lancée. + * + * @param Server $server Le serveur a verifier + * @return string 'online' si SSH accessible, 'offline' sinon + */ + public function checkStatus(Server $server): string + { + try { + $ssh = new SSH2($server->getIpAddress(), $server->getSshPort(), 5); + if (!$ssh->login($server->getSshUser(), $server->getSshPassword())) { + return 'offline'; + } + return 'online'; + } catch (\Throwable) { + return 'offline'; + } + } + + /** + * @brief Etablit une connexion SSH sur le serveur avec les bons credentials + * + * Si l'utilisateur courant est un opérateur (pas admin), on utilise + * ses credentials SSH personnels depuis ServerAccess. Les admins + * utilisent les credentials principaux du serveur. + * + * @param Server $server Le serveur sur lequel se connecter + * @param User|null $currentUser L'utilisateur courant ou null pour les admins + * @return SSH2 L'objet de connexion SSH prêt a etre utilisé + * @throws \RuntimeException Si l'authentification SSH echoue + */ + private function connect(Server $server, ?User $currentUser = null): SSH2 + { + $sshUser = $server->getSshUser(); + $sshPassword = $server->getSshPassword(); + + if ($currentUser !== null && !$currentUser->isAdmin()) { + $access = $this->accessRepo->findOneBy(['server' => $server, 'user' => $currentUser]); + if ($access) { + $sshUser = $access->getSshUser(); + $sshPassword = $access->getSshPassword(); + } + } + + $ssh = new SSH2($server->getIpAddress(), $server->getSshPort(), 10); + if (!$ssh->login($sshUser, $sshPassword)) { + throw new \RuntimeException(sprintf('SSH authentication failed for user "%s".', $sshUser)); + } + + return $ssh; + } + + /** + * @brief Execute une commande via un pseudo-terminal (PTY) pour supporter sudo + * + * Necesaire pour les commandes sudo car sudo a besoin d'un vrai terminal + * pour afficher le prompt de mot de passe. On nettoie les codes d'echappement + * ANSI de la sortie avant de la retourner. + * + * @param SSH2 $ssh La connexion SSH active + * @param string $command La commande a executer avec le PTY + * @return array ['output' => string, 'exit_code' => int] + */ + private function execPty(SSH2 $ssh, string $command): array + { + $ssh->enablePTY(); + $ssh->exec($command); + $ssh->setTimeout(15); + $raw = (string) ($ssh->read() ?: ''); + + $output = preg_replace('/\x1b\[[0-9;]*[a-zA-Z]|\x1b[()][\w]|\r/', '', $raw) ?? $raw; + $output = preg_replace('/^\[sudo\] password for .*?:\s*$/m', '', $output); + $output = trim($output); + + $exitCode = (int) ($ssh->getExitStatus() ?? 0); + + return ['output' => $output, 'exit_code' => $exitCode]; + } + + /** + * @brief Verifie le statut de plusieurs services systemd en une seule commande + * + * Utilise 'systemctl is-active' avec plusieurs noms en meme temps pour + * limiter le nombre de connexions SSH. Retourne 'unknown' pour tout les + * services si la connexion SSH echoue. + * + * @param Server $server Le serveur cible + * @param array $serviceNames Tableau des noms de services a verifier + * @return array Tableau associatif ['service_name' => 'active'|'inactive'|'unknown'] + */ + public function checkServicesStatus(Server $server, array $serviceNames): array + { + if (empty($serviceNames)) return []; + try { + $ssh = $this->connect($server); + $escaped = implode(' ', array_map('escapeshellarg', $serviceNames)); + $output = (string) $ssh->exec("systemctl is-active $escaped 2>/dev/null"); + $lines = array_values(array_filter(explode("\n", trim($output)), fn($l) => $l !== '')); + $result = []; + foreach (array_values($serviceNames) as $i => $name) { + $result[$name] = ($lines[$i] ?? '') === 'active' ? 'active' : 'inactive'; + } + return $result; + } catch (\Throwable) { + return array_fill_keys($serviceNames, 'unknown'); + } + } + + /** + * @brief Scanne les services systemd actifs sur le serveur + * + * Utilisé automatiquement lors de l'ajout d'un serveur pour pre-remplir + * la liste des services a surveiller. Retourne un tableau vide si la + * connexion SSH echoue (le serveur est quand meme crée). + * + * @param Server $server Le serveur a scanner + * @return array Tableau des noms de services systemd actifs (sans '.service') + */ + public function scanServices(Server $server): array + { + try { + $ssh = $this->connect($server); + $output = (string) $ssh->exec( + 'systemctl list-units --type=service --state=active --no-pager --no-legend --plain 2>/dev/null' + ); + $services = []; + foreach (explode("\n", $output) as $line) { + $line = trim($line); + if (!$line) continue; + $parts = preg_split('/\s+/', $line, 2); + $name = $parts[0] ?? ''; + if (str_ends_with($name, '.service')) { + $serviceName = substr($name, 0, -8); + if ($serviceName !== '') { + $services[] = $serviceName; + } + } + } + return $services; + } catch (\Throwable) { + return []; + } + } + + /** + * @brief Detecte si la sortie SSH indique que sudo demande un mot de passe + * + * @param string $output La sortie brute de la commande SSH + * @return bool true si sudo a besoin d'un mot de passe + */ + private function needsSudoPassword(string $output): bool + { + return (bool) preg_match( + '/\[sudo\] password|sudo: a password is required|sudo: a terminal is required/i', + $output + ); + } + + /** + * @brief Detecte si le mot de passe sudo fourni est incorrect + * + * @param string $output La sortie brute de la commande PTY + * @return bool true si le mot de passe est mauvais + */ + private function isWrongPassword(string $output): bool + { + return (bool) preg_match( + '/sorry, try again|sudo: \d+ incorrect password attempt|authentication failure/i', + $output + ); + } +} diff --git a/tests/Unit/ServerTest.php b/tests/Unit/ServerTest.php new file mode 100644 index 0000000..e64d51f --- /dev/null +++ b/tests/Unit/ServerTest.php @@ -0,0 +1,55 @@ +assertEquals('unknown', $server->getLastStatus()); + } + + public function testServerDefaultPort(): void + { + $server = new Server(); + $this->assertEquals(22, $server->getSshPort()); + } + + public function testServerSettersAndGetters(): void + { + $server = new Server(); + $server->setName('Test Server'); + $server->setIpAddress('192.168.1.1'); + $server->setSshPort(2222); + $server->setSshUser('admin'); + $server->setSshPassword('pass123'); + $server->setLastStatus('online'); + + $this->assertEquals('Test Server', $server->getName()); + $this->assertEquals('192.168.1.1', $server->getIpAddress()); + $this->assertEquals(2222, $server->getSshPort()); + $this->assertEquals('admin', $server->getSshUser()); + $this->assertEquals('online', $server->getLastStatus()); + } + + public function testServerOwnerRelation(): void + { + $server = new Server(); + $user = new User(); + $user->setEmail('owner@test.com')->setPseudo('Owner'); + + $server->setOwner($user); + $this->assertSame($user, $server->getOwner()); + } + + public function testServerCreatedAtIsSetOnConstruct(): void + { + $server = new Server(); + $this->assertInstanceOf(\DateTimeImmutable::class, $server->getCreatedAt()); + } +} diff --git a/tests/Unit/UserTest.php b/tests/Unit/UserTest.php new file mode 100644 index 0000000..5678476 --- /dev/null +++ b/tests/Unit/UserTest.php @@ -0,0 +1,51 @@ +setRoles([]); + $this->assertContains('ROLE_USER', $user->getRoles()); + } + + public function testAdminDetection(): void + { + $user = new User(); + $user->setRoles(['ROLE_ADMIN']); + $this->assertTrue($user->isAdmin()); + $this->assertTrue($user->isOperator()); + } + + public function testOperatorDetection(): void + { + $user = new User(); + $user->setRoles(['ROLE_OPERATOR']); + $this->assertTrue($user->isOperator()); + $this->assertFalse($user->isAdmin()); + } + + public function testUserIsActiveByDefault(): void + { + $user = new User(); + $this->assertTrue($user->isActive()); + } + + public function testUserIdentifierIsEmail(): void + { + $user = new User(); + $user->setEmail('test@example.com'); + $this->assertEquals('test@example.com', $user->getUserIdentifier()); + } + + public function testCreatedAtSetOnConstruct(): void + { + $user = new User(); + $this->assertInstanceOf(\DateTimeImmutable::class, $user->getCreatedAt()); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..47a5855 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,13 @@ +bootEnv(dirname(__DIR__).'/.env'); +} + +if ($_SERVER['APP_DEBUG']) { + umask(0000); +}