From 8742bde8072267d1d6b12afadf929f31a27b5f08 Mon Sep 17 00:00:00 2001 From: Adam Belkacemi Date: Thu, 8 Oct 2026 09:35:16 +0200 Subject: [PATCH] ajoute le proxy console Go et le relais WebSocket vers nova-serialproxy Co-Authored-By: Claude Sonnet 5 --- ansible/proxy-console.yml | 11 ++ ansible/roles/portail/tasks/03-code.yml | 30 ++++ .../portail/templates/nginx-portail.conf.j2 | 11 ++ ansible/roles/proxy-console/defaults/main.yml | 6 + ansible/roles/proxy-console/handlers/main.yml | 10 ++ ansible/roles/proxy-console/tasks/01-go.yml | 32 ++++ .../roles/proxy-console/tasks/02-service.yml | 98 +++++++++++ ansible/roles/proxy-console/tasks/main.yml | 6 + .../templates/proxy-console.env.j2 | 4 + .../templates/proxy-console.service.j2 | 20 +++ design/js/console-reelle.js | 60 +++++++ docs/journal.md | 32 ++++ docs/runbooks/proxy-console.md | 90 ++++++++++ .../Http/Controllers/ConsoleController.php | 44 +++++ .../app/Http/Controllers/SeanceController.php | 11 ++ .../app/Services/OpenStack/JetonConsole.php | 47 ++++++ portail/app/Services/OpenStack/NovaClient.php | 59 +++++++ portail/config/openstack.php | 13 ++ portail/public/js/console-reelle.js | 60 +++++++ .../views/seances/attente-eleve.blade.php | 29 +++- .../resources/views/seances/attente.blade.php | 7 +- .../views/seances/console-prof.blade.php | 23 +++ portail/routes/web.php | 4 + proxy-console/.gitignore | 2 + proxy-console/go.mod | 14 ++ proxy-console/go.sum | 22 +++ proxy-console/hub.go | 156 ++++++++++++++++++ proxy-console/main.go | 99 +++++++++++ proxy-console/test-duplication.py | 58 +++++++ proxy-console/test-manuel.py | 51 ++++++ 30 files changed, 1103 insertions(+), 6 deletions(-) create mode 100644 ansible/proxy-console.yml create mode 100644 ansible/roles/proxy-console/defaults/main.yml create mode 100644 ansible/roles/proxy-console/handlers/main.yml create mode 100644 ansible/roles/proxy-console/tasks/01-go.yml create mode 100644 ansible/roles/proxy-console/tasks/02-service.yml create mode 100644 ansible/roles/proxy-console/tasks/main.yml create mode 100644 ansible/roles/proxy-console/templates/proxy-console.env.j2 create mode 100644 ansible/roles/proxy-console/templates/proxy-console.service.j2 create mode 100644 design/js/console-reelle.js create mode 100644 docs/runbooks/proxy-console.md create mode 100644 portail/app/Http/Controllers/ConsoleController.php create mode 100644 portail/app/Services/OpenStack/JetonConsole.php create mode 100644 portail/app/Services/OpenStack/NovaClient.php create mode 100644 portail/config/openstack.php create mode 100644 portail/public/js/console-reelle.js create mode 100644 portail/resources/views/seances/console-prof.blade.php create mode 100644 proxy-console/.gitignore create mode 100644 proxy-console/go.mod create mode 100644 proxy-console/go.sum create mode 100644 proxy-console/hub.go create mode 100644 proxy-console/main.go create mode 100644 proxy-console/test-duplication.py create mode 100644 proxy-console/test-manuel.py diff --git a/ansible/proxy-console.yml b/ansible/proxy-console.yml new file mode 100644 index 0000000..c961520 --- /dev/null +++ b/ansible/proxy-console.yml @@ -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 diff --git a/ansible/roles/portail/tasks/03-code.yml b/ansible/roles/portail/tasks/03-code.yml index edd7c80..44ad47a 100644 --- a/ansible/roles/portail/tasks/03-code.yml +++ b/ansible/roles/portail/tasks/03-code.yml @@ -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 diff --git a/ansible/roles/portail/templates/nginx-portail.conf.j2 b/ansible/roles/portail/templates/nginx-portail.conf.j2 index cd2501e..28a78b9 100644 --- a/ansible/roles/portail/templates/nginx-portail.conf.j2 +++ b/ansible/roles/portail/templates/nginx-portail.conf.j2 @@ -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; diff --git a/ansible/roles/proxy-console/defaults/main.yml b/ansible/roles/proxy-console/defaults/main.yml new file mode 100644 index 0000000..2c22e25 --- /dev/null +++ b/ansible/roles/proxy-console/defaults/main.yml @@ -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" diff --git a/ansible/roles/proxy-console/handlers/main.yml b/ansible/roles/proxy-console/handlers/main.yml new file mode 100644 index 0000000..f5fe350 --- /dev/null +++ b/ansible/roles/proxy-console/handlers/main.yml @@ -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 diff --git a/ansible/roles/proxy-console/tasks/01-go.yml b/ansible/roles/proxy-console/tasks/01-go.yml new file mode 100644 index 0000000..c8ba474 --- /dev/null +++ b/ansible/roles/proxy-console/tasks/01-go.yml @@ -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 diff --git a/ansible/roles/proxy-console/tasks/02-service.yml b/ansible/roles/proxy-console/tasks/02-service.yml new file mode 100644 index 0000000..69c7bbb --- /dev/null +++ b/ansible/roles/proxy-console/tasks/02-service.yml @@ -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 diff --git a/ansible/roles/proxy-console/tasks/main.yml b/ansible/roles/proxy-console/tasks/main.yml new file mode 100644 index 0000000..97b59ea --- /dev/null +++ b/ansible/roles/proxy-console/tasks/main.yml @@ -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 diff --git a/ansible/roles/proxy-console/templates/proxy-console.env.j2 b/ansible/roles/proxy-console/templates/proxy-console.env.j2 new file mode 100644 index 0000000..290d157 --- /dev/null +++ b/ansible/roles/proxy-console/templates/proxy-console.env.j2 @@ -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 }} diff --git a/ansible/roles/proxy-console/templates/proxy-console.service.j2 b/ansible/roles/proxy-console/templates/proxy-console.service.j2 new file mode 100644 index 0000000..c866d1f --- /dev/null +++ b/ansible/roles/proxy-console/templates/proxy-console.service.j2 @@ -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 diff --git a/design/js/console-reelle.js b/design/js/console-reelle.js new file mode 100644 index 0000000..0f7dd17 --- /dev/null +++ b/design/js/console-reelle.js @@ -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); + } }; +})(); diff --git a/docs/journal.md b/docs/journal.md index e4c3e76..97bd684 100644 --- a/docs/journal.md +++ b/docs/journal.md @@ -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). diff --git a/docs/runbooks/proxy-console.md b/docs/runbooks/proxy-console.md new file mode 100644 index 0000000..16c0616 --- /dev/null +++ b/docs/runbooks/proxy-console.md @@ -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/' '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/' 'ws://127.0.0.1:8081/console/' +``` + +## 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). diff --git a/portail/app/Http/Controllers/ConsoleController.php b/portail/app/Http/Controllers/ConsoleController.php new file mode 100644 index 0000000..47656db --- /dev/null +++ b/portail/app/Http/Controllers/ConsoleController.php @@ -0,0 +1,44 @@ +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), + ]); + } +} diff --git a/portail/app/Http/Controllers/SeanceController.php b/portail/app/Http/Controllers/SeanceController.php index 3c7ebfa..a9181af 100644 --- a/portail/app/Http/Controllers/SeanceController.php +++ b/portail/app/Http/Controllers/SeanceController.php @@ -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); diff --git a/portail/app/Services/OpenStack/JetonConsole.php b/portail/app/Services/OpenStack/JetonConsole.php new file mode 100644 index 0000000..cfc5145 --- /dev/null +++ b/portail/app/Services/OpenStack/JetonConsole.php @@ -0,0 +1,47 @@ +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; + } +} diff --git a/portail/app/Services/OpenStack/NovaClient.php b/portail/app/Services/OpenStack/NovaClient.php new file mode 100644 index 0000000..8146852 --- /dev/null +++ b/portail/app/Services/OpenStack/NovaClient.php @@ -0,0 +1,59 @@ + $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'); + }); + } +} diff --git a/portail/config/openstack.php b/portail/config/openstack.php new file mode 100644 index 0000000..816edc6 --- /dev/null +++ b/portail/config/openstack.php @@ -0,0 +1,13 @@ + 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'), +]; diff --git a/portail/public/js/console-reelle.js b/portail/public/js/console-reelle.js new file mode 100644 index 0000000..0f7dd17 --- /dev/null +++ b/portail/public/js/console-reelle.js @@ -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); + } }; +})(); diff --git a/portail/resources/views/seances/attente-eleve.blade.php b/portail/resources/views/seances/attente-eleve.blade.php index 77441c5..4b65ff0 100644 --- a/portail/resources/views/seances/attente-eleve.blade.php +++ b/portail/resources/views/seances/attente-eleve.blade.php @@ -6,14 +6,33 @@ @if ($labSession->etat === \App\Models\LabSession::EN_ATTENTE) @endif -
+

{{ $seance->lab->titre }}

@if ($labSession->etat === \App\Models\LabSession::EN_ATTENTE) -

En attente que le prof t'admette…

-

Cette page se rafraîchit toute seule.

+
+

En attente que le prof t'admette…

+

Cette page se rafraîchit toute seule.

+
+ @elseif ($labSession->stack_id) +

Ta console

+
+ + + + + @else -

Tu es admis·e !

-

Le lancement de ta VM arrive dans une prochaine étape du déploiement.

+
+

Tu es admis·e !

+

Le lancement de ta VM arrive dans une prochaine étape du déploiement.

+
@endif
@endsection diff --git a/portail/resources/views/seances/attente.blade.php b/portail/resources/views/seances/attente.blade.php index bfb6e8e..fe2d79d 100644 --- a/portail/resources/views/seances/attente.blade.php +++ b/portail/resources/views/seances/attente.blade.php @@ -22,7 +22,12 @@ @else - Admis + + Admis + @if ($labSession->stack_id) + Voir sa console + @endif + @endif @endforeach diff --git a/portail/resources/views/seances/console-prof.blade.php b/portail/resources/views/seances/console-prof.blade.php new file mode 100644 index 0000000..9a90bdc --- /dev/null +++ b/portail/resources/views/seances/console-prof.blade.php @@ -0,0 +1,23 @@ +@extends('layouts.vitrine') + +@section('titre', 'Console de ' . $labSession->eleve->nomAffiche()) + +@section('contenu') +
+

{{ $seance->lab->titre }} · lecture seule

+

Console de {{ $labSession->eleve->nomAffiche() }}

+
+ + + + + +
+@endsection diff --git a/portail/routes/web.php b/portail/routes/web.php index 2695dac..ccf42af 100644 --- a/portail/routes/web.php +++ b/portail/routes/web.php @@ -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'); }); diff --git a/proxy-console/.gitignore b/proxy-console/.gitignore new file mode 100644 index 0000000..c6a7818 --- /dev/null +++ b/proxy-console/.gitignore @@ -0,0 +1,2 @@ +proxy-console +proxy-console-linux-amd64 diff --git a/proxy-console/go.mod b/proxy-console/go.mod new file mode 100644 index 0000000..a658f49 --- /dev/null +++ b/proxy-console/go.mod @@ -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 +) diff --git a/proxy-console/go.sum b/proxy-console/go.sum new file mode 100644 index 0000000..bf1fe5c --- /dev/null +++ b/proxy-console/go.sum @@ -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= diff --git a/proxy-console/hub.go b/proxy-console/hub.go new file mode 100644 index 0000000..aedc02d --- /dev/null +++ b/proxy-console/hub.go @@ -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) + }, +} diff --git a/proxy-console/main.go b/proxy-console/main.go new file mode 100644 index 0000000..74fccde --- /dev/null +++ b/proxy-console/main.go @@ -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 +} diff --git a/proxy-console/test-duplication.py b/proxy-console/test-duplication.py new file mode 100644 index 0000000..c3ca81e --- /dev/null +++ b/proxy-console/test-duplication.py @@ -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) diff --git a/proxy-console/test-manuel.py b/proxy-console/test-manuel.py new file mode 100644 index 0000000..736922a --- /dev/null +++ b/proxy-console/test-manuel.py @@ -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 ===")