diff --git a/ansible/roles/durcissement/defaults/main.yml b/ansible/roles/durcissement/defaults/main.yml index 4b4b7fd..7bb03d3 100644 --- a/ansible/roles/durcissement/defaults/main.yml +++ b/ansible/roles/durcissement/defaults/main.yml @@ -5,6 +5,20 @@ durcissement_ports_ouverts: - 80 - 443 +# Ports UDP ouverts. 10000 : média de la visio (Jitsi Videobridge, étape 15, +# accord d'Adam le 2026-10-09). Le relais TURN sur 443 pour les réseaux qui +# bloquent l'UDP reste à faire (docs/dette.md). +durcissement_ports_udp_ouverts: + - 10000 + # Délai avant retour automatique à l'état d'avant durcissement, si la vérification # post-changement échoue (voir CLAUDE.md et le prompt de déploiement, règle 4). durcissement_delai_filet_secondes: 300 + +# Ponts Docker dont les conteneurs peuvent se parler entre eux. br_netfilter +# fait passer le trafic interne d'un pont par la chaîne forward (politique +# drop) : sans cette règle, les conteneurs d'un même réseau ne se joignent +# pas. Seul le trafic qui entre ET ressort par ce pont est accepté. +# br-visio : Jitsi (étape 15, accord d'Adam le 2026-10-12). +durcissement_ponts_internes: + - br-visio diff --git a/ansible/roles/durcissement/templates/nftables.conf.j2 b/ansible/roles/durcissement/templates/nftables.conf.j2 index 221e624..4bfbc09 100644 --- a/ansible/roles/durcissement/templates/nftables.conf.j2 +++ b/ansible/roles/durcissement/templates/nftables.conf.j2 @@ -1,7 +1,7 @@ #!/usr/sbin/nft -f # Géré par Ansible (rôle durcissement) — ne pas modifier à la main. # Politique : tout est refusé en entrée sauf loopback, trafic déjà établi, -# ICMP, et les ports {{ durcissement_ports_ouverts | join(', ') }}. +# ICMP, les ports TCP {{ durcissement_ports_ouverts | join(', ') }}{% if durcissement_ports_udp_ouverts | default([]) %} et UDP {{ durcissement_ports_udp_ouverts | join(', ') }}{% endif %}. flush ruleset @@ -17,10 +17,18 @@ table inet filter { meta l4proto ipv6-icmp accept tcp dport { {{ durcissement_ports_ouverts | join(', ') }} } accept +{% if durcissement_ports_udp_ouverts | default([]) %} + udp dport { {{ durcissement_ports_udp_ouverts | join(', ') }} } accept +{% endif %} } chain forward { type filter hook forward priority 0; policy drop; +{% for pont in durcissement_ponts_internes | default([]) %} + + # Trafic interne au réseau Docker {{ pont }} (conteneur ↔ conteneur). + iifname "{{ pont }}" oifname "{{ pont }}" accept +{% endfor %} {% if reseau_veth_gw_subnet is defined %} # Un seul sens autorisé : réseau externe OpenStack -> Internet (NAT), diff --git a/ansible/roles/portail/defaults/main.yml b/ansible/roles/portail/defaults/main.yml index 9f65e6b..bc188d2 100644 --- a/ansible/roles/portail/defaults/main.yml +++ b/ansible/roles/portail/defaults/main.yml @@ -16,3 +16,6 @@ portail_domaine_statut: status.syllabuslab.fr portail_statut_slug: syllabus portail_ip_publique: 163.172.4.195 portail_port_uptime_kuma: 3001 +# Étape 15 : visio (Jitsi, rôle visio), activée quand ce nom résout vers le serveur. +portail_domaine_visio: meet.syllabuslab.fr +portail_port_visio: 8090 diff --git a/ansible/roles/portail/tasks/05-nginx.yml b/ansible/roles/portail/tasks/05-nginx.yml index ad1618e..fce0e6f 100644 --- a/ansible/roles/portail/tasks/05-nginx.yml +++ b/ansible/roles/portail/tasks/05-nginx.yml @@ -26,12 +26,20 @@ changed_when: false failed_when: false -- name: noter si la page de statut peut être activée +- name: vérifier le DNS du sous-domaine de la visio + ansible.builtin.command: getent ahostsv4 {{ portail_domaine_visio }} + register: portail_dns_visio + changed_when: false + failed_when: false + +- name: noter si la page de statut et la visio peuvent être activées ansible.builtin.set_fact: portail_statut_actif: "{{ portail_ip_publique in portail_dns_statut.stdout }}" + portail_visio_actif: "{{ portail_ip_publique in portail_dns_visio.stdout }}" portail_noms_certificat: >- {{ [portail_domaine] + portail_domaines_alias - + ([portail_domaine_statut] if portail_ip_publique in portail_dns_statut.stdout else []) }} + + ([portail_domaine_statut] if portail_ip_publique in portail_dns_statut.stdout else []) + + ([portail_domaine_visio] if portail_ip_publique in portail_dns_visio.stdout else []) }} - name: vérifier si le certificat existe déjà ansible.builtin.stat: diff --git a/ansible/roles/portail/templates/nginx-portail.conf.j2 b/ansible/roles/portail/templates/nginx-portail.conf.j2 index 2806d9a..a36b19e 100644 --- a/ansible/roles/portail/templates/nginx-portail.conf.j2 +++ b/ansible/roles/portail/templates/nginx-portail.conf.j2 @@ -40,7 +40,7 @@ server { listen 80; listen [::]:80; - server_name {{ portail_domaine }} {{ portail_domaines_alias | join(' ') }}{% if portail_statut_actif %} {{ portail_domaine_statut }}{% endif %}; + server_name {{ portail_domaine }} {{ portail_domaines_alias | join(' ') }}{% if portail_statut_actif %} {{ portail_domaine_statut }}{% endif %}{% if portail_visio_actif | default(false) %} {{ portail_domaine_visio }}{% endif %}; server_tokens off; # Dans une location (pas au niveau server) : la location exacte que # certbot ajoute pour le défi ACME au renouvellement reste prioritaire. @@ -117,6 +117,48 @@ server { error_log /var/log/nginx/statut-error.log; } {% endif %} +{% if portail_visio_actif | default(false) %} + +# Visio (Jitsi Meet, rôle visio). Pas l'extrait d'en-têtes commun : son +# X-Frame-Options et sa Permissions-Policy interdiraient l'intégration dans +# le portail et la caméra. Intégration permise au seul portail. +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name {{ portail_domaine_visio }}; + server_tokens off; + include snippets/syllabus-tls.conf; + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Content-Security-Policy "frame-ancestors https://{{ portail_domaine }}" always; + + # Pas de page d'accueil Jitsi : on n'entre dans une salle que depuis le portail. + location = / { + return 302 https://{{ portail_domaine }}/; + } + + location /xmpp-websocket { + proxy_pass http://127.0.0.1:{{ portail_port_visio }}; + 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 / { + proxy_pass http://127.0.0.1:{{ portail_port_visio }}; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto https; + } + + access_log /var/log/nginx/visio-access.log; + error_log /var/log/nginx/visio-error.log; +} +{% endif %} {% else %} # Avant le premier certificat : HTTP seul, avec les noms du domaine pour que # certbot (authentificateur nginx) trouve le bon bloc. diff --git a/ansible/roles/portail/templates/syllabus-entetes.conf.j2 b/ansible/roles/portail/templates/syllabus-entetes.conf.j2 index ff5730b..4372ecc 100644 --- a/ansible/roles/portail/templates/syllabus-entetes.conf.j2 +++ b/ansible/roles/portail/templates/syllabus-entetes.conf.j2 @@ -6,5 +6,7 @@ add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" alway add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "DENY" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; -add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always; +# Caméra, micro et partage d'écran : délégués à la seule visio intégrée +# (étape 15) ; refusés à tout autre site. +add_header Permissions-Policy "camera=(self \"https://{{ portail_domaine_visio }}\"), microphone=(self \"https://{{ portail_domaine_visio }}\"), display-capture=(self \"https://{{ portail_domaine_visio }}\"), geolocation=(), payment=()" always; add_header Cross-Origin-Opener-Policy "same-origin" always; diff --git a/ansible/roles/visio/defaults/main.yml b/ansible/roles/visio/defaults/main.yml new file mode 100644 index 0000000..fc7f65f --- /dev/null +++ b/ansible/roles/visio/defaults/main.yml @@ -0,0 +1,17 @@ +--- +# Étape 15 : visio de classe (Jitsi Meet auto-hébergé, docker-jitsi-meet). +visio_version: stable-11248 +visio_domaine: meet.syllabuslab.fr +visio_dossier: /opt/visio +visio_port_web: 8090 +visio_ip_publique: 163.172.4.195 +visio_port_media: 10000 +# Émetteur et audience des jetons signés par le portail (portail/app/Services/Visio). +visio_jwt_app_id: syllabus +visio_jwt_audience: syllabus-visio +# Mémoire plafonnée (Java) : la visio prend sur la capacité des VM d'élèves. +visio_memoire_jvb: 1024m +visio_memoire_jicofo: 512m +# Utilisateur des conteneurs docker-jitsi-meet (ils refusent de démarrer +# si leurs volumes ne lui appartiennent pas). +visio_uid_conteneurs: 1000 diff --git a/ansible/roles/visio/handlers/main.yml b/ansible/roles/visio/handlers/main.yml new file mode 100644 index 0000000..1dc8fe7 --- /dev/null +++ b/ansible/roles/visio/handlers/main.yml @@ -0,0 +1,7 @@ +--- +- name: remettre en cache la configuration du portail + ansible.builtin.command: + cmd: runuser -u www-data -- php artisan config:cache + args: + chdir: /var/www/portail + changed_when: true diff --git a/ansible/roles/visio/tasks/main.yml b/ansible/roles/visio/tasks/main.yml new file mode 100644 index 0000000..3058877 --- /dev/null +++ b/ansible/roles/visio/tasks/main.yml @@ -0,0 +1,126 @@ +--- +# Étape 15 : visio de classe. Jitsi Meet (docker-jitsi-meet, version figée) +# derrière le Nginx de l'hôte ; accès par jeton JWT signé par le portail. +- name: créer les dossiers de la visio + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: root + group: root + mode: "0750" + loop: + - /etc/visio + - "{{ visio_dossier }}" + +- name: créer les dossiers des conteneurs (au nom de leur utilisateur) + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: "{{ visio_uid_conteneurs }}" + group: "{{ visio_uid_conteneurs }}" + mode: "0750" + loop: + - "{{ visio_dossier }}/config" + - "{{ visio_dossier }}/config/web" + - "{{ visio_dossier }}/config/storage/web" + - "{{ visio_dossier }}/config/storage/transcripts" + - "{{ visio_dossier }}/config/tmp/web-load-test" + - "{{ visio_dossier }}/config/prosody/config" + - "{{ visio_dossier }}/config/prosody/prosody-plugins-custom" + - "{{ visio_dossier }}/config/storage/prosody" + - "{{ visio_dossier }}/config/jicofo" + - "{{ visio_dossier }}/config/jvb" + +# Générés une seule fois sur le serveur, jamais dans Git ni affichés. +- name: générer les secrets de la visio (première fois seulement) + ansible.builtin.copy: + dest: /etc/visio/secrets.env + owner: root + group: root + mode: "0600" + force: false + content: | + JICOFO_AUTH_PASSWORD={{ lookup('community.general.random_string', length=40, special=false) }} + JVB_AUTH_PASSWORD={{ lookup('community.general.random_string', length=40, special=false) }} + JWT_APP_SECRET={{ lookup('community.general.random_string', length=64, special=false) }} + no_log: true + +- name: déployer la configuration de la visio + ansible.builtin.template: + src: visio.env.j2 + dest: /etc/visio/visio.env + owner: root + group: root + mode: "0640" + register: visio_config + +- name: déployer docker-compose.yml de la visio + ansible.builtin.template: + src: docker-compose.yml.j2 + dest: "{{ visio_dossier }}/docker-compose.yml" + owner: root + group: root + mode: "0640" + register: visio_compose + +- name: démarrer Jitsi (web, prosody, jicofo, jvb) + community.docker.docker_compose_v2: + project_src: "{{ visio_dossier }}" + project_name: visio + state: present + pull: missing + recreate: "{{ 'always' if (visio_config.changed or visio_compose.changed) else 'auto' }}" + +- name: attendre que Jitsi web réponde sur 127.0.0.1 + ansible.builtin.uri: + url: "http://127.0.0.1:{{ visio_port_web }}/" + status_code: [200] + register: visio_web + retries: 20 + delay: 3 + until: visio_web.status == 200 + +- name: vérifier que JVB écoute l'UDP {{ visio_port_media }} + ansible.builtin.shell: + cmd: "ss -Hulnp 'sport = :{{ visio_port_media }}' | grep -q java" + register: visio_jvb + retries: 20 + delay: 3 + until: visio_jvb.rc == 0 + changed_when: false + +# Jicofo doit voir un JVB opérationnel : c'est la preuve que les quatre +# services se parlent (cassé tant que le pare-feu jetait le trafic entre +# conteneurs, voir durcissement_ponts_internes). +- name: vérifier que Jicofo voit un JVB opérationnel + ansible.builtin.shell: + cmd: >- + docker compose -p visio -f {{ visio_dossier }}/docker-compose.yml exec -T jicofo + curl -s http://127.0.0.1:8888/stats + register: visio_stats_jicofo + retries: 20 + delay: 5 + until: >- + visio_stats_jicofo.rc == 0 and + (visio_stats_jicofo.stdout | from_json).bridge_selector.operational_bridge_count | int >= 1 + changed_when: false + +# Le portail signe les jetons avec le même secret (portail/config/visio.php). +- name: lire le secret de signature des jetons + ansible.builtin.slurp: + src: /etc/visio/secrets.env + register: visio_secrets_brut + no_log: true + +- name: transmettre la configuration de la visio au portail + ansible.builtin.lineinfile: + path: /var/www/portail/.env + regexp: "^{{ item.cle }}=" + line: "{{ item.cle }}={{ item.valeur }}" + loop: + - { cle: "VISIO_DOMAINE", valeur: "{{ visio_domaine }}" } + - { cle: "VISIO_JWT_APP_ID", valeur: "{{ visio_jwt_app_id }}" } + - { cle: "VISIO_JWT_AUDIENCE", valeur: "{{ visio_jwt_audience }}" } + - { cle: "VISIO_JWT_SECRET", valeur: "{{ visio_secrets_brut.content | b64decode | regex_search('JWT_APP_SECRET=(\\S+)', '\\1') | first }}" } + no_log: true + notify: remettre en cache la configuration du portail diff --git a/ansible/roles/visio/templates/docker-compose.yml.j2 b/ansible/roles/visio/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..f59b636 --- /dev/null +++ b/ansible/roles/visio/templates/docker-compose.yml.j2 @@ -0,0 +1,88 @@ +# Géré par Ansible (rôle visio). D'après docker-jitsi-meet {{ visio_version }} +# (docker-compose.yml officiel), adapté à ce serveur : +# - Docker tourne sans pont par défaut ni règles iptables (étape 5) : JVB est +# sur le réseau de l'hôte (UDP {{ visio_port_media }} sans NAT) et joint Prosody +# par 127.0.0.1 ; +# - le conteneur web n'écoute que sur 127.0.0.1, derrière le Nginx de l'hôte. +services: + web: + image: ghcr.io/jitsi/web:{{ visio_version }} + restart: unless-stopped + read_only: true + tmpfs: + - /run:size=16M,mode=1750,exec + - /tmp:size=16M,mode=1777,noexec + ports: + - '127.0.0.1:{{ visio_port_web }}:8000' + volumes: + - {{ visio_dossier }}/config/web:/config:Z + - {{ visio_dossier }}/config/storage/web:/storage:Z + - {{ visio_dossier }}/config/storage/transcripts:/usr/share/jitsi-meet/transcripts:ro,z + - {{ visio_dossier }}/config/tmp/web-load-test:/usr/share/jitsi-meet/load-test:Z + env_file: [/etc/visio/visio.env, /etc/visio/secrets.env] + networks: + meet.jitsi: + depends_on: + - prosody + + prosody: + image: ghcr.io/jitsi/prosody:{{ visio_version }} + restart: unless-stopped + read_only: true + tmpfs: + - /run:size=16M,mode=1750,exec + - /tmp:size=16M,mode=1777,noexec + ports: + # Pour JVB, sur le réseau de l'hôte. + - '127.0.0.1:5222:5222' + expose: + - '5269' + - '5347' + - '5280' + volumes: + - {{ visio_dossier }}/config/prosody/config:/config:Z + - {{ visio_dossier }}/config/prosody/prosody-plugins-custom:/prosody-plugins-custom:Z + - {{ visio_dossier }}/config/storage/prosody:/var/lib/prosody:Z + env_file: [/etc/visio/visio.env, /etc/visio/secrets.env] + networks: + meet.jitsi: + aliases: + - xmpp.meet.jitsi + + jicofo: + image: ghcr.io/jitsi/jicofo:{{ visio_version }} + restart: unless-stopped + read_only: true + tmpfs: + - /run:size=32M,mode=1750,exec + - /tmp:size=16M,mode=1777,noexec + volumes: + - {{ visio_dossier }}/config/jicofo:/config:Z + env_file: [/etc/visio/visio.env, /etc/visio/secrets.env] + networks: + meet.jitsi: + depends_on: + - prosody + + jvb: + image: ghcr.io/jitsi/jvb:{{ visio_version }} + restart: unless-stopped + read_only: true + tmpfs: + - /run:size=32M,mode=1750,exec + - /tmp:size=16M,mode=1777,noexec + network_mode: host + volumes: + - {{ visio_dossier }}/config/jvb:/config:Z + env_file: [/etc/visio/visio.env, /etc/visio/secrets.env] + environment: + XMPP_SERVER: 127.0.0.1 + depends_on: + - prosody + +networks: + meet.jitsi: + # Nom fixe : le pare-feu n'autorise le trafic entre conteneurs que + # sur ce pont (rôle durcissement, durcissement_ponts_internes). + driver_opts: + com.docker.network.bridge.name: br-visio diff --git a/ansible/roles/visio/templates/visio.env.j2 b/ansible/roles/visio/templates/visio.env.j2 new file mode 100644 index 0000000..e6a5786 --- /dev/null +++ b/ansible/roles/visio/templates/visio.env.j2 @@ -0,0 +1,44 @@ +# Géré par Ansible (rôle visio) — aucun secret ici (voir /etc/visio/secrets.env). +PUBLIC_URL=https://{{ visio_domaine }} +TZ=Europe/Paris + +# HTTPS assuré par le Nginx de l'hôte (certificat Let's Encrypt du portail). +DISABLE_HTTPS=1 +ENABLE_HTTP_REDIRECT=0 +ENABLE_LETSENCRYPT=0 + +# Entrée seulement avec un jeton signé par le portail ; pas d'invités. +ENABLE_AUTH=1 +AUTH_TYPE=jwt +ENABLE_GUESTS=0 +JWT_APP_ID={{ visio_jwt_app_id }} +JWT_ACCEPTED_ISSUERS={{ visio_jwt_app_id }} +JWT_ACCEPTED_AUDIENCES={{ visio_jwt_audience }} + +# Le prof seul modérateur : rôle lu dans le jeton (context.user.moderator, +# module natif token_affiliation), jamais attribué au premier arrivé. +XMPP_MUC_MODULES=token_affiliation +ENABLE_AUTO_OWNER=0 +# Jicofo ne fait PAS sa propre authentification : sinon il donne le rôle de +# propriétaire à tout utilisateur authentifié, élèves compris (constaté le +# 2026-10-12 : un élève pouvait ouvrir son micro sans la parole). Prosody +# vérifie toujours chaque jeton ; token_affiliation seul décide du rôle. +JICOFO_ENABLE_AUTH=0 + +# Salle d'attente, modération du micro et de la caméra. +ENABLE_LOBBY=1 +ENABLE_AV_MODERATION=1 + +# Tout le monde arrive micro et caméra coupés ; pas de page d'accueil ni de +# pré-réunion (le nom vient du jeton et ne se modifie pas). +START_AUDIO_MUTED=0 +START_VIDEO_MUTED=0 +ENABLE_WELCOME_PAGE=0 +ENABLE_PREJOIN_PAGE=0 +# Sans « chat » : désactivé par défaut. +TOOLBAR_BUTTONS=microphone,camera,desktop,raisehand,participants-pane,tileview,settings,fullscreen,hangup + +JVB_ADVERTISE_IPS={{ visio_ip_publique }} +JVB_PORT={{ visio_port_media }} +VIDEOBRIDGE_MAX_MEMORY={{ visio_memoire_jvb }} +JICOFO_MAX_MEMORY={{ visio_memoire_jicofo }} diff --git a/ansible/visio.yml b/ansible/visio.yml new file mode 100644 index 0000000..7e8fdfb --- /dev/null +++ b/ansible/visio.yml @@ -0,0 +1,11 @@ +--- +# Étape 15 du déploiement Syllabus : visio de classe (Jitsi Meet). +# Rejoue aussi `portail` : il porte le vhost Nginx de meet.syllabuslab.fr et +# le certificat (activés dès que le nom DNS répond). +- name: Portail et visio + hosts: syllabus + become: true + gather_facts: true + roles: + - portail + - visio diff --git a/docs/adr/0005-visio-jitsi.md b/docs/adr/0005-visio-jitsi.md new file mode 100644 index 0000000..b100b52 --- /dev/null +++ b/docs/adr/0005-visio-jitsi.md @@ -0,0 +1,54 @@ +# ADR 0005 — Visio de classe : Jitsi auto-hébergé, jetons du portail, prof seul modérateur + +- **Date** : 2026-10-12 +- **Statut** : accepté + +## Contexte + +Étape 15 : une visio pour la classe, sur le même serveur, où seuls les élèves +de la classe entrent (nom non modifiable), où tout le monde arrive en +sourdine et où seul le prof donne la parole. + +## Décisions + +1. **Jitsi Meet en Docker** (docker-jitsi-meet, version figée `stable-11248`), + rôle Ansible `visio`, derrière le Nginx de l'hôte sur `meet.syllabuslab.fr`. +2. **JVB sur le réseau de l'hôte** : Docker tourne sans pont par défaut ni règles + iptables (étape 5), donc la publication de l'UDP 10000 par NAT Docker est + impossible. JVB écoute directement l'UDP 10000 (ouvert dans nftables, accord + d'Adam) et joint Prosody par `127.0.0.1:5222`. +3. **Pont interne `br-visio` autorisé** dans la chaîne `forward` (accord d'Adam) : + avec `br_netfilter`, le trafic entre conteneurs d'un même réseau Docker passe + par `forward`, dont la politique `drop` bloquait Jicofo et le conteneur web. + Seul le trafic qui entre ET ressort par `br-visio` est accepté. +4. **Accès par jeton JWT (HS256) signé par le portail**, un par personne, pour + une salle au nom imprévisible ; pas d'invités (`ENABLE_GUESTS=0`). Le secret + est généré sur le serveur et copié dans le `.env` du portail par Ansible. +5. **Rôle de modérateur lu dans le jeton** (`context.user.moderator`, module + natif `token_affiliation`) ; `ENABLE_AUTO_OWNER=0` et **`JICOFO_ENABLE_AUTH=0`** : + sinon Jicofo authentifie chaque participant et donne le rôle de propriétaire à + tous (constaté : un élève pouvait ouvrir son micro sans la parole). +6. **Modération du micro** activée par la page du prof à son arrivée + (`toggleModeration`), « Donner la parole » = `askToUnmute` (qui autorise le + micro quand la modération est active, d'après la documentation de l'IFrame API). +7. **État de la classe tenu par le portail** (cache Redis, verrou) : mains levées + par ordre d'arrivée, correspondance élève ↔ participant Jitsi, salle d'attente. + La main levée suit toujours l'événement Jitsi (`raiseHandUpdated`) : une seule source. + +## Alternatives écartées + +- **Publier l'UDP 10000 par Docker** : demande de rendre à Docker la main sur + iptables, ce qu'on a refusé dès l'étape 5. +- **Mettre tous les conteneurs sur le réseau de l'hôte** : conflits de ports avec + Kolla (ex. 10.10.0.1:8000) et services internes exposés sur toutes les interfaces. +- **Relais TURN sur 443** pour les réseaux qui bloquent l'UDP : impose de + restructurer l'entrée HTTPS (aiguillage par nom) ; reporté (choix d'Adam). + +## Conséquences + +- Réseaux scolaires qui bloquent l'UDP : pas de son ni d'image tant que le relais + TURN sur 443 n'existe pas (`docs/dette.md`). +- Capacité ramenée de 22 à 20 VM (`docs/capacite.md`). +- `Permissions-Policy` du portail délègue caméra, micro et partage d'écran au seul + `meet.syllabuslab.fr` ; le site `meet` n'est intégrable que dans le portail + (`frame-ancestors`). diff --git a/docs/capacite.md b/docs/capacite.md index 7f38d4f..33d5813 100644 --- a/docs/capacite.md +++ b/docs/capacite.md @@ -6,7 +6,14 @@ Kolla 2025.2 tout-en-un. Script : `openstack/tests/capacite.py` ; données brutes sur le serveur : `/root/capacite-20261009/` (`hote.csv` toutes les 2 s, `demarrage.csv`, `resume.json`). -## Capacité retenue : 22 VM `eleve.petit` en même temps +## Capacité retenue : 20 VM `eleve.petit` en même temps + +Ramenée de 22 à 20 le 2026-10-12 pour la visio (Jitsi) : après une visio à +trois, Jitsi occupe ≈ 710 Mo (JVB 459, Jicofo 206, Prosody 28, web 16) ; JVB est +plafonné à 1 Go et Jicofo à 512 Mo, soit 1,5 Go réservés en plus +(`reserved_host_memory_mb` 15000 → 16500 ; (31993 − 16500) / 768 = 20). + +### Historique : 22 VM (étape 13) Sessions d'élèves et stock chaud compris. Choisie par Adam d'après les mesures ci-dessous. Elle est appliquée à deux endroits, à garder alignés : @@ -17,7 +24,7 @@ mesures ci-dessous. Elle est appliquée à deux endroits, à garder alignés : | Portail (plafond du stock chaud) | `LABS_CAPACITE_VM`, défaut 22 (`portail/config/labs.php`) | | Quota du projet (filet de sécurité, au-dessus) | 40 instances, 40 vCPU, 30 Go (rôle `openstack-projet`) | -Ce que ça permet : une classe de 22 sur un lab à 1 VM ; 7 élèves sur le +Ce que ça permet (20 VM) : une classe de 20 sur un lab à 1 VM ; 6 élèves sur le lab 08 (3 VM chacun). Moins le stock en attente (1 stack par lab hors séance). ## Mesures diff --git a/docs/dette.md b/docs/dette.md index 284b697..2d25d29 100644 --- a/docs/dette.md +++ b/docs/dette.md @@ -20,10 +20,12 @@ retirer la ligne (et le noter dans le journal) quand c'est fait. | Correction séquentielle (~5 s pour le lab 08) | Acceptable ; à paralléliser si la charge l'exige. | Bloc B | Étape 11 | | Jeton de console : ≈ 1,1 s par appel Nova `remote-consoles` | Plus gros poste de l'ouverture d'un lab (2,8 s au total, critère < 5 s tenu). Préparer les jetons à l'attribution si besoin. | Bloc B | Étape 13 | | Tâches Ansible toujours « modifiées » (composer, npm, build, cache) | Brouille la lecture de l'idempotence. | Bloc B | Étape 8 | -| `Permissions-Policy` ferme caméra et micro | À ouvrir pour la visio. | Étape 15 | Étape 12 | | Mentions légales à compléter | Article « Disponibilité et responsabilité » des CGU ; délégué à la protection des données s'il en est désigné (éditeur, SIREN et adresse complétés le 2026-10-09). | Avant le pilote | Étape 14 | | Slogans « du lycée au master » | Le catalogue va aujourd'hui du lycée au BUT. Positionnement laissé tel quel : décision éditoriale d'Adam. | À décider | Étape 14 | | Ancien `storage/logs/laravel.log` | Fichier unique d'avant la rotation quotidienne (étape 14) : jamais purgé, à supprimer ou archiver (accord d'Adam). | Dès que possible | Étape 14 | | Pages d'aperçu publiques (32) | Signalées comme aperçus avec données fictives ; à retirer ou remplacer au fur et à mesure que les fonctions réelles arrivent. | Au fil du bloc B | Étape 14 | | Invitation des élèves majeurs par e-mail (V2 du CLAUDE.md) | Demande un service d'envoi d'e-mails (compte ouvert par Adam, DNS SPF/DKIM). Jamais pour les mineurs. | V2 | 2026-10-09 | | Code de classe : caractères ambigus | Généré avec `Str::random` (0/O, 1/I/l possibles), contrairement aux codes élèves. Utiliser le même alphabet sans ambiguïté. | Bloc B | 2026-10-09 | +| **Visio : pas de relais TURN sur 443** | Les réseaux scolaires qui bloquent l'UDP n'auront ni son ni image (CLAUDE.md : tout par 443). Restructurer l'entrée HTTPS (aiguillage Nginx par nom) + coturn ; demande un nom DNS (turn.syllabuslab.fr). | Avant le pilote | Étape 15 | +| Visio : chat activé à chaud non vérifié | L'interrupteur « Chat des élèves » change la barre d'outils par `overwriteConfig` ; non vérifié en navigateur. Vue mobile de la maquette non reprise (la page élève est simplement adaptative). | Bloc B | Étape 15 | +| Image Jitsi figée sans procédure de mise à jour | `stable-11248` : comme pour Kuma, prévoir la montée de version par Ansible (variable `visio_version`) et rejouer `visio-e2e.mjs`. | Bloc B | Étape 15 | diff --git a/docs/journal.md b/docs/journal.md index 09c104d..10af656 100644 --- a/docs/journal.md +++ b/docs/journal.md @@ -625,3 +625,49 @@ Vérifié en production : `/ide` 1 script en ligne, 0 erreur de syntaxe, `sandbo - 6 tests Pest (41 au total) ; vérifié en production : `/rejoindre` 200 avec CSP stricte, code faux refusé avec message, table créée, nettoyage planifié. **Clé des sauvegardes** : je ne peux ni ne dois voir la copie d'Adam. Procédure de preuve ajoutée au runbook des sauvegardes : un message chiffré sur le serveur avec la clé publique, que la copie doit déchiffrer sur le Mac. Fichier de test déposé sur le Bureau d'Adam. Corrigé aussi la commande de copie du runbook (un `scp` d'un fichier de root échouait). + +## 2026-10-12 — Étape 15 : visio de classe + +Démarrée après validation des étapes 1 à 11 par Adam. Décisions : [ADR 0005](adr/0005-visio-jitsi.md). + +**Fait** + +- **Jitsi Meet auto-hébergé** (docker-jitsi-meet `stable-11248`, version vérifiée sur le dépôt officiel, images figées), rôle Ansible `visio` : configuration d'après le `docker-compose.yml` officiel, JVB sur le réseau de l'hôte, secrets générés une fois sur le serveur, contrôle au déploiement que Jicofo voit un JVB opérationnel. +- **Pare-feu** (deux accords explicites d'Adam, filet anti-coupure de 5 min, vérification SSH depuis le Mac, règles de NAT d'OpenStack intactes) : UDP 10000 ouvert ; pont `br-visio` autorisé en `forward` pour les échanges entre conteneurs. +- **Accès par jeton JWT** signé par le portail (HS256, une salle imprévisible par séance, nom non modifiable : prénom et initiale pour les élèves) ; pas d'invités. **Prof seul modérateur** par le module natif `token_affiliation` (vérifié dans l'image : `context.user.moderator`). +- **Pages prof et élève** reprises de `design/resources/views/visio.blade.php` : « En direct » et chrono, « Couper tous les micros », « Terminer la visio », mains levées (premier arrivé en haut, « Donner la parole »), salle d'attente (élèves présents tant que la visio est fermée, « Tout admettre » l'ouvre), chat des élèves désactivé par défaut avec interrupteur ; côté élève : statut, micro, caméra, « Lever la main », « Quitter ». La grille factice est remplacée par la vraie visio (IFrame API, `external_api.js` servi par `meet`). +- **Main levée synchronisée** : l'événement Jitsi `raiseHandUpdated` met à jour l'état du portail (cache Redis, verrou) ; quand le prof donne la parole (`askToUnmute`, qui autorise le micro sous modération d'après la documentation officielle), le portail baisse la main. +- `meet.syllabuslab.fr` ajouté au certificat ; site intégrable dans le seul portail (`frame-ancestors`) ; `Permissions-Policy` du portail : caméra, micro et partage d'écran délégués à `meet` seulement ; CSP stricte complétée (`frame-src` et `script-src` vers `meet`). +- **Capacité ramenée à 20 VM** (Jitsi ≈ 710 Mo après une visio à trois, plafonds 1 Go + 512 Mo) : Nova et portail alignés. +- 6 tests Pest de la visio (47 au total) ; test réel en navigateurs `portail/tests/Reels/visio-e2e.mjs` (Playwright 1.64, Chromium, caméra et micro simulés) ; politique de confidentialité complétée (visio sans enregistrement). + +**Problèmes rencontrés et solution** + +1. Conteneurs Jitsi en boucle : leurs volumes doivent appartenir à l'uid 1000 (ils tournent sous cet utilisateur). +2. **Jicofo ne joignait pas Prosody** (« Connect timed out ») : le module `br_netfilter` fait passer le trafic entre conteneurs d'un même réseau Docker par la chaîne `forward`, dont la politique est `drop`. Jamais vu avant parce que tous les autres conteneurs ne sont joints que depuis l'hôte. Règle `br-visio` (accord d'Adam). +3. DNS de `meet` absent au départ : test complet d'abord joué en résolvant `meet` vers le serveur dans Chromium (`--host-resolver-rules`, certificat du portail accepté), puis rejoué en conditions réelles une fois le DNS créé par Adam et le certificat étendu. +4. Tinker resté ouvert en attente de saisie sur le serveur, bloquant la suite : lancé désormais avec `< /dev/null` (runbook). +5. **Un élève pouvait ouvrir son micro sans la parole** — trouvé par le test en navigateurs. En observant les messages XMPP envoyés par chaque navigateur, aucun client n'approuvait les élèves : c'est **Jicofo**, avec son authentification active par défaut, qui donnait le rôle de propriétaire à tout utilisateur authentifié, élèves compris (ils échappaient donc aussi à la modération et pouvaient modérer les autres). `JICOFO_ENABLE_AUTH=0` : Prosody vérifie toujours chaque jeton, `token_affiliation` seul attribue les rôles. Vérifié dans les journaux : élèves `PARTICIPANT`, liste des autorisés = le prof, puis l'élève à qui il donne la parole. + +**Critère vérifié (vrai DNS, vrai certificat, trois navigateurs + un intrus)** + +``` +prof : visio ouverte, Jitsi chargé +prof : · 3 en ligne (le prof et les deux élèves) +prof : main levée reçue de « Lea M. » +Yanis tente d'ouvrir son micro sans la parole → « En sourdine » +Léa : sa main est baissée (parole donnée) +Léa ouvre son micro → « Tu as la parole » +intrus sans jeton : le prof voit toujours « · 3 en ligne » ; écran de l'intrus : « … Authentication required … » +CRITÈRE DE L’ÉTAPE 15 : RÉUSSI +``` + +Données et fichiers de test supprimés (accord d'Adam). + +**Comprendre** + +Dans une visio, l'image et le son ne sont que la partie visible : la sécurité tient dans *qui a quel rôle*. Ici, trois composants pouvaient en décider — Prosody (le serveur de messagerie), le module `token_affiliation` (qui lit le jeton signé par le portail) et Jicofo (l'organisateur des conférences) — et le moins attendu, Jicofo, accordait silencieusement le rôle de propriétaire à tout le monde. Aucune lecture de configuration ne l'aurait montré : il a fallu un test qui joue la situation réelle (un élève tente de parler sans y être autorisé) puis observer les messages échangés pour remonter à la cause. L'autre leçon est réseau : un pare-feu « tout refuser » protège aussi contre ses propres conteneurs, et une règle doit alors être la plus étroite possible (ici, un seul pont, dans les deux sens). + +**Suite** + +Étape 16 : nettoyage. diff --git a/docs/runbooks/visio.md b/docs/runbooks/visio.md new file mode 100644 index 0000000..d7ca1ef --- /dev/null +++ b/docs/runbooks/visio.md @@ -0,0 +1,54 @@ +# Runbook — Visio de classe (Jitsi, étape 15) + +Décisions : [ADR 0005](../adr/0005-visio-jitsi.md). + +## Où sont les choses + +| Élément | Emplacement | +|---|---| +| Conteneurs (web, prosody, jicofo, jvb) | `/opt/visio/docker-compose.yml`, projet Docker `visio` (rôle `visio`) | +| Configuration (sans secret) | `/etc/visio/visio.env` (modèle `ansible/roles/visio/templates/visio.env.j2`) | +| Secrets (mots de passe internes, secret des jetons) | `/etc/visio/secrets.env`, `600 root:root`, générés une fois ; `VISIO_JWT_SECRET` copié dans le `.env` du portail | +| Site public | `https://meet.syllabuslab.fr` (vhost du rôle `portail`, activé quand le DNS répond) | +| Pare-feu | UDP 10000 ouvert ; pont `br-visio` autorisé en `forward` (rôle `durcissement`) | +| Portail | `VisioController`, `JetonVisio`, vues `seances/visio-prof` et `seances/visio-eleve` | +| Test réel | `portail/tests/Reels/preparer-visio.php` + `visio-e2e.mjs` (Playwright) | + +## Déployer + +``` +cd ansible && ansible-playbook visio.yml -i inventory.ini +``` + +Le rôle vérifie que Jitsi web répond, que JVB écoute l'UDP 10000, et que +**Jicofo voit un JVB opérationnel** (preuve que les services se parlent). + +## Vérifier + +``` +D="sudo docker compose -p visio -f /opt/visio/docker-compose.yml" +$D ps +$D exec -T jicofo curl -s http://127.0.0.1:8888/stats | python3 -m json.tool | grep -E "operational_bridge_count|conferences" +$D logs --since 10m jicofo | grep -E "Member joined|Setting whitelist|Moderation for" +``` + +Attendu dans les journaux de Jicofo : le prof `role=OWNER`, les élèves +`role=PARTICIPANT` ; la liste des autorisés à parler ne contient que le prof, +puis chaque élève au moment où il reçoit la parole. **Si un élève y apparaît dès +son arrivée, Jicofo a repris l'authentification : vérifier `JICOFO_ENABLE_AUTH=0`.** + +## Test de bout en bout (navigateurs) + +1. Sur le serveur : `sudo -u www-data HOME=/tmp php8.3 artisan tinker /var/www/portail/tests/Reels/preparer-visio.php < /dev/null` + (le `< /dev/null` évite que Tinker reste ouvert en attente d'une saisie). +2. Sur le Mac : récupérer `/tmp/visio-identifiants.json` (fichier `600`), puis + `node visio-e2e.mjs identifiants.json` (Playwright, Chromium). +3. Supprimer les données et fichiers de test. + +## Pièges rencontrés + +- Conteneurs refusant de démarrer : leurs volumes doivent appartenir à l'uid 1000. +- Conteneurs qui ne se joignent pas : `br_netfilter` + politique `forward drop` (règle `br-visio`). +- Jicofo ne se reconnecte pas tout seul après une longue indisponibilité de Prosody : `$D restart jicofo`. +- Élèves modérateurs malgré `token_affiliation` : `JICOFO_ENABLE_AUTH=0` obligatoire. +- `Permissions-Policy` trop stricte (caméra interdite) ou `X-Frame-Options DENY` : la visio ne s'affiche pas sans erreur visible. diff --git a/kolla/config/nova/nova-compute.conf b/kolla/config/nova/nova-compute.conf index 4301037..27f666f 100644 --- a/kolla/config/nova/nova-compute.conf +++ b/kolla/config/nova/nova-compute.conf @@ -8,8 +8,10 @@ resume_guests_state_on_host_boot = true # Défaut : 512. Nova se croyait libre de distribuer 31,5 Go alors que Kolla et # le portail en occupent ~15,5 : il aurait accepté ~41 VM et l'hôte aurait -# manqué de mémoire bien avant qu'il refuse. (31993 - 15000) / 768 = 22 VM -# eleve.petit au plus : capacité retenue par Adam le 2026-10-09 d'après le -# test de capacité (docs/capacite.md). Au-delà, Nova refuse proprement -# (« No valid host ») et la session passe en échec, visible du prof. -reserved_host_memory_mb = 15000 +# manqué de mémoire bien avant qu'il refuse. Capacité retenue par Adam le +# 2026-10-09 d'après le test de capacité : 22 VM (réserve 15000) ; ramenée à +# 20 VM le 2026-10-12 pour la visio (Jitsi : JVB plafonné à 1 Go, Jicofo à +# 512 Mo) : (31993 - 16500) / 768 = 20 VM eleve.petit au plus (docs/capacite.md). +# Au-delà, Nova refuse proprement (« No valid host ») et la session passe en +# échec, visible du prof. +reserved_host_memory_mb = 16500 diff --git a/portail/app/Http/Controllers/VisioController.php b/portail/app/Http/Controllers/VisioController.php new file mode 100644 index 0000000..d33bd86 --- /dev/null +++ b/portail/app/Http/Controllers/VisioController.php @@ -0,0 +1,230 @@ +verifierProf($seance); + + return view('seances.visio-prof', [ + 'seance' => $seance->load(['classe', 'lab']), + 'jeton' => $seance->visioOuverte() ? $this->jetons->pour($seance, 'prof-'.$prof->id, $prof->name, true) : null, + ]); + } + + public function ouvrir(Seance $seance): RedirectResponse + { + $this->verifierProf($seance); + abort_if($seance->classe->estArchivee() || $seance->statut === Seance::TERMINEE, 409); + + if (! $seance->visioOuverte()) { + $seance->update([ + 'visio_ouverte_a' => now(), + 'visio_salle' => 'syllabus-'.$seance->id.'-'.Str::lower(Str::random(16)), + 'visio_chat' => false, + ]); + } + + return redirect()->route('seances.visio.prof', $seance); + } + + public function fermer(Seance $seance): RedirectResponse + { + $this->verifierProf($seance); + $seance->update(['visio_ouverte_a' => null, 'visio_salle' => null, 'visio_chat' => false]); + Cache::forget($this->cle($seance)); + + return redirect()->route('seances.attente', $seance); + } + + public function etatProf(Seance $seance): JsonResponse + { + $this->verifierProf($seance); + $etat = $this->etat($seance); + $limite = time() - self::PRESENCE_SECONDES; + + $mains = collect($etat['mains'])->sortBy('depuis')->map(fn ($m, $id) => $m + [ + 'eleve_id' => $id, + 'participant_id' => $etat['participants'][$id] ?? null, + ])->values(); + $attente = collect($etat['presences']) + ->filter(fn ($p, $id) => $p['vu'] >= $limite && ! isset($etat['participants'][$id])) + ->map(fn ($p) => ['nom' => $p['nom']])->values(); + + return response()->json([ + 'ouverte' => $seance->visioOuverte(), + 'chat' => $seance->visio_chat, + 'mains' => $mains, + 'attente' => $attente, + ]); + } + + /** + * Le prof a donné la parole dans Jitsi (askToUnmute, qui autorise le + * micro quand la modération est active) : la main est baissée. + */ + public function parole(Seance $seance, Eleve $eleve): JsonResponse + { + $this->verifierProf($seance); + abort_if($eleve->classe_id !== $seance->classe_id, 404); + $this->modifier($seance, function (array $etat) use ($eleve) { + unset($etat['mains'][$eleve->id]); + + return $etat; + }); + + return response()->json(['ok' => true]); + } + + public function chat(Request $request, Seance $seance): JsonResponse + { + $this->verifierProf($seance); + $seance->update(['visio_chat' => $request->boolean('actif')]); + + return response()->json(['chat' => $seance->visio_chat]); + } + + // ---- Élève ------------------------------------------------------------ + + public function eleve(Seance $seance): View + { + $eleve = $this->verifierEleve($seance); + + return view('seances.visio-eleve', [ + 'seance' => $seance->load(['classe', 'lab']), + 'eleve' => $eleve, + 'jeton' => $seance->visioOuverte() ? $this->jetons->pour($seance, 'eleve-'.$eleve->id, $this->nomVisible($eleve), false) : null, + ]); + } + + /** + * Interrogé régulièrement par la page de l'élève : sert aussi de signe de + * présence (salle d'attente côté prof). + */ + public function etatEleve(Seance $seance): JsonResponse + { + $eleve = $this->verifierEleve($seance); + $etat = $this->modifier($seance, function (array $etat) use ($eleve) { + $etat['presences'][$eleve->id] = ['nom' => $this->nomVisible($eleve), 'vu' => time()]; + + return $etat; + }); + + return response()->json([ + 'ouverte' => $seance->visioOuverte(), + 'chat' => $seance->visio_chat, + 'main_levee' => isset($etat['mains'][$eleve->id]), + ]); + } + + public function main(Request $request, Seance $seance): JsonResponse + { + $eleve = $this->verifierEleve($seance); + $levee = $request->boolean('levee'); + $this->modifier($seance, function (array $etat) use ($eleve, $levee) { + if ($levee) { + $etat['mains'][$eleve->id] ??= ['nom' => $this->nomVisible($eleve), 'depuis' => time()]; + } else { + unset($etat['mains'][$eleve->id]); + } + + return $etat; + }); + + return response()->json(['main_levee' => $levee]); + } + + public function participant(Request $request, Seance $seance): JsonResponse + { + $eleve = $this->verifierEleve($seance); + $participant = $request->validate(['participant_id' => ['required', 'string', 'max:64', 'regex:/^[A-Za-z0-9_-]+$/']])['participant_id']; + $this->modifier($seance, function (array $etat) use ($eleve, $participant) { + $etat['participants'][$eleve->id] = $participant; + + return $etat; + }); + + return response()->json(['ok' => true]); + } + + // ---- Interne ---------------------------------------------------------- + + private function verifierProf(Seance $seance) + { + $prof = Auth::guard('web')->user(); + abort_if($seance->classe->user_id !== $prof->id, 403); + + return $prof; + } + + private function verifierEleve(Seance $seance): Eleve + { + $eleve = Auth::guard('eleve')->user(); + abort_if($eleve->classe_id !== $seance->classe_id || $seance->classe->estArchivee(), 403); + + return $eleve; + } + + /** + * Minimisation : visible de toute la classe, donc prénom et initiale du nom. + */ + private function nomVisible(Eleve $eleve): string + { + return $eleve->prenom.($eleve->nom ? ' '.mb_strtoupper(mb_substr($eleve->nom, 0, 1)).'.' : ''); + } + + private function cle(Seance $seance): string + { + return "visio:{$seance->id}"; + } + + /** + * @return array{mains: array, participants: array, presences: array} + */ + private function etat(Seance $seance): array + { + return Cache::get($this->cle($seance)) ?? ['mains' => [], 'participants' => [], 'presences' => []]; + } + + /** + * Lecture-modification-écriture sous verrou : deux élèves qui lèvent la + * main au même instant ne s'écrasent pas. + */ + private function modifier(Seance $seance, callable $changement): array + { + return Cache::lock($this->cle($seance).':verrou', 5)->block(3, function () use ($seance, $changement) { + $etat = $changement($this->etat($seance)); + Cache::put($this->cle($seance), $etat, self::DUREE_ETAT); + + return $etat; + }); + } +} diff --git a/portail/app/Http/Middleware/PolitiqueSecuriteContenu.php b/portail/app/Http/Middleware/PolitiqueSecuriteContenu.php index 61178d4..f8a3e43 100644 --- a/portail/app/Http/Middleware/PolitiqueSecuriteContenu.php +++ b/portail/app/Http/Middleware/PolitiqueSecuriteContenu.php @@ -50,9 +50,13 @@ class PolitiqueSecuriteContenu 'report-uri' => '/csp-rapport', ]; + // Visio (étape 15) : script d'intégration et cadre servis par Jitsi. + $visio = config('visio.domaine') ? ' https://'.config('visio.domaine') : ''; + $directives = $this->estApplication($request) ? [ - 'script-src' => "'self' 'nonce-".Vite::cspNonce()."'", + 'script-src' => "'self' 'nonce-".Vite::cspNonce()."'".$visio, + 'frame-src' => "'self'".$visio, // Attributs style= présents dans le gabarit commun : sans // risque d'exécution de code, tolérés. 'style-src' => "'self' 'unsafe-inline'", diff --git a/portail/app/Models/Seance.php b/portail/app/Models/Seance.php index 9cdf146..fb0a79a 100644 --- a/portail/app/Models/Seance.php +++ b/portail/app/Models/Seance.php @@ -19,14 +19,21 @@ class Seance extends Model public const TERMINEE = 'terminee'; - protected $fillable = ['classe_id', 'lab_id', 'statut', 'prevue_a', 'demarree_a', 'terminee_a']; + protected $fillable = ['classe_id', 'lab_id', 'statut', 'prevue_a', 'demarree_a', 'terminee_a', 'visio_ouverte_a', 'visio_salle', 'visio_chat']; protected $casts = [ 'prevue_a' => 'datetime', 'demarree_a' => 'datetime', 'terminee_a' => 'datetime', + 'visio_ouverte_a' => 'datetime', + 'visio_chat' => 'boolean', ]; + public function visioOuverte(): bool + { + return $this->visio_ouverte_a !== null && $this->visio_salle !== null; + } + public function classe(): BelongsTo { return $this->belongsTo(Classe::class); diff --git a/portail/app/Services/Visio/JetonVisio.php b/portail/app/Services/Visio/JetonVisio.php new file mode 100644 index 0000000..f796086 --- /dev/null +++ b/portail/app/Services/Visio/JetonVisio.php @@ -0,0 +1,52 @@ +visio_salle === null) { + throw new RuntimeException('Visio non configurée ou salle non ouverte.'); + } + + $maintenant = time(); + $charge = [ + 'iss' => config('visio.jwt.app_id'), + 'aud' => config('visio.jwt.audience'), + 'sub' => '*', + 'room' => $seance->visio_salle, + 'iat' => $maintenant, + 'nbf' => $maintenant - 10, + 'exp' => $maintenant + 60 * (int) config('visio.jwt.duree_minutes'), + 'context' => [ + 'user' => [ + 'id' => $identifiant, + 'name' => $nom, + 'moderator' => $moderateur, + ], + ], + ]; + + $entete = $this->base64Url(json_encode(['alg' => 'HS256', 'typ' => 'JWT'])); + $corps = $this->base64Url(json_encode($charge, JSON_UNESCAPED_UNICODE)); + $signature = $this->base64Url(hash_hmac('sha256', "{$entete}.{$corps}", $secret, true)); + + return "{$entete}.{$corps}.{$signature}"; + } + + private function base64Url(string $donnees): string + { + return rtrim(strtr(base64_encode($donnees), '+/', '-_'), '='); + } +} diff --git a/portail/config/labs.php b/portail/config/labs.php index b938ca5..becba6a 100644 --- a/portail/config/labs.php +++ b/portail/config/labs.php @@ -10,7 +10,7 @@ return [ // même temps, sessions d'élèves et stock compris : mesuré à l'étape 13 // (docs/capacite.md). Doit rester aligné sur la réserve mémoire de Nova // (kolla/config/nova/nova-compute.conf), qui refuse au-delà. - 'capacite_vm' => (int) env('LABS_CAPACITE_VM', 22), + 'capacite_vm' => (int) env('LABS_CAPACITE_VM', 20), 'stock' => [ // Stacks prêtes gardées en permanence pour chaque lab du catalogue, diff --git a/portail/config/visio.php b/portail/config/visio.php new file mode 100644 index 0000000..510f6a6 --- /dev/null +++ b/portail/config/visio.php @@ -0,0 +1,14 @@ + env('VISIO_DOMAINE'), + 'jwt' => [ + 'app_id' => env('VISIO_JWT_APP_ID', 'syllabus'), + 'audience' => env('VISIO_JWT_AUDIENCE', 'syllabus-visio'), + 'secret' => env('VISIO_JWT_SECRET'), + // Durée de validité d'un jeton : le temps d'une séance. + 'duree_minutes' => 180, + ], +]; diff --git a/portail/database/migrations/2026_10_12_120000_add_visio_to_seances.php b/portail/database/migrations/2026_10_12_120000_add_visio_to_seances.php new file mode 100644 index 0000000..dd507ff --- /dev/null +++ b/portail/database/migrations/2026_10_12_120000_add_visio_to_seances.php @@ -0,0 +1,26 @@ +timestamp('visio_ouverte_a')->nullable()->after('terminee_a'); + // Nom de salle Jitsi imprévisible : il figure dans les jetons, et + // un jeton ne vaut que pour cette salle. + $table->string('visio_salle', 64)->nullable()->after('visio_ouverte_a'); + $table->boolean('visio_chat')->default(false)->after('visio_salle'); + }); + } + + public function down(): void + { + Schema::table('seances', function (Blueprint $table) { + $table->dropColumn(['visio_ouverte_a', 'visio_salle', 'visio_chat']); + }); + } +}; diff --git a/portail/resources/views/confidentialite.blade.php b/portail/resources/views/confidentialite.blade.php index bef8264..2eaae59 100644 --- a/portail/resources/views/confidentialite.blade.php +++ b/portail/resources/views/confidentialite.blade.php @@ -48,6 +48,7 @@
  • sessions de lab : date, état, machines virtuelles attribuées ;
  • résultats de la vérification automatique (réussi ou non, note, message par vérification).
  • s'il rejoint sa classe avec le code de classe : le prénom et le nom (facultatif) qu'il saisit, le temps que l'enseignant accepte ou refuse sa demande.
  • +
  • pendant une visio de classe : le nom affiché aux autres participants (prénom et initiale du nom, non modifiable) et les mains levées. Le son et l’image transitent par notre serveur, en France, sans être enregistrés.
  • Ce que l’élève tape dans la console de sa machine virtuelle n’est pas enregistré par Syllabus. Son enseignant peut suivre la console en direct, en lecture seule, pendant la séance.

    Enseignants

    @@ -73,6 +74,7 @@
  • Classe et données de ses élèves : jusqu’à l’archivage de la classe par l’enseignant, puis supprimées automatiquement 90 jours après l’archivage.
  • Demande pour rejoindre une classe : supprimée dès que l’élève a reçu son identifiant, et au plus tard une heure après son envoi.
  • Compte enseignant : tant qu’il est utilisé ; supprimé sur demande.
  • +
  • Visio : rien n’est enregistré ; l’état de la séance (mains levées, présence) reste en mémoire au plus 6 heures.
  • Journaux techniques : 14 jours.
  • Sauvegardes : chiffrées, conservées au plus 5 semaines (7 sauvegardes quotidiennes et 4 hebdomadaires). Une donnée supprimée disparaît donc des sauvegardes au plus tard 5 semaines après sa suppression.
  • @@ -112,7 +114,7 @@

    Cookies

    Syllabus n’utilise que deux cookies, strictement nécessaires : syllabus-session, qui vous garde connecté, et XSRF-TOKEN, qui protège les formulaires. Aucun cookie publicitaire ni de mesure d’audience. Ils ne demandent donc pas de consentement.

    -

    L’éditeur de code et la page de visio gardent aussi, dans le stockage local de votre navigateur, votre code et une préférence d’affichage. Ces informations ne nous sont pas transmises.

    +

    L’éditeur de code et la page de visio gardent aussi, dans le stockage local de votre navigateur, votre code et des préférences d’affichage (y compris celles du lecteur de visio, sur meet.syllabuslab.fr). Ces informations ne nous sont pas transmises.

    diff --git a/portail/resources/views/eleve.blade.php b/portail/resources/views/eleve.blade.php index 85bc35b..5a72c74 100644 --- a/portail/resources/views/eleve.blade.php +++ b/portail/resources/views/eleve.blade.php @@ -27,6 +27,9 @@ @csrf + @if ($seance->visioOuverte()) + Rejoindre la visio + @endif @endforeach diff --git a/portail/resources/views/seances/attente-eleve.blade.php b/portail/resources/views/seances/attente-eleve.blade.php index a4e4551..b8c2ce2 100644 --- a/portail/resources/views/seances/attente-eleve.blade.php +++ b/portail/resources/views/seances/attente-eleve.blade.php @@ -7,6 +7,9 @@ @section('contenu')

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

    + @if ($seance->visioOuverte()) +

    Rejoindre la visio de la classe

    + @endif @if ($labSession->etat === $etats::EN_ATTENTE)

    En attente que le prof t'admette…

    diff --git a/portail/resources/views/seances/attente.blade.php b/portail/resources/views/seances/attente.blade.php index 70d6f4c..8f0895f 100644 --- a/portail/resources/views/seances/attente.blade.php +++ b/portail/resources/views/seances/attente.blade.php @@ -10,6 +10,7 @@

    {{ $seance->classe->nom }} · {{ $seance->lab->titre }}

    Salle d'attente

    La page se rafraîchit toutes les 10 secondes.

    +

    {{ $seance->visioOuverte() ? 'Retourner à la visio' : 'Visio de la classe' }}

    @if ($seance->labSessions->isEmpty())

    Personne n'a encore rejoint la séance.

    diff --git a/portail/resources/views/seances/visio-eleve.blade.php b/portail/resources/views/seances/visio-eleve.blade.php new file mode 100644 index 0000000..d695db7 --- /dev/null +++ b/portail/resources/views/seances/visio-eleve.blade.php @@ -0,0 +1,100 @@ +@extends('layouts.vitrine') + +@section('titre', 'Visio · '.$seance->classe->nom) + +{{-- Étape 15 : reprend design/resources/views/visio.blade.php (vue élève) ; + l'image factice est remplacée par la vraie visio (Jitsi, IFrame API). --}} +@section('contenu') +
    +
    +
    {{ $seance->classe->nom }} · {{ $seance->lab->titre }}{{ $jeton ? 'En sourdine' : 'En attente' }}
    + @if ($jeton) +
    +
    + + + + Quitter +
    +

    Tu es en sourdine. Lève la main pour demander la parole.

    + @else +

    Ton prof n’a pas encore ouvert la visio. Garde cette page ouverte : tu entreras dès qu’il l’ouvrira.

    + @endif +
    +
    + +@if ($jeton) + +@endif + +@endsection diff --git a/portail/resources/views/seances/visio-prof.blade.php b/portail/resources/views/seances/visio-prof.blade.php new file mode 100644 index 0000000..dff4882 --- /dev/null +++ b/portail/resources/views/seances/visio-prof.blade.php @@ -0,0 +1,138 @@ +@extends('layouts.vitrine') + +@section('titre', 'Visio · '.$seance->classe->nom) + +{{-- Étape 15 : reprend design/resources/views/visio.blade.php (vue prof) ; la + grille factice est remplacée par la vraie visio (Jitsi, IFrame API). --}} +@section('contenu') +
    +
    +

    Visio de classe

    +

    {{ $seance->classe->nom }} · {{ $seance->lab->titre }}

    +

    Tous les élèves arrivent en sourdine. Ils lèvent la main, vous donnez la parole, un à la fois.

    +
    + +
    +
    +
    +
    + @if ($jeton) + En direct · 00:00:00 + @else + Visio fermée + @endif + {{ $seance->classe->nom }} +
    +
    + @if ($jeton) + +
    @csrf
    + @else +
    @csrf
    + @endif +
    +
    + @if ($jeton) +
    + @else +

    La visio n’est pas ouverte. Les élèves qui la rejoignent attendent dans la salle d’attente ; ouvrez-la pour les faire entrer.

    + @endif +
    +
    +

    Mains levées

    0

      Premier arrivé en haut.

      +

      Salle d’attente

      @unless ($jeton)
      @csrf
      @endunless
        +

        Chat des élèves

        {{ $seance->visio_chat ? 'Le chat est ouvert aux élèves.' : 'Le chat est désactivé.' }}
        +
        +
        +
        + +@if ($jeton) + +@endif + +@endsection diff --git a/portail/routes/web.php b/portail/routes/web.php index ef88f4d..47787a0 100644 --- a/portail/routes/web.php +++ b/portail/routes/web.php @@ -9,6 +9,7 @@ use App\Http\Controllers\InscriptionController; use App\Http\Controllers\RapportCspController; use App\Http\Controllers\RejoindreClasseController; use App\Http\Controllers\SeanceController; +use App\Http\Controllers\VisioController; use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken; use Illuminate\Support\Facades\Route; @@ -87,6 +88,10 @@ Route::middleware(['auth:eleve', 'session.unique:eleve'])->group(function () { 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::get('/seances/{seance}/etat-eleve', [SeanceController::class, 'etatEleve'])->name('seances.eleve.etat'); + Route::get('/seances/{seance}/visio/eleve', [VisioController::class, 'eleve'])->name('seances.visio.eleve'); + Route::get('/seances/{seance}/visio/eleve/etat', [VisioController::class, 'etatEleve'])->name('seances.visio.eleve.etat'); + Route::post('/seances/{seance}/visio/eleve/main', [VisioController::class, 'main'])->name('seances.visio.eleve.main'); + Route::post('/seances/{seance}/visio/eleve/participant', [VisioController::class, 'participant'])->name('seances.visio.eleve.participant'); Route::post('/lab-sessions/{labSession}/corriger', [CorrectionController::class, 'corriger']) ->middleware('throttle:6,1') ->name('lab-sessions.corriger'); @@ -108,6 +113,12 @@ Route::middleware(['auth:web', 'session.unique:web'])->group(function () { Route::post('/mes-classes/{classe}/desarchiver', [ClasseController::class, 'desarchiver'])->name('mes-classes.desarchiver'); 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::get('/seances/{seance}/visio', [VisioController::class, 'prof'])->name('seances.visio.prof'); + Route::post('/seances/{seance}/visio/ouvrir', [VisioController::class, 'ouvrir'])->name('seances.visio.ouvrir'); + Route::post('/seances/{seance}/visio/fermer', [VisioController::class, 'fermer'])->name('seances.visio.fermer'); + Route::get('/seances/{seance}/visio/etat', [VisioController::class, 'etatProf'])->name('seances.visio.etat'); + Route::post('/seances/{seance}/visio/eleves/{eleve}/parole', [VisioController::class, 'parole'])->name('seances.visio.parole'); + Route::post('/seances/{seance}/visio/chat', [VisioController::class, 'chat'])->name('seances.visio.chat'); Route::post('/seances/{seance}/ouvrir', [SeanceController::class, 'ouvrir'])->name('seances.ouvrir'); 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'); diff --git a/portail/tests/Feature/PagesLegalesTest.php b/portail/tests/Feature/PagesLegalesTest.php index faa005c..d47e67b 100644 --- a/portail/tests/Feature/PagesLegalesTest.php +++ b/portail/tests/Feature/PagesLegalesTest.php @@ -5,7 +5,8 @@ test('les pages légales sont en ligne et la page de statut redirige vers le sit $this->get(route('confidentialite'))->assertOk() ->assertSee('90 jours après l’archivage', false) ->assertSee('aucune adresse e-mail', false) - ->assertSee('cdn.tailwindcss.com'); + ->assertSee('cdn.tailwindcss.com') + ->assertSee('sans être enregistrés', false); $this->get(route('statut'))->assertRedirect('https://status.syllabuslab.fr'); }); diff --git a/portail/tests/Feature/VisioTest.php b/portail/tests/Feature/VisioTest.php new file mode 100644 index 0000000..299eff5 --- /dev/null +++ b/portail/tests/Feature/VisioTest.php @@ -0,0 +1,139 @@ + 'meet.test', 'visio.jwt.secret' => str_repeat('s', 64)]); +}); + +function seanceAvecClasse(): Seance +{ + $classe = Classe::factory()->create(); + + return Seance::create(['classe_id' => $classe->id, 'lab_id' => Lab::factory()->create()->id, 'statut' => Seance::SALLE_ATTENTE, 'demarree_a' => now()]); +} + +function prof($test, User $prof) +{ + return $test->withSession(['jeton_session_web' => $prof->demarreNouvelleSession()])->actingAs($prof, 'web'); +} + +function eleve($test, Eleve $eleve) +{ + return $test->withSession(['jeton_session_eleve' => $eleve->demarreNouvelleSession()])->actingAs($eleve, 'eleve'); +} + +/** Décode un JWT HS256 en vérifiant sa signature, comme le fait Prosody. */ +function decoderJeton(string $jeton): array +{ + [$entete, $corps, $signature] = explode('.', $jeton); + $attendue = rtrim(strtr(base64_encode(hash_hmac('sha256', "{$entete}.{$corps}", str_repeat('s', 64), true)), '+/', '-_'), '='); + expect(hash_equals($attendue, $signature))->toBeTrue(); + + return json_decode(base64_decode(strtr($corps, '-_', '+/')), true); +} + +test('le jeton est signé, limité à la salle, et seul le prof est modérateur', function () { + $seance = seanceAvecClasse(); + $seance->update(['visio_ouverte_a' => now(), 'visio_salle' => 'syllabus-1-abc']); + + $prof = decoderJeton(app(JetonVisio::class)->pour($seance, 'prof-1', 'M. Prof', true)); + $eleve = decoderJeton(app(JetonVisio::class)->pour($seance, 'eleve-1', 'Léa M.', false)); + + expect($prof['room'])->toBe('syllabus-1-abc') + ->and($prof['iss'])->toBe('syllabus')->and($prof['aud'])->toBe('syllabus-visio') + ->and($prof['context']['user']['moderator'])->toBeTrue() + ->and($eleve['context']['user']['moderator'])->toBeFalse() + ->and($eleve['context']['user']['name'])->toBe('Léa M.') + ->and($eleve['exp'])->toBeGreaterThan(time()); +}); + +test('le prof ouvre la visio ; un autre prof ne peut ni l’ouvrir ni la voir', function () { + $seance = seanceAvecClasse(); + + prof($this, User::factory()->create())->post(route('seances.visio.ouvrir', $seance))->assertForbidden(); + prof($this, $seance->classe->prof)->post(route('seances.visio.ouvrir', $seance))->assertRedirect(route('seances.visio.prof', $seance)); + + $seance->refresh(); + expect($seance->visioOuverte())->toBeTrue()->and($seance->visio_salle)->toMatch('/^syllabus-\d+-[a-z0-9]{16}$/'); + + $page = prof($this, $seance->classe->prof)->get(route('seances.visio.prof', $seance))->assertOk()->assertSee('https://meet.test/external_api.js', false); + preg_match('/jwt: "([^"]+)"/', $page->getContent(), $m); + expect(decoderJeton($m[1])['context']['user']['moderator'])->toBeTrue(); + expect($page->headers->get('Content-Security-Policy'))->toContain('frame-src \'self\' https://meet.test'); +}); + +test('un élève de la classe attend, puis entre sans être modérateur, avec un nom non modifiable ; un élève d’une autre classe est refusé', function () { + $seance = seanceAvecClasse(); + $lea = Eleve::factory()->create(['classe_id' => $seance->classe_id, 'prenom' => 'Léa', 'nom' => 'Martin']); + + eleve($this, $lea)->get(route('seances.visio.eleve', $seance))->assertOk()->assertSee('pas encore ouvert la visio', false)->assertDontSee('jwt:', false); + + $seance->update(['visio_ouverte_a' => now(), 'visio_salle' => 'syllabus-x']); + $page = eleve($this, $lea)->get(route('seances.visio.eleve', $seance))->assertOk()->assertSee('readOnlyName: true', false); + preg_match('/jwt: "([^"]+)"/', $page->getContent(), $m); + $jeton = decoderJeton($m[1]); + expect($jeton['context']['user']['moderator'])->toBeFalse()->and($jeton['context']['user']['name'])->toBe('Léa M.'); + + $intrus = Eleve::factory()->create(); + eleve($this, $intrus)->get(route('seances.visio.eleve', $seance))->assertForbidden(); +}); + +test('les mains levées arrivent chez le prof dans l’ordre ; donner la parole baisse la main', function () { + $seance = seanceAvecClasse(); + $seance->update(['visio_ouverte_a' => now(), 'visio_salle' => 'syllabus-x']); + $a = Eleve::factory()->create(['classe_id' => $seance->classe_id, 'prenom' => 'Yanis', 'nom' => 'B']); + $b = Eleve::factory()->create(['classe_id' => $seance->classe_id, 'prenom' => 'Adam', 'nom' => 'D']); + + eleve($this, $a)->postJson(route('seances.visio.eleve.participant', $seance), ['participant_id' => 'abc123']); + eleve($this, $a)->postJson(route('seances.visio.eleve.main', $seance), ['levee' => true]); + $this->travel(2)->seconds(); + eleve($this, $b)->postJson(route('seances.visio.eleve.main', $seance), ['levee' => true]); + + $etat = prof($this, $seance->classe->prof)->getJson(route('seances.visio.etat', $seance))->json(); + expect(collect($etat['mains'])->pluck('nom')->all())->toBe(['Yanis B.', 'Adam D.']) + ->and($etat['mains'][0]['participant_id'])->toBe('abc123'); + + prof($this, $seance->classe->prof)->postJson(route('seances.visio.parole', [$seance, $a]))->assertOk(); + expect(collect(prof($this, $seance->classe->prof)->getJson(route('seances.visio.etat', $seance))->json('mains'))->pluck('nom')->all())->toBe(['Adam D.']) + ->and(eleve($this, $a)->getJson(route('seances.visio.eleve.etat', $seance))->json('main_levee'))->toBeFalse(); +}); + +test('la salle d’attente montre les élèves présents tant que la visio est fermée', function () { + $seance = seanceAvecClasse(); + $lea = Eleve::factory()->create(['classe_id' => $seance->classe_id, 'prenom' => 'Léa', 'nom' => 'M']); + + eleve($this, $lea)->getJson(route('seances.visio.eleve.etat', $seance))->assertJson(['ouverte' => false]); + + expect(prof($this, $seance->classe->prof)->getJson(route('seances.visio.etat', $seance))->json('attente'))->toBe([['nom' => 'Léa M.']]); +}); + +test('les scripts des pages de visio sont du JavaScript valide', function () { + if (Process::run('node --version')->failed()) { + $this->markTestSkipped('Node absent'); + } + $seance = seanceAvecClasse(); + $seance->update(['visio_ouverte_a' => now(), 'visio_salle' => 'syllabus-x']); + $lea = Eleve::factory()->create(['classe_id' => $seance->classe_id]); + + $pages = [ + prof($this, $seance->classe->prof)->get(route('seances.visio.prof', $seance))->getContent(), + eleve($this, $lea)->get(route('seances.visio.eleve', $seance))->getContent(), + ]; + foreach ($pages as $html) { + preg_match_all('#]*\bsrc=)[^>]*>(.*?)#s', $html, $scripts); + foreach ($scripts[1] as $code) { + $fichier = tempnam(sys_get_temp_dir(), 'js').'.js'; + file_put_contents($fichier, $code); + $r = Process::run(['node', '--check', $fichier]); + unlink($fichier); + expect($r->successful())->toBeTrue($r->errorOutput()); + } + } +}); diff --git a/portail/tests/Reels/preparer-visio.php b/portail/tests/Reels/preparer-visio.php new file mode 100644 index 0000000..76ee1cf --- /dev/null +++ b/portail/tests/Reels/preparer-visio.php @@ -0,0 +1,31 @@ + 'Lycée Test Visio', 'ville' => 'Test']); +$motDePasse = Str::password(24, symbols: false); +$prof = User::create(['name' => 'Prof Visio', 'email' => 'prof-visio@syllabus.test', 'password' => $motDePasse, 'etablissement_id' => $etablissement->id]); +$classe = Classe::create(['etablissement_id' => $etablissement->id, 'user_id' => $prof->id, 'nom' => 'Classe Test Visio', 'formation' => 'Test']); +$eleves = []; +foreach ([['Lea', 'Martin', 'lea.visio'], ['Yanis', 'Bernard', 'yanis.visio']] as [$prenom, $nom, $identifiant]) { + $code = Str::upper(Str::random(6)); + Eleve::create(['classe_id' => $classe->id, 'prenom' => $prenom, 'nom' => $nom, 'identifiant' => $identifiant, 'code_secret' => Hash::make($code), 'jeton_qr' => Str::random(32)]); + $eleves[] = ['identifiant' => $identifiant, 'code' => $code]; +} +$seance = Seance::create(['classe_id' => $classe->id, 'lab_id' => Lab::where('identifiant', '08-deux-lan-routeur')->value('id'), 'statut' => Seance::SALLE_ATTENTE, 'demarree_a' => now()]); + +$fichier = '/tmp/visio-identifiants.json'; +file_put_contents($fichier, json_encode(['prof' => ['email' => $prof->email, 'mot_de_passe' => $motDePasse], 'eleves' => $eleves, 'seance_id' => $seance->id])); +chmod($fichier, 0600); +echo "données de test prêtes (séance {$seance->id})\n"; diff --git a/portail/tests/Reels/visio-e2e.mjs b/portail/tests/Reels/visio-e2e.mjs new file mode 100644 index 0000000..0ea2164 --- /dev/null +++ b/portail/tests/Reels/visio-e2e.mjs @@ -0,0 +1,91 @@ +// Critère de l'étape 15, dans de vrais navigateurs (Chromium, caméra et micro +// simulés) : un prof et deux élèves rejoignent la visio ; un élève lève la +// main ; le prof lui donne la parole ; un intrus sans jeton ne peut pas entrer. +// node visio-e2e.mjs [https://syllabuslab.fr] +import { chromium } from 'playwright'; +import { readFileSync } from 'node:fs'; + +const ids = JSON.parse(readFileSync(process.argv[2], 'utf8')); +const BASE = process.argv[3] ?? 'https://syllabuslab.fr'; +const S = ids.seance_id; +const navigateur = await chromium.launch({ args: ['--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream'] }); +const contexte = () => navigateur.newContext({ locale: 'fr-FR', permissions: ['camera', 'microphone'] }); +const etape = (texte) => console.log(`${new Date().toISOString().slice(11, 19)} ${texte}`); +const attendre = async (fn, delai = 30000, pas = 500) => { + const fin = Date.now() + delai; + while (Date.now() < fin) { const r = await fn(); if (r) return r; await new Promise((ok) => setTimeout(ok, pas)); } + throw new Error('délai dépassé'); +}; + +async function connexion(page, champs, bouton) { + await page.goto(`${BASE}/connexion`); + for (const [nom, valeur] of Object.entries(champs)) await page.fill(`input[name="${nom}"]`, valeur); + await page.click(bouton); + await page.waitForLoadState('networkidle'); +} + +try { + // 1. Le prof ouvre la visio. + const prof = await (await contexte()).newPage(); + await connexion(prof, { email: ids.prof.email, password: ids.prof.mot_de_passe }, 'form[action$="/connexion/prof"] button[type=submit]'); + await prof.goto(`${BASE}/seances/${S}/visio`); + if (await prof.locator('text=Ouvrir la visio').count()) await prof.click('text=Ouvrir la visio'); + await prof.waitForSelector('#v-scene iframe', { timeout: 30000 }); + etape('prof : visio ouverte, Jitsi chargé'); + + // 2. Deux élèves la rejoignent. + const eleves = []; + for (const e of ids.eleves) { + const page = await (await contexte()).newPage(); + await connexion(page, { identifiant: e.identifiant, code: e.code }, 'form[action$="/connexion/eleve"] button[type=submit]'); + await page.goto(`${BASE}/seances/${S}/visio/eleve`); + await page.waitForSelector('#v-scene iframe', { timeout: 30000 }); + eleves.push(page); + } + const enLigne = await attendre(async () => { + const t = await prof.textContent('#v-nb'); + return /3 en ligne/.test(t ?? '') && t; + }, 60000); + etape(`prof : ${enLigne.trim()} (le prof et les deux élèves)`); + + // 3. Léa lève la main ; elle apparaît chez le prof. + const [lea, yanis] = eleves; + await lea.click('[data-main-e]'); + await attendre(async () => (await prof.locator('#v-mains [data-parole]').count()) === 1, 30000); + etape(`prof : main levée reçue de « ${(await prof.locator('#v-mains strong').first().textContent())} »`); + + // 4. Yanis, sans la parole, ne peut pas réactiver son micro. + await yanis.click('[data-micro-e]'); + await new Promise((ok) => setTimeout(ok, 4000)); + const statutYanis = (await yanis.textContent('[data-statut-e]')).trim(); + etape(`Yanis tente d'ouvrir son micro sans la parole → « ${statutYanis} »`); + + // 5. Le prof donne la parole à Léa : elle peut ouvrir son micro. + await prof.click('#v-mains [data-parole]'); + await attendre(async () => (await lea.getAttribute('[data-main-e]', 'aria-pressed')) === 'false', 30000); + etape('Léa : sa main est baissée (parole donnée)'); + await lea.click('[data-micro-e]'); + const statutLea = await attendre(async () => { + const t = (await lea.textContent('[data-statut-e]')).trim(); + return t === 'Tu as la parole' && t; + }, 30000); + etape(`Léa ouvre son micro → « ${statutLea} »`); + + // 6. Un intrus, sans jeton, tente d'entrer directement dans la salle. + const salle = await prof.evaluate(() => document.querySelector('#v-scene iframe').src.split('?')[0].split('#')[0].split('/').pop()); + const intrus = await (await contexte()).newPage(); + await intrus.goto(`${BASE.replace('://', '://meet.')}/${salle}`); + await new Promise((ok) => setTimeout(ok, 12000)); + const toujours = (await prof.textContent('#v-nb')).trim(); + const ecranIntrus = (await intrus.locator('body').innerText()).replace(/\s+/g, ' ').slice(0, 160); + etape(`intrus sans jeton : le prof voit toujours « ${toujours} » ; écran de l'intrus : « ${ecranIntrus} »`); + + const ok = /3 en ligne/.test(toujours) && statutYanis === 'En sourdine' && statutLea === 'Tu as la parole'; + console.log(ok ? 'CRITÈRE DE L’ÉTAPE 15 : RÉUSSI' : 'CRITÈRE DE L’ÉTAPE 15 : ÉCHOUÉ'); + process.exitCode = ok ? 0 : 1; +} catch (e) { + console.error('ÉCHEC :', e.message); + process.exitCode = 1; +} finally { + await navigateur.close(); +}