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:
Adam Belkacemi 2026-10-08 09:35:16 +02:00
parent 30e14b1c54
commit 8742bde807
30 changed files with 1103 additions and 6 deletions

11
ansible/proxy-console.yml Normal file
View file

@ -0,0 +1,11 @@
---
# Étape 9 du déploiement Syllabus : proxy console Go et xterm.js.
# Rejoue aussi `portail` : le vhost Nginx (location /console/) et les
# variables OpenStack de .env vivent dans ce rôle.
- name: Proxy console et portail
hosts: syllabus
become: true
gather_facts: true
roles:
- portail
- proxy-console

View file

@ -81,6 +81,36 @@
portail_app_url: "http://{{ ansible_host }}"
when: not portail_env_stat.stat.exists
# Variables ajoutées après le premier déploiement (étape 9) : mises à jour
# ligne par ligne, pour ne jamais retoucher le reste d'un .env déjà en place
# (APP_KEY en particulier, qui ne doit jamais changer sans le vouloir).
- name: lire l'application credential OpenStack du portail (étape 6)
ansible.builtin.slurp:
src: /etc/kolla/clouds-paillasse.yaml
register: portail_clouds_brut
- name: extraire l'id et le secret de l'application credential
ansible.builtin.set_fact:
portail_os_application_credential_id: "{{ portail_clouds_contenu | regex_search('application_credential_id:\\s*\"?([^\"\\n]+)', '\\1') | first }}"
portail_os_application_credential_secret: "{{ portail_clouds_contenu | regex_search('application_credential_secret:\\s*\"?([^\"\\n]+)', '\\1') | first }}"
vars:
portail_clouds_contenu: "{{ portail_clouds_brut.content | b64decode }}"
no_log: true
- name: ajouter/mettre à jour les variables OpenStack dans .env
ansible.builtin.lineinfile:
path: "{{ portail_dossier_app }}/.env"
regexp: "^{{ item.cle }}="
line: "{{ item.cle }}={{ item.valeur }}"
loop:
- { cle: "OS_AUTH_URL", valeur: "http://10.10.0.1:5000" }
- { cle: "OS_NOVA_URL", valeur: "http://10.10.0.1:8774/v2.1" }
- { cle: "OS_APPLICATION_CREDENTIAL_ID", valeur: "{{ portail_os_application_credential_id }}" }
- { cle: "OS_APPLICATION_CREDENTIAL_SECRET", valeur: "{{ portail_os_application_credential_secret }}" }
- { cle: "REDIS_PREFIX", valeur: "" }
no_log: true
register: portail_env_mis_a_jour
- name: installer les dépendances JS (npm)
ansible.builtin.command:
cmd: npm ci

View file

@ -12,6 +12,17 @@ server {
try_files $uri $uri/ /index.php?$query_string;
}
# Relaie la console série (étape 9) en WebSocket vers le proxy Go. Le
# jeton après /console/ est à usage court (5 min), jamais l'URL Nova.
location /console/ {
proxy_pass http://127.0.0.1:8081/console/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 1h;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php/php{{ portail_php_version }}-fpm.sock;
fastcgi_index index.php;

View file

@ -0,0 +1,6 @@
---
proxy_console_go_version: "1.27.1"
proxy_console_go_sha256: "63d339f0da5ab53635a56f2490a7984dfe12dfcff22ad749f63edaf590168445"
proxy_console_dossier: /opt/proxy-console
proxy_console_ecoute: "127.0.0.1:8081"
proxy_console_redis: "127.0.0.1:6380"

View file

@ -0,0 +1,10 @@
---
- name: recharger systemd
ansible.builtin.systemd:
daemon_reload: true
- name: redémarrer proxy-console
ansible.builtin.systemd:
name: proxy-console
state: restarted
enabled: true

View file

@ -0,0 +1,32 @@
---
- name: télécharger Go {{ proxy_console_go_version }} (archive officielle, somme vérifiée)
ansible.builtin.get_url:
url: "https://go.dev/dl/go{{ proxy_console_go_version }}.linux-amd64.tar.gz"
dest: "/tmp/go{{ proxy_console_go_version }}.linux-amd64.tar.gz"
checksum: "sha256:{{ proxy_console_go_sha256 }}"
mode: "0644"
- name: vérifier si Go est déjà installé dans cette version
ansible.builtin.command: /usr/local/go/bin/go version
register: proxy_console_go_deja_installe
failed_when: false
changed_when: false
- name: supprimer une éventuelle ancienne version de Go
ansible.builtin.file:
path: /usr/local/go
state: absent
when: proxy_console_go_version not in (proxy_console_go_deja_installe.stdout | default(''))
- name: extraire Go dans /usr/local
ansible.builtin.unarchive:
src: "/tmp/go{{ proxy_console_go_version }}.linux-amd64.tar.gz"
dest: /usr/local
remote_src: true
creates: /usr/local/go/bin/go
- name: rendre go accessible dans le PATH
ansible.builtin.file:
src: /usr/local/go/bin/go
dest: /usr/local/bin/go
state: link

View file

@ -0,0 +1,98 @@
---
- name: créer l'utilisateur système dédié
ansible.builtin.user:
name: proxyconsole
system: true
shell: /usr/sbin/nologin
home: "{{ proxy_console_dossier }}"
create_home: false
- name: créer le dossier de l'application
ansible.builtin.file:
path: "{{ proxy_console_dossier }}"
state: directory
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0755"
- name: synchroniser le code source
become: false
ansible.posix.synchronize:
src: "{{ playbook_dir }}/../proxy-console/"
dest: "{{ proxy_console_dossier }}/"
delete: true
rsync_opts:
- "--exclude=proxy-console-linux-amd64"
- "--exclude=proxy-console"
- "--exclude=.cache"
- "--exclude=go"
register: proxy_console_code_synchronise
- name: compiler le binaire
ansible.builtin.command:
cmd: /usr/local/bin/go build -o {{ proxy_console_dossier }}/proxy-console .
args:
chdir: "{{ proxy_console_dossier }}"
environment:
HOME: "{{ proxy_console_dossier }}"
register: proxy_console_build
changed_when: true
notify: redémarrer proxy-console
- name: donner la propriété du binaire à l'utilisateur dédié
ansible.builtin.file:
path: "{{ proxy_console_dossier }}/proxy-console"
owner: proxyconsole
group: proxyconsole
mode: "0750"
# Le mot de passe Redis est déjà généré par le rôle `portail` (même Redis,
# partagé) : on le relit plutôt que d'en gérer un second.
- name: lire le mot de passe Redis de l'app
ansible.builtin.slurp:
src: /etc/portail/.env
register: proxy_console_env_portail_brut
- name: extraire le mot de passe Redis
ansible.builtin.set_fact:
proxy_console_redis_mot_de_passe: "{{ proxy_console_env_portail_contenu | regex_search('REDIS_PASSWORD=(.*)', '\\1') | first }}"
vars:
proxy_console_env_portail_contenu: "{{ proxy_console_env_portail_brut.content | b64decode }}"
no_log: true
- name: déployer le fichier d'environnement du service
ansible.builtin.template:
src: proxy-console.env.j2
dest: /etc/proxy-console.env
owner: root
group: proxyconsole
mode: "0640"
no_log: true
notify: redémarrer proxy-console
- name: déployer le service systemd
ansible.builtin.template:
src: proxy-console.service.j2
dest: /etc/systemd/system/proxy-console.service
mode: "0644"
notify:
- recharger systemd
- redémarrer proxy-console
- name: appliquer les changements
ansible.builtin.meta: flush_handlers
- name: vérifier que le service tourne
ansible.builtin.systemd:
name: proxy-console
enabled: true
state: started
- name: déployer les scripts de test manuel de la console
ansible.builtin.copy:
src: "{{ playbook_dir }}/../proxy-console/{{ item }}"
dest: "/opt/construction-image/{{ item }}"
mode: "0644"
loop:
- test-manuel.py
- test-duplication.py

View file

@ -0,0 +1,6 @@
---
- name: installer Go
ansible.builtin.import_tasks: 01-go.yml
- name: déployer et démarrer le service
ansible.builtin.import_tasks: 02-service.yml

View file

@ -0,0 +1,4 @@
# Géré par Ansible (rôle proxy-console) — ne pas modifier à la main.
PROXY_CONSOLE_ECOUTE={{ proxy_console_ecoute }}
PROXY_CONSOLE_REDIS={{ proxy_console_redis }}
PROXY_CONSOLE_REDIS_MOT_DE_PASSE={{ proxy_console_redis_mot_de_passe }}

View file

@ -0,0 +1,20 @@
[Unit]
Description=Proxy console série Syllabus (étape 9)
# Géré par Ansible (rôle proxy-console) — ne pas modifier à la main.
After=network.target
[Service]
Type=simple
User=proxyconsole
Group=proxyconsole
EnvironmentFile=/etc/proxy-console.env
ExecStart={{ proxy_console_dossier }}/proxy-console
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
ProtectSystem=strict
PrivateTmp=true
[Install]
WantedBy=multi-user.target

View 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);
} };
})();

View file

@ -314,3 +314,35 @@ Cette étape a un piège intéressant pour la suite : Laravel a deux systèmes d
**Suite**
Étape 9 : proxy console Go et xterm.js (jeton Redis à usage unique, relais WebSocket vers `nova-serialproxy`, lecture seule pour le prof).
## 2026-10-08 — Étape 9 : proxy console Go et xterm.js
**Fait**
- Vérifié avant d'écrire du code : l'appel Nova exact (`POST /servers/{id}/remote-consoles`) et le format d'authentification Keystone par *application credential* (`POST /v3/auth/tokens`, méthode `application_credential`), par observation directe (`openstack --debug`) et un test `curl` réel — pas de mémoire.
- Go 1.27.1 installé localement (Homebrew) pour développer `proxy-console/` sur le Mac, comme pour Laravel à l'étape 8. Déployé en compilant **sur le serveur** à partir du code source (pas de binaire committé), Go 1.27.1 installé là-bas depuis l'archive officielle (somme SHA256 vérifiée via le flux JSON officiel de go.dev, pas depuis un résumé de page).
- Service Go (`proxy-console/`, utilisateur système dédié sans shell, durci avec `ProtectSystem=strict`) : un `Hub` par VM (`instance_id`) maintient une seule connexion amont vers `nova-serialproxy` (elle n'en accepte qu'une), et duplique ce qu'il reçoit vers tous les navigateurs abonnés à cette VM. Seul l'abonné en rôle `ecriture` peut faire passer ses frappes ; les autres sont ignorées côté serveur.
- Côté Laravel : `NovaClient` (jeton Keystone mis en cache ~50 min), `JetonConsole` (écrit `console:{jeton}` dans Redis, 5 minutes, format lu par le proxy Go), `ConsoleController` (vérifie que l'élève est bien propriétaire de sa session de lab et admis ; que le prof est bien propriétaire de la séance).
- Nginx : `location /console/` relayée en WebSocket vers `127.0.0.1:8081`.
- Front : nouveau script `console-reelle.js` (vraie connexion WebSocket, xterm.js) — distinct de `paillasse-terminal.js` qui est un faux shell simulé pour la vitrine, explicitement prévu pour être remplacé en production. Intégré dans la salle d'attente élève (une fois admis, si une VM est associée) et dans une nouvelle page prof en lecture seule.
**Problèmes rencontrés et solution**
1. **VM de test créée dans le mauvais projet OpenStack** (`admin` plutôt que `paillasse`) : Nova renvoyait `404 Instance could not be found` à l'*application credential* du portail (scopée à `paillasse`), pas une erreur d'authentification. Recréée avec le cloud `paillasse-service`, comme à l'étape 6.
2. `.env` de production existait déjà depuis l'étape 8 et n'est jamais régénéré : les nouvelles variables `OS_*` devaient être ajoutées sans y retoucher le reste (`APP_KEY` en particulier). Ajouté via des tâches `lineinfile` idempotentes, séparées du template de première génération.
3. `go build`, lancé avec `$HOME` pointé sur le dossier de l'app, y crée `.cache/` et `go/pkg/mod/` appartenant à `root` : le `rsync` suivant (sans `become`) ne pouvait plus les supprimer. Exclus du `rsync`.
4. Premier test manuel de la console : « Invalid WebSocket Header ». Le script de test demandait le sous-protocole `binary`, nécessaire pour parler *directement* à `nova-serialproxy` (étape 6) mais pas à notre propre proxy, qui ne le renvoie pas — un vrai navigateur ne le demande pas non plus. Corrigé dans le script de test ; rien à changer côté proxy ni côté `console-reelle.js`.
**Critère vérifié**
- Une élève fictive a tapé `ip a` dans sa VM et vu le résultat, via le chemin complet navigateur → Nginx → proxy Go → `nova-serialproxy`.
- Un autre élève n'a pas pu obtenir de jeton pour cette session de lab (`403`).
- Vérification allant au-delà du critère minimal : prof et élève connectés en même temps sur la même VM — le prof voit ce que tape l'élève (duplication du flux), et sa propre tentative d'écrire n'apparaît jamais dans le flux renvoyé par la VM (protection côté serveur, pas seulement côté interface).
**Comprendre**
La vraie contrainte de cette étape, c'est qu'une console série ne supporte qu'un seul lecteur à la fois — c'est une caractéristique de QEMU/libvirt, pas un choix arbitraire. Tout l'intérêt du proxy Go est d'être le seul à parler à cette connexion fragile et unique, et de se comporter comme un standard téléphonique pour tout le monde d'autre : il décroche une fois, et fait écouter qui il veut sur la même ligne. C'est aussi pour ça que le jeton que porte le navigateur ne doit jamais révéler l'adresse réelle de la console : s'il fallait donner cette adresse directement à chaque élève, rien n'empêcherait d'en ouvrir une deuxième connexion et de faire planter la première.
**Suite**
Étape 10 : liaison Laravel ↔ OpenStack (client HTTP ou `php-opencloud/openstack`) et modèle Heat du lab 08 — poser la vraie mécanique qu'on a seulement simulée à la main à cette étape (associer une VM à une session de lab).

View file

@ -0,0 +1,90 @@
# Runbook — Proxy console série (étape 9)
## Comment ça marche
```
Navigateur élève/prof → WebSocket /console/{jeton} → Nginx → proxy-console (Go, 127.0.0.1:8081)
│
lit "console:{jeton}" dans Redis
(posé par Laravel, 5 min)
│
une seule connexion WebSocket
▼
nova-serialproxy (10.10.0.1:6083)
```
1. Laravel (`App\Services\OpenStack\NovaClient`) s'authentifie auprès de Keystone avec l'*application credential* `portail-syllabus` (étape 6), demande à Nova l'adresse de la console série d'une instance (`POST /servers/{id}/remote-consoles`).
2. `App\Services\OpenStack\JetonConsole` range cette adresse dans Redis sous un jeton aléatoire (`console:{jeton}`, 5 minutes), avec le rôle (`ecriture` pour l'élève, `lecture` pour le prof). Le navigateur ne reçoit que le jeton.
3. Le navigateur ouvre `ws://.../console/{jeton}`, relayé par Nginx vers le proxy Go.
4. Le proxy lit Redis, ouvre (ou réutilise) **une seule** connexion amont vers nova-serialproxy par VM (`instance_id`), et duplique ce qu'il en reçoit vers tous les navigateurs abonnés à cette VM. Seul l'abonné en rôle `ecriture` peut faire passer ses frappes ; les autres sont ignorées côté serveur (pas seulement côté interface).
Le code source : `proxy-console/` (Go), `portail/app/Services/OpenStack/`, `portail/app/Http/Controllers/ConsoleController.php`.
## Déployer
```
cd ansible && script -q /dev/null ansible-playbook proxy-console.yml -i inventory.ini
```
Rejoue aussi le rôle `portail` (le vhost Nginx avec `location /console/` et les
variables `OS_*` de `.env` vivent dans ce rôle).
## Où sont les choses
| Élément | Emplacement |
|---|---|
| Code source Go | `/opt/proxy-console/` (utilisateur système dédié `proxyconsole`, sans shell) |
| Binaire compilé | `/opt/proxy-console/proxy-console` (compilé sur le serveur, jamais committé) |
| Secrets du service (mot de passe Redis) | `/etc/proxy-console.env`, `640 root:proxyconsole` |
| Service | `systemd proxy-console.service`, durci (`ProtectSystem=strict`, `NoNewPrivileges`) |
| Scripts de test manuel | `/opt/construction-image/test-manuel.py`, `test-duplication.py` |
Le proxy utilise **le même Redis que l'app** (port 6380) : pas de deuxième
instance à gérer, juste un espace de clés différent (`console:*`).
## Tester manuellement (sans navigateur)
Récupérer un jeton (en tant qu'élève connecté, via l'API du portail), puis :
```
ssh paillasse
sudo /usr/bin/python3 /opt/construction-image/test-manuel.py 'ws://127.0.0.1:8081/console/<jeton>' 'ip a'
```
Pour vérifier la duplication (élève + prof simultanés, le prof ne peut rien taper) :
```
sudo /usr/bin/python3 /opt/construction-image/test-duplication.py \
'ws://127.0.0.1:8081/console/<jeton-ecriture>' 'ws://127.0.0.1:8081/console/<jeton-lecture>'
```
## Piège vécu le 2026-10-08
**La VM de test avait été créée dans le mauvais projet OpenStack** (`admin`
au lieu de `paillasse`) : l'*application credential* du portail, scopée au
projet `paillasse`, recevait `404 Instance could not be found` de Nova —
pas un problème d'authentification, juste un projet qui ne voit pas les
ressources d'un autre. Toujours créer les VM avec le cloud `paillasse-service`
(étape 6), jamais `kolla-admin` seul.
**`go build` avec `$HOME` pointé sur le dossier de l'app** crée `.cache/` et
`go/pkg/mod/` appartenant à `root`, que le `rsync` suivant (qui tourne sans
`become`) ne peut plus supprimer. Ces deux dossiers sont exclus du `rsync`.
**Le sous-protocole WebSocket `binary`**, nécessaire pour parler directement
à `nova-serialproxy` (étape 6), n'a pas sa place pour parler à *notre*
proxy : le vrai navigateur ne le demande pas, et un client qui le demande
sans que le proxy le lui renvoie peut rejeter le handshake (« Invalid
WebSocket Header »). Les scripts de test ne le demandent plus.
## Vérifié le 2026-10-08
- Une élève fictive a tapé `ip a` dans sa VM et vu le résultat (deux
interfaces, `lo` et `ens3`), via le chemin complet navigateur → Nginx →
proxy Go → nova-serialproxy.
- Un autre élève a tenté d'obtenir un jeton pour cette même session de lab :
`403 Forbidden`.
- Prof et élève connectés en même temps sur la même VM : le prof a vu une
commande tapée par l'élève (duplication du flux), et sa propre tentative
d'écrire n'a eu aucun effet (vérifié qu'elle n'apparaît jamais dans le
flux renvoyé par la VM).

View 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),
]);
}
}

View file

@ -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);

View 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;
}
}

View 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');
});
}
}

View 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'),
];

View 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);
} };
})();

View file

@ -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>
<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>
<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

View file

@ -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="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

View 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

View file

@ -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');
});

2
proxy-console/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
proxy-console
proxy-console-linux-amd64

14
proxy-console/go.mod Normal file
View file

@ -0,0 +1,14 @@
module proxy-console
go 1.27.1
require (
github.com/gorilla/websocket v1.5.3
github.com/redis/go-redis/v9 v9.23.0
)
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
go.uber.org/atomic v1.12.0 // indirect
golang.org/x/sys v0.48.0 // indirect
)

22
proxy-console/go.sum Normal file
View file

@ -0,0 +1,22 @@
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/redis/go-redis/v9 v9.23.0 h1:/TTVdJa8BRNsybwwGGnmiYktHNlUXtbOQxlWpHBPL3s=
github.com/redis/go-redis/v9 v9.23.0/go.mod h1:EWP2UUk+XDaI1s5nQB5mablZN6vccXlmBQTyfnF87D0=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.uber.org/atomic v1.12.0 h1:BvcXdFKuviU4fTL/f+SxdQ5qJX/Jix8pAkgdUcb3XOE=
go.uber.org/atomic v1.12.0/go.mod h1:I6c4cg+6HCxRjfjSsYtApoFILnpc0CGUdGkXVqbYVNk=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=

156
proxy-console/hub.go Normal file
View file

@ -0,0 +1,156 @@
package main
import (
"log"
"net/http"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
)
// Un Hub représente une seule connexion amont vers nova-serialproxy, pour une
// seule VM (identifiée par son instance_id). La console série n'accepte
// qu'une connexion à la fois : le Hub est le seul à parler à nova-serialproxy,
// et duplique ce qu'il reçoit vers tous les navigateurs abonnés (l'élève qui
// écrit, le prof qui regarde en lecture seule).
type Hub struct {
instanceID string
upstream *websocket.Conn
mu sync.Mutex
subscribers map[*Client]bool
}
type Client struct {
conn *websocket.Conn
role string // "ecriture" ou "lecture"
hub *Hub
}
var (
hubsMu sync.Mutex
hubs = map[string]*Hub{}
)
var upstreamDialer = websocket.Dialer{
HandshakeTimeout: 10 * time.Second,
Subprotocols: []string{"binary"},
}
// obtenirHub renvoie le Hub existant pour cette instance, ou en ouvre un
// nouveau (une seule connexion amont) si c'est le premier abonné.
func obtenirHub(instanceID, urlAmont string) (*Hub, error) {
hubsMu.Lock()
defer hubsMu.Unlock()
if hub, ok := hubs[instanceID]; ok {
return hub, nil
}
upstream, _, err := upstreamDialer.Dial(urlAmont, nil)
if err != nil {
return nil, err
}
hub := &Hub{
instanceID: instanceID,
upstream: upstream,
subscribers: map[*Client]bool{},
}
hubs[instanceID] = hub
go hub.pompeAmont()
log.Printf("console %s : connexion amont ouverte", instanceID)
return hub, nil
}
// pompeAmont lit en continu ce que renvoie nova-serialproxy et le recopie
// vers tous les navigateurs abonnés à cette VM.
func (h *Hub) pompeAmont() {
defer h.fermer()
for {
messageType, donnees, err := h.upstream.ReadMessage()
if err != nil {
return
}
h.mu.Lock()
for client := range h.subscribers {
_ = client.conn.WriteMessage(messageType, donnees)
}
h.mu.Unlock()
}
}
func (h *Hub) ajouter(c *Client) {
h.mu.Lock()
h.subscribers[c] = true
h.mu.Unlock()
}
// retirer enlève l'abonné ; si c'était le dernier, la connexion amont est
// fermée (plus personne ne regarde cette VM, pas de raison de la garder).
func (h *Hub) retirer(c *Client) {
h.mu.Lock()
delete(h.subscribers, c)
vide := len(h.subscribers) == 0
h.mu.Unlock()
if vide {
h.fermer()
}
}
func (h *Hub) fermer() {
hubsMu.Lock()
if hubs[h.instanceID] == h {
delete(hubs, h.instanceID)
}
hubsMu.Unlock()
_ = h.upstream.Close()
log.Printf("console %s : connexion amont fermée", h.instanceID)
}
// pompeClient lit ce que tape le navigateur et le relaie vers nova-serialproxy
// — uniquement si ce client a le rôle "ecriture". Un abonné en lecture seule
// ne peut rien envoyer : ses frappes sont simplement ignorées.
func (c *Client) pompeClient() {
defer c.hub.retirer(c)
defer c.conn.Close()
for {
messageType, donnees, err := c.conn.ReadMessage()
if err != nil {
return
}
if c.role != "ecriture" {
continue
}
c.hub.mu.Lock()
err = c.hub.upstream.WriteMessage(messageType, donnees)
c.hub.mu.Unlock()
if err != nil {
return
}
}
}
// N'accepte que les connexions dont l'en-tête Origin correspond au site qui
// sert la page (reflété via Nginx) : empêche un autre site de faire ouvrir
// une console depuis le navigateur d'un élève à son insu.
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
origine := r.Header.Get("Origin")
if origine == "" {
return true // outils en ligne de commande, pas de navigateur
}
return strings.HasSuffix(origine, "://"+r.Host)
},
}

