ajoute le proxy console Go et le relais WebSocket vers nova-serialproxy
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
30e14b1c54
commit
8742bde807
30 changed files with 1103 additions and 6 deletions
44
portail/app/Http/Controllers/ConsoleController.php
Normal file
44
portail/app/Http/Controllers/ConsoleController.php
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\LabSession;
|
||||
use App\Models\Seance;
|
||||
use App\Services\OpenStack\JetonConsole;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
/**
|
||||
* Émet des jetons de console série à usage court (étape 9) : le navigateur
|
||||
* ne reçoit jamais l'adresse de nova-serialproxy, seulement un jeton Redis
|
||||
* valable 5 minutes. L'élève peut écrire dans sa propre VM ; le prof ne
|
||||
* peut que regarder celle d'un élève de sa classe.
|
||||
*/
|
||||
class ConsoleController extends Controller
|
||||
{
|
||||
public function __construct(private readonly JetonConsole $jetons) {}
|
||||
|
||||
public function pourEleve(LabSession $labSession): JsonResponse
|
||||
{
|
||||
$eleve = Auth::guard('eleve')->user();
|
||||
abort_if($labSession->eleve_id !== $eleve->id, 403);
|
||||
abort_if($labSession->etat !== LabSession::ADMIS, 403, 'En attente d\'admission.');
|
||||
abort_if($labSession->stack_id === null, 404, 'Aucune VM associée à cette session.');
|
||||
|
||||
return response()->json([
|
||||
'jeton' => $this->jetons->pourEcriture($labSession->stack_id),
|
||||
]);
|
||||
}
|
||||
|
||||
public function pourProf(Seance $seance, LabSession $labSession): JsonResponse
|
||||
{
|
||||
abort_if($seance->classe->user_id !== Auth::guard('web')->id(), 403);
|
||||
abort_if($labSession->seance_id !== $seance->id, 404);
|
||||
abort_if($labSession->etat !== LabSession::ADMIS, 403, 'Élève pas encore admis.');
|
||||
abort_if($labSession->stack_id === null, 404, 'Aucune VM associée à cette session.');
|
||||
|
||||
return response()->json([
|
||||
'jeton' => $this->jetons->pourLecture($labSession->stack_id),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -38,6 +38,17 @@ class SeanceController extends Controller
|
|||
]);
|
||||
}
|
||||
|
||||
public function consoleProf(Seance $seance, LabSession $labSession): View
|
||||
{
|
||||
abort_if($seance->classe->user_id !== Auth::guard('web')->id(), 403);
|
||||
abort_if($labSession->seance_id !== $seance->id, 404);
|
||||
|
||||
return view('seances.console-prof', [
|
||||
'seance' => $seance->load('lab'),
|
||||
'labSession' => $labSession->load('eleve'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function admettre(Seance $seance, LabSession $labSession): RedirectResponse
|
||||
{
|
||||
abort_if($seance->classe->user_id !== Auth::guard('web')->id(), 403);
|
||||
|
|
|
|||
47
portail/app/Services/OpenStack/JetonConsole.php
Normal file
47
portail/app/Services/OpenStack/JetonConsole.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\OpenStack;
|
||||
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Range l'adresse de la console série dans Redis sous un jeton aléatoire,
|
||||
* pour 5 minutes (étape 9). Le navigateur ne reçoit que ce jeton, jamais
|
||||
* l'URL ni le jeton Nova sous-jacent. Format lu par proxy-console/main.go :
|
||||
* ne pas changer les noms de champs sans mettre à jour les deux côtés.
|
||||
*/
|
||||
class JetonConsole
|
||||
{
|
||||
private const DUREE_MINUTES = 5;
|
||||
|
||||
public function __construct(private readonly NovaClient $nova) {}
|
||||
|
||||
public function pourEcriture(string $instanceId): string
|
||||
{
|
||||
return $this->creer($instanceId, 'ecriture');
|
||||
}
|
||||
|
||||
public function pourLecture(string $instanceId): string
|
||||
{
|
||||
return $this->creer($instanceId, 'lecture');
|
||||
}
|
||||
|
||||
private function creer(string $instanceId, string $role): string
|
||||
{
|
||||
$jeton = Str::random(40);
|
||||
$url = $this->nova->consoleSerielle($instanceId);
|
||||
|
||||
Redis::setex(
|
||||
"console:{$jeton}",
|
||||
self::DUREE_MINUTES * 60,
|
||||
json_encode([
|
||||
'url' => $url,
|
||||
'instance_id' => $instanceId,
|
||||
'role' => $role,
|
||||
]),
|
||||
);
|
||||
|
||||
return $jeton;
|
||||
}
|
||||
}
|
||||
59
portail/app/Services/OpenStack/NovaClient.php
Normal file
59
portail/app/Services/OpenStack/NovaClient.php
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\OpenStack;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Juste ce qu'il faut pour demander à Nova l'adresse de la console série
|
||||
* d'une VM (étape 9). La liaison Laravel ↔ OpenStack complète (Heat, labs)
|
||||
* est l'étape 10 : on ne construit pas de client générique ici.
|
||||
*/
|
||||
class NovaClient
|
||||
{
|
||||
public function consoleSerielle(string $instanceId): string
|
||||
{
|
||||
$reponse = Http::withHeaders([
|
||||
'X-Auth-Token' => $this->jetonKeystone(),
|
||||
'OpenStack-API-Version' => 'compute 2.99',
|
||||
])
|
||||
->post(config('openstack.nova_url')."/servers/{$instanceId}/remote-consoles", [
|
||||
'remote_console' => ['protocol' => 'serial', 'type' => 'serial'],
|
||||
]);
|
||||
|
||||
if ($reponse->failed()) {
|
||||
throw new RuntimeException("Nova a refusé la demande de console série pour {$instanceId} : ".$reponse->body());
|
||||
}
|
||||
|
||||
return $reponse->json('remote_console.url');
|
||||
}
|
||||
|
||||
/**
|
||||
* Jeton Keystone mis en cache (un jeton v3 dure 1h côté Keystone, on le
|
||||
* garde un peu moins longtemps par prudence).
|
||||
*/
|
||||
private function jetonKeystone(): string
|
||||
{
|
||||
return Cache::remember('openstack.jeton_keystone', now()->addMinutes(50), function () {
|
||||
$reponse = Http::post(config('openstack.auth_url').'/v3/auth/tokens', [
|
||||
'auth' => [
|
||||
'identity' => [
|
||||
'methods' => ['application_credential'],
|
||||
'application_credential' => [
|
||||
'id' => config('openstack.application_credential_id'),
|
||||
'secret' => config('openstack.application_credential_secret'),
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
if ($reponse->failed()) {
|
||||
throw new RuntimeException('Authentification OpenStack refusée : '.$reponse->body());
|
||||
}
|
||||
|
||||
return $reponse->header('X-Subject-Token');
|
||||
});
|
||||
}
|
||||
}
|
||||
13
portail/config/openstack.php
Normal file
13
portail/config/openstack.php
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
// Utilisé par App\Services\OpenStack\NovaClient pour demander à Nova
|
||||
// l'adresse de la console série d'une VM (étape 9). Les identifiants
|
||||
// sont l'application credential « portail-syllabus » créée à l'étape 6,
|
||||
// lus depuis /etc/kolla/clouds-paillasse.yaml et injectés une seule fois
|
||||
// dans .env par le rôle Ansible `portail` — jamais dans Git.
|
||||
'auth_url' => env('OS_AUTH_URL', 'http://10.10.0.1:5000'),
|
||||
'application_credential_id' => env('OS_APPLICATION_CREDENTIAL_ID'),
|
||||
'application_credential_secret' => env('OS_APPLICATION_CREDENTIAL_SECRET'),
|
||||
'nova_url' => env('OS_NOVA_URL', 'http://10.10.0.1:8774/v2.1'),
|
||||
];
|
||||
60
portail/public/js/console-reelle.js
Normal file
60
portail/public/js/console-reelle.js
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
// Vraie console série (étape 9) : se branche sur le proxy WebSocket Go, qui
|
||||
// relaie nova-serialproxy. Contrairement à paillasse-terminal.js (shell
|
||||
// simulé pour la vitrine), ceci parle à une VM réelle.
|
||||
(function () {
|
||||
var THEME = {
|
||||
background: '#13233A', foreground: '#E8EDF5', cursor: '#FFD23F', cursorAccent: '#13233A',
|
||||
selectionBackground: '#2B4A73', black: '#0D192B', red: '#FF7A66', green: '#7CF2C0', yellow: '#FFD23F',
|
||||
blue: '#8DB4FF', magenta: '#FF6FB5', cyan: '#7CE3F2', white: '#E8EDF5',
|
||||
brightBlack: '#6B7F99', brightRed: '#FF9A8A', brightGreen: '#7CF2C0', brightYellow: '#FFD23F',
|
||||
brightBlue: '#A9C6FF', brightMagenta: '#FF8FC6', brightCyan: '#9CEBF7', brightWhite: '#FFFFFF'
|
||||
};
|
||||
|
||||
function mount(el, o) {
|
||||
o = o || {};
|
||||
var lectureSeule = !!o.lectureSeule;
|
||||
|
||||
var term = new window.Terminal({
|
||||
theme: THEME, fontFamily: '"IBM Plex Mono", ui-monospace, monospace', fontSize: o.fontSize || 14,
|
||||
lineHeight: 1.25, cursorBlink: !lectureSeule, cursorStyle: 'block', scrollback: 2000,
|
||||
disableStdin: lectureSeule
|
||||
});
|
||||
var fit = window.FitAddon ? new window.FitAddon.FitAddon() : null;
|
||||
if (fit) term.loadAddon(fit);
|
||||
term.open(el);
|
||||
var fitNow = function () { try { fit && fit.fit(); } catch (e) {} };
|
||||
var raf, doFit = function () { cancelAnimationFrame(raf); raf = requestAnimationFrame(fitNow); };
|
||||
fitNow(); window.addEventListener('resize', doFit);
|
||||
if (window.ResizeObserver) new ResizeObserver(doFit).observe(el);
|
||||
|
||||
term.write('\x1b[2mConnexion à la console…\x1b[0m\r\n');
|
||||
|
||||
var protocole = location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
var ws = new WebSocket(protocole + location.host + '/console/' + o.jeton);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onopen = function () { term.write('\x1b[2mConnecté.\x1b[0m\r\n'); };
|
||||
ws.onmessage = function (evenement) {
|
||||
if (evenement.data instanceof ArrayBuffer) {
|
||||
term.write(new Uint8Array(evenement.data));
|
||||
} else {
|
||||
term.write(evenement.data);
|
||||
}
|
||||
};
|
||||
ws.onclose = function () { term.write('\r\n\x1b[2mConnexion fermée.\x1b[0m\r\n'); };
|
||||
ws.onerror = function () { term.write('\r\n\x1b[31mErreur de connexion à la console.\x1b[0m\r\n'); };
|
||||
|
||||
if (!lectureSeule) {
|
||||
term.onData(function (donnees) {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(donnees);
|
||||
});
|
||||
}
|
||||
|
||||
return { term: term, ws: ws };
|
||||
}
|
||||
|
||||
window.SyllabusConsoleReelle = { mount: function (el, o) {
|
||||
var go = function () { mount(el, o); };
|
||||
(document.fonts && document.fonts.ready ? document.fonts.ready : Promise.resolve()).then(go);
|
||||
} };
|
||||
})();
|
||||
|
|
@ -6,14 +6,33 @@
|
|||
@if ($labSession->etat === \App\Models\LabSession::EN_ATTENTE)
|
||||
<meta http-equiv="refresh" content="5">
|
||||
@endif
|
||||
<div class="mx-auto max-w-2xl px-5 py-20 text-center lg:px-10">
|
||||
<div class="mx-auto max-w-4xl px-5 py-14 lg:px-10">
|
||||
<p class="font-mono text-sm font-semibold">{{ $seance->lab->titre }}</p>
|
||||
@if ($labSession->etat === \App\Models\LabSession::EN_ATTENTE)
|
||||
<h1 class="mt-2 text-4xl font-extrabold tracking-tight">En attente que le prof t'admette…</h1>
|
||||
<p class="mt-4 font-semibold">Cette page se rafraîchit toute seule.</p>
|
||||
<div class="py-20 text-center">
|
||||
<h1 class="text-4xl font-extrabold tracking-tight">En attente que le prof t'admette…</h1>
|
||||
<p class="mt-4 font-semibold">Cette page se rafraîchit toute seule.</p>
|
||||
</div>
|
||||
@elseif ($labSession->stack_id)
|
||||
<h1 class="mt-2 text-3xl font-extrabold tracking-tight">Ta console</h1>
|
||||
<div id="terminal" class="mt-4 h-[480px] overflow-hidden rounded-2xl border border-[#e3e5e8]" style="background:#13233A"></div>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
||||
<script src="{{ asset('js/console-reelle.js') }}"></script>
|
||||
<script>
|
||||
fetch('{{ route('console.eleve', $labSession) }}', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]')?.content || '{{ csrf_token() }}' },
|
||||
}).then(function (r) { return r.json(); }).then(function (d) {
|
||||
window.SyllabusConsoleReelle.mount(document.getElementById('terminal'), { jeton: d.jeton, lectureSeule: false });
|
||||
});
|
||||
</script>
|
||||
@else
|
||||
<h1 class="mt-2 text-4xl font-extrabold tracking-tight">Tu es admis·e !</h1>
|
||||
<p class="mt-4 font-semibold">Le lancement de ta VM arrive dans une prochaine étape du déploiement.</p>
|
||||
<div class="py-20 text-center">
|
||||
<h1 class="text-4xl font-extrabold tracking-tight">Tu es admis·e !</h1>
|
||||
<p class="mt-4 font-semibold">Le lancement de ta VM arrive dans une prochaine étape du déploiement.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
|
|
|
|||
|
|
@ -22,7 +22,12 @@
|
|||
<button type="submit" class="inline-flex h-10 items-center rounded-full bg-[#0b57d0] px-4 font-bold text-white">Admettre</button>
|
||||
</form>
|
||||
@else
|
||||
<span class="rounded-lg border border-[#dadce0] bg-[#e6f4ea] px-3 py-1 font-mono text-sm font-bold">Admis</span>
|
||||
<span class="flex items-center gap-3">
|
||||
<span class="rounded-lg border border-[#dadce0] bg-[#e6f4ea] px-3 py-1 font-mono text-sm font-bold">Admis</span>
|
||||
@if ($labSession->stack_id)
|
||||
<a href="{{ route('seances.console.prof.page', [$seance, $labSession]) }}" class="font-bold underline underline-offset-4">Voir sa console</a>
|
||||
@endif
|
||||
</span>
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
|
|
|
|||
23
portail/resources/views/seances/console-prof.blade.php
Normal file
23
portail/resources/views/seances/console-prof.blade.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
@extends('layouts.vitrine')
|
||||
|
||||
@section('titre', 'Console de ' . $labSession->eleve->nomAffiche())
|
||||
|
||||
@section('contenu')
|
||||
<div class="mx-auto max-w-4xl px-5 py-14 lg:px-10">
|
||||
<p class="font-mono text-sm font-semibold">{{ $seance->lab->titre }} · lecture seule</p>
|
||||
<h1 class="mt-1 text-3xl font-extrabold tracking-tight">Console de {{ $labSession->eleve->nomAffiche() }}</h1>
|
||||
<div id="terminal" class="mt-4 h-[480px] overflow-hidden rounded-2xl border border-[#e3e5e8]" style="background:#13233A"></div>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
||||
<script src="{{ asset('js/console-reelle.js') }}"></script>
|
||||
<script>
|
||||
fetch('{{ route('console.prof', [$seance, $labSession]) }}', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
|
||||
}).then(function (r) { return r.json(); }).then(function (d) {
|
||||
window.SyllabusConsoleReelle.mount(document.getElementById('terminal'), { jeton: d.jeton, lectureSeule: true });
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
@endsection
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
use App\Http\Controllers\Auth\ConnexionController;
|
||||
use App\Http\Controllers\ClasseController;
|
||||
use App\Http\Controllers\ConsoleController;
|
||||
use App\Http\Controllers\EleveTableauDeBordController;
|
||||
use App\Http\Controllers\InscriptionController;
|
||||
use App\Http\Controllers\SeanceController;
|
||||
|
|
@ -67,6 +68,7 @@ Route::middleware(['auth:eleve', 'session.unique:eleve'])->group(function () {
|
|||
Route::get('/eleve', [EleveTableauDeBordController::class, 'show'])->name('eleve');
|
||||
Route::post('/seances/{seance}/rejoindre', [SeanceController::class, 'rejoindre'])->name('seances.rejoindre');
|
||||
Route::get('/seances/{seance}/attente-eleve', [SeanceController::class, 'attenteEleve'])->name('seances.eleve.attente');
|
||||
Route::post('/lab-sessions/{labSession}/console', [ConsoleController::class, 'pourEleve'])->name('console.eleve');
|
||||
});
|
||||
|
||||
// --- Espace prof (protégé, session unique) ---
|
||||
|
|
@ -79,4 +81,6 @@ Route::middleware(['auth:web', 'session.unique:web'])->group(function () {
|
|||
Route::post('/mes-classes/{classe}/seances', [SeanceController::class, 'store'])->name('mes-classes.seances.store');
|
||||
Route::get('/seances/{seance}/attente', [SeanceController::class, 'attente'])->name('seances.attente');
|
||||
Route::post('/seances/{seance}/admettre/{labSession}', [SeanceController::class, 'admettre'])->name('seances.admettre');
|
||||
Route::get('/seances/{seance}/labsessions/{labSession}/console', [SeanceController::class, 'consoleProf'])->name('seances.console.prof.page');
|
||||
Route::post('/seances/{seance}/labsessions/{labSession}/console', [ConsoleController::class, 'pourProf'])->name('console.prof');
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue