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
11
ansible/proxy-console.yml
Normal file
11
ansible/proxy-console.yml
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
6
ansible/roles/proxy-console/defaults/main.yml
Normal file
6
ansible/roles/proxy-console/defaults/main.yml
Normal 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"
|
||||
10
ansible/roles/proxy-console/handlers/main.yml
Normal file
10
ansible/roles/proxy-console/handlers/main.yml
Normal 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
|
||||
32
ansible/roles/proxy-console/tasks/01-go.yml
Normal file
32
ansible/roles/proxy-console/tasks/01-go.yml
Normal 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
|
||||
98
ansible/roles/proxy-console/tasks/02-service.yml
Normal file
98
ansible/roles/proxy-console/tasks/02-service.yml
Normal 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
|
||||
6
ansible/roles/proxy-console/tasks/main.yml
Normal file
6
ansible/roles/proxy-console/tasks/main.yml
Normal 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
|
||||
|
|
@ -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 }}
|
||||
|
|
@ -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
|
||||
60
design/js/console-reelle.js
Normal file
60
design/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);
|
||||
} };
|
||||
})();
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
90
docs/runbooks/proxy-console.md
Normal file
90
docs/runbooks/proxy-console.md
Normal 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).
|
||||
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>
|
||||
<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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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');
|
||||
});
|
||||
|
|
|
|||
2
proxy-console/.gitignore
vendored
Normal file
2
proxy-console/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
proxy-console
|
||||
proxy-console-linux-amd64
|
||||
14
proxy-console/go.mod
Normal file
14
proxy-console/go.mod
Normal 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
22
proxy-console/go.sum
Normal 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
156
proxy-console/hub.go
Normal 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
99
proxy-console/main.go
Normal 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
|
||||
}
|
||||
58
proxy-console/test-duplication.py
Normal file
58
proxy-console/test-duplication.py
Normal 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)
|
||||
51
proxy-console/test-manuel.py
Normal file
51
proxy-console/test-manuel.py
Normal 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 ===")
|
||||
Loading…
Add table
Add a link
Reference in a new issue