99
proxy-console/main.go Normal file
View file

@ -0,0 +1,99 @@
// Proxy console (étape 9 du déploiement Syllabus).
//
// Laravel demande à Nova l'adresse de la console série d'une VM, la range
// dans Redis sous un jeton aléatoire pour 5 minutes, et ne donne que ce
// jeton au navigateur. Ce service lit le jeton, ouvre (ou réutilise) la
// connexion WebSocket vers nova-serialproxy, et recopie les octets dans les
// deux sens — en lecture seule pour les abonnés qui n'ont pas le droit
// d'écrire (voir hub.go).
package main
import (
"context"
"encoding/json"
"log"
"net/http"
"os"
"github.com/gorilla/websocket"
"github.com/redis/go-redis/v9"
)
// Doit correspondre exactement à ce que Laravel écrit dans Redis
// (App\Services\OpenStack\JetonConsole).
type jetonConsole struct {
URL string `json:"url"`
InstanceID string `json:"instance_id"`
Role string `json:"role"`
}
var rdb *redis.Client
func main() {
adresseEcoute := env("PROXY_CONSOLE_ECOUTE", "127.0.0.1:8081")
adresseRedis := env("PROXY_CONSOLE_REDIS", "127.0.0.1:6380")
motDePasseRedis := os.Getenv("PROXY_CONSOLE_REDIS_MOT_DE_PASSE")
rdb = redis.NewClient(&redis.Options{
Addr: adresseRedis,
Password: motDePasseRedis,
})
http.HandleFunc("/console/", gererConsole)
http.HandleFunc("/sante", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
log.Printf("proxy-console à l'écoute sur %s (Redis %s)", adresseEcoute, adresseRedis)
log.Fatal(http.ListenAndServe(adresseEcoute, nil))
}
func gererConsole(w http.ResponseWriter, r *http.Request) {
jeton := r.URL.Path[len("/console/"):]
if jeton == "" {
http.Error(w, "jeton manquant", http.StatusBadRequest)
return
}
valeur, err := rdb.Get(context.Background(), "console:"+jeton).Result()
if err == redis.Nil {
http.Error(w, "jeton inconnu ou expiré", http.StatusNotFound)
return
}
if err != nil {
log.Printf("erreur Redis pour le jeton %s : %v", jeton, err)
http.Error(w, "erreur serveur", http.StatusInternalServerError)
return
}
var jc jetonConsole
if err := json.Unmarshal([]byte(valeur), &jc); err != nil {
http.Error(w, "jeton corrompu", http.StatusInternalServerError)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("échec de l'upgrade WebSocket : %v", err)
return
}
hub, err := obtenirHub(jc.InstanceID, jc.URL)
if err != nil {
log.Printf("échec de connexion à nova-serialproxy pour %s : %v", jc.InstanceID, err)
_ = conn.WriteMessage(websocket.TextMessage, []byte("\r\nConsole indisponible.\r\n"))
_ = conn.Close()
return
}
client := &Client{conn: conn, role: jc.Role, hub: hub}
hub.ajouter(client)
client.pompeClient()
}
func env(cle, defaut string) string {
if v := os.Getenv(cle); v != "" {
return v
}
return defaut
}

View file

@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""Vérifie la duplication du flux (étape 9) : l'élève se connecte et garde
la connexion ouverte, le prof se connecte en parallèle sur le même instant
et doit voir ce que tape l'élève, sans pouvoir rien y faire lui-même."""
import sys
import threading
import time
import websocket
jeton_eleve = sys.argv[1] # URL ws:// complète
jeton_prof = sys.argv[2] # URL ws:// complète
sortie_prof = []
def ecouter_prof():
ws = websocket.create_connection(jeton_prof, timeout=10)
ws.settimeout(8)
fin = time.time() + 8
while time.time() < fin:
try:
m = ws.recv()
except websocket.WebSocketTimeoutException:
break
sortie_prof.append(m if isinstance(m, str) else m.decode(errors="replace"))
# Le prof essaie d'écrire : ça ne doit avoir aucun effet côté VM.
ws.send_binary(b"COMMANDE-INTERDITE-DU-PROF\r")
ws.close()
ws_eleve = websocket.create_connection(jeton_eleve, timeout=10)
ws_eleve.send_binary(b"eleve\r")
time.sleep(1)
ws_eleve.send_binary(b"eleve\r")
time.sleep(1)
fil_prof = threading.Thread(target=ecouter_prof)
fil_prof.start()
time.sleep(1)
ws_eleve.send_binary(b"echo MARQUEUR-ELEVE-UNIQUE\r")
time.sleep(2)
ws_eleve.settimeout(2)
try:
while True:
ws_eleve.recv()
except Exception:
pass
fil_prof.join()
ws_eleve.close()
texte_prof = "".join(sortie_prof)
print("Le prof a-t-il vu le marqueur de l'élève ?", "MARQUEUR-ELEVE-UNIQUE" in texte_prof)
print("Le prof a-t-il vu sa propre commande interdite s'exécuter ? (ne doit jamais arriver)",
"COMMANDE-INTERDITE" in texte_prof and "command not found" not in texte_prof)

View file

@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Test manuel du proxy-console, comme le ferait le navigateur (étape 9) :
pas de sous-protocole demandé, contrairement au test direct de nova-serialproxy."""
import sys
import time
import websocket
url = sys.argv[1]
commande = sys.argv[2] if len(sys.argv) > 2 else "echo bonjour-syllabus"
def lire(ws, duree=3):
fin = time.time() + duree
sortie = b""
while time.time() < fin:
ws.settimeout(max(0.2, fin - time.time()))
try:
morceau = ws.recv()
except websocket.WebSocketTimeoutException:
break
except websocket.WebSocketConnectionClosedException:
print("[connexion fermée par le serveur]")
break
if isinstance(morceau, str):
morceau = morceau.encode()
if not morceau:
break
sortie += morceau
return sortie
ws = websocket.create_connection(url, timeout=10)
print("=== lecture initiale ===")
sys.stdout.buffer.write(lire(ws, 3))
print("\n=== connexion : eleve ===")
ws.send_binary(b"eleve\r")
sys.stdout.buffer.write(lire(ws, 2))
print("\n=== mot de passe ===")
ws.send_binary(b"eleve\r")
sys.stdout.buffer.write(lire(ws, 2))
print(f"\n=== commande : {commande} ===")
ws.send_binary((commande + "\r").encode())
sys.stdout.buffer.write(lire(ws, 3))
ws.close()
print("\n=== fin ===")