diff --git a/ansible/roles/portail/defaults/main.yml b/ansible/roles/portail/defaults/main.yml index bc188d2..b70ae4e 100644 --- a/ansible/roles/portail/defaults/main.yml +++ b/ansible/roles/portail/defaults/main.yml @@ -19,3 +19,8 @@ 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 +# Bloc A (ADR 0006) : relais TURN sur 443 pour la visio (réseaux scolaires qui +# bloquent l'UDP). Dans le certificat seulement — jamais un vhost HTTPS : le +# routeur par nom (stream Nginx) l'envoie directement à coturn. +portail_domaine_turn: turn.syllabuslab.fr +portail_port_turn_tls: 5349 diff --git a/ansible/roles/portail/tasks/05-nginx.yml b/ansible/roles/portail/tasks/05-nginx.yml index fce0e6f..88b70f8 100644 --- a/ansible/roles/portail/tasks/05-nginx.yml +++ b/ansible/roles/portail/tasks/05-nginx.yml @@ -32,14 +32,22 @@ changed_when: false failed_when: false -- name: noter si la page de statut et la visio peuvent être activées +- name: vérifier le DNS du sous-domaine du relais TURN + ansible.builtin.command: getent ahostsv4 {{ portail_domaine_turn }} + register: portail_dns_turn + changed_when: false + failed_when: false + +- name: noter si la page de statut, la visio et le relais TURN peuvent être activés 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_turn_actif: "{{ portail_ip_publique in portail_dns_turn.stdout }}" portail_noms_certificat: >- {{ [portail_domaine] + portail_domaines_alias + ([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 []) }} + + ([portail_domaine_visio] if portail_ip_publique in portail_dns_visio.stdout else []) + + ([portail_domaine_turn] if portail_ip_publique in portail_dns_turn.stdout else []) }} - name: vérifier si le certificat existe déjà ansible.builtin.stat: diff --git a/ansible/roles/portail/tasks/07-turn-stream.yml b/ansible/roles/portail/tasks/07-turn-stream.yml new file mode 100644 index 0000000..e097300 --- /dev/null +++ b/ansible/roles/portail/tasks/07-turn-stream.yml @@ -0,0 +1,37 @@ +--- +# Bloc A (ADR 0006) : partage du port 443 entre le portail et le relais TURN +# (coturn, rôle turn), par nom de domaine (SNI), sans jamais déchiffrer le +# TLS existant. N'ouvre aucun nouveau port dans nftables : toujours 443. +- name: installer le module stream de Nginx + ansible.builtin.apt: + name: libnginx-mod-stream + state: present + when: portail_turn_actif | default(false) + notify: recharger nginx + +- name: router le port 443 public par nom (stream Nginx) + ansible.builtin.blockinfile: + path: /etc/nginx/nginx.conf + marker: "# {mark} ANSIBLE MANAGED BLOCK — bloc A, ADR 0006, relais TURN" + insertafter: EOF + block: | + # Le vrai TLS (nos vhosts HTTPS, désormais sur 127.0.0.1:8443) et + # coturn (127.0.0.1:{{ portail_port_turn_tls }}) se partagent le port public + # 443 : ce bloc regarde seulement le nom demandé dans la poignée de + # main TLS (SNI), jamais son contenu déchiffré, puis transmet les + # octets tels quels (aucun des deux ne voit l'autre). + stream { + map $ssl_preread_server_name $syllabus_stream_backend { + {{ portail_domaine_turn }} 127.0.0.1:{{ portail_port_turn_tls }}; + default 127.0.0.1:8443; + } + + server { + listen 443; + listen [::]:443; + ssl_preread on; + proxy_pass $syllabus_stream_backend; + } + } + when: portail_turn_actif | default(false) + notify: recharger nginx diff --git a/ansible/roles/portail/tasks/main.yml b/ansible/roles/portail/tasks/main.yml index a8828df..f8947e3 100644 --- a/ansible/roles/portail/tasks/main.yml +++ b/ansible/roles/portail/tasks/main.yml @@ -14,5 +14,8 @@ - name: Nginx ansible.builtin.import_tasks: 05-nginx.yml +- name: relais TURN sur 443 (bloc A, ADR 0006) + ansible.builtin.import_tasks: 07-turn-stream.yml + - name: file et planificateur (jobs Heat, expiration des sessions) ansible.builtin.import_tasks: 06-queue-scheduler.yml diff --git a/ansible/roles/portail/templates/nginx-portail.conf.j2 b/ansible/roles/portail/templates/nginx-portail.conf.j2 index a36b19e..411087a 100644 --- a/ansible/roles/portail/templates/nginx-portail.conf.j2 +++ b/ansible/roles/portail/templates/nginx-portail.conf.j2 @@ -1,4 +1,16 @@ # Géré par Ansible (rôle portail) — ne pas modifier à la main. +{# Bloc A (ADR 0006) : quand le relais TURN est actif, le port public 443 + est pris par le routeur Nginx par nom (stream, nginx.conf) — ces vhost + HTTPS n'écoutent alors plus qu'en interne, sur 127.0.0.1:8443, où ce + routeur leur transmet tout ce qui n'est pas pour coturn. #} +{%- macro listen443(default_server=False) -%} +{%- if portail_turn_actif | default(false) %} + listen 127.0.0.1:8443 ssl http2{{ ' default_server' if default_server }}; +{%- else %} + listen 443 ssl http2{{ ' default_server' if default_server }}; + listen [::]:443 ssl http2{{ ' default_server' if default_server }}; +{%- endif -%} +{% endmacro -%} {% macro bloc_portail() %} root {{ portail_dossier_app }}/public; index index.php; @@ -40,7 +52,7 @@ server { listen 80; listen [::]:80; - 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_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 %}{% if portail_turn_actif | default(false) %} {{ portail_domaine_turn }}{% 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. @@ -61,8 +73,7 @@ server { # www → domaine principal (une seule adresse canonique). server { - listen 443 ssl http2; - listen [::]:443 ssl http2; +{{ listen443() }} server_name {{ portail_domaines_alias | join(' ') }}; server_tokens off; include snippets/syllabus-tls.conf; @@ -71,8 +82,7 @@ server { } server { - listen 443 ssl http2 default_server; - listen [::]:443 ssl http2 default_server; +{{ listen443(default_server=True) }} server_name {{ portail_domaine }}; server_tokens off; include snippets/syllabus-tls.conf; @@ -85,8 +95,7 @@ server { # (tableau de bord, /socket.io, /metrics…) reste accessible par tunnel SSH # uniquement. Chemins relevés dans server/routers/status-page-router.js. server { - listen 443 ssl http2; - listen [::]:443 ssl http2; +{{ listen443() }} server_name {{ portail_domaine_statut }}; server_tokens off; include snippets/syllabus-tls.conf; @@ -123,8 +132,7 @@ server { # 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; +{{ listen443() }} server_name {{ portail_domaine_visio }}; server_tokens off; include snippets/syllabus-tls.conf; diff --git a/ansible/roles/turn/defaults/main.yml b/ansible/roles/turn/defaults/main.yml new file mode 100644 index 0000000..b3d233e --- /dev/null +++ b/ansible/roles/turn/defaults/main.yml @@ -0,0 +1,10 @@ +--- +# Bloc A (ADR 0006) : relais TURN sur 443 pour la visio, réservé aux réseaux +# scolaires qui bloquent l'UDP. coturn en paquet système (pas Docker : un +# seul process à durcir, pas de souci de réseau Docker comme avec Jitsi/Kuma). +turn_secret_fichier: /etc/turnserver-secret +turn_port_tls: 5349 +turn_realm: syllabuslab.fr +# Nom du certificat Let's Encrypt (rôle portail, portail_domaine) : dupliqué +# ici plutôt que de dépendre de ce rôle, pour que `turn` reste autonome. +turn_domaine_certificat: syllabuslab.fr diff --git a/ansible/roles/turn/handlers/main.yml b/ansible/roles/turn/handlers/main.yml new file mode 100644 index 0000000..3b2e9fc --- /dev/null +++ b/ansible/roles/turn/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: redémarrer coturn + ansible.builtin.systemd: + name: coturn + state: restarted diff --git a/ansible/roles/turn/tasks/main.yml b/ansible/roles/turn/tasks/main.yml new file mode 100644 index 0000000..edf21e5 --- /dev/null +++ b/ansible/roles/turn/tasks/main.yml @@ -0,0 +1,79 @@ +--- +# Bloc A (ADR 0006) : relais TURN sur 443 pour la visio. coturn n'écoute +# qu'en TLS, sur 127.0.0.1 : jamais directement sur l'IP publique — c'est le +# routeur Nginx par nom (rôle portail) qui lui amène le trafic du port 443. +- name: installer coturn + ansible.builtin.apt: + name: coturn + state: present + +- name: vérifier si le secret existe déjà + ansible.builtin.stat: + path: "{{ turn_secret_fichier }}" + register: turn_secret_stat + +- name: générer le secret partagé une seule fois + ansible.builtin.command: openssl rand -hex 32 + register: turn_secret_genere + changed_when: false + when: not turn_secret_stat.stat.exists + +- name: déposer le secret (une seule fois) + ansible.builtin.copy: + content: "{{ turn_secret_genere.stdout }}\n" + dest: "{{ turn_secret_fichier }}" + owner: root + group: root + mode: "0600" + when: not turn_secret_stat.stat.exists + +- name: lire le secret + ansible.builtin.slurp: + src: "{{ turn_secret_fichier }}" + register: turn_secret_lu + no_log: true + +- name: créer le dossier de la copie lisible du certificat + ansible.builtin.file: + path: /etc/turnserver/cert + state: directory + owner: root + group: turnserver + mode: "0750" + +- name: déployer le script de copie du certificat (Ansible + renouvellement certbot) + ansible.builtin.template: + src: turn-copier-certificat.sh.j2 + dest: /usr/local/sbin/syllabus-turn-copier-certificat + owner: root + group: root + mode: "0750" + +- name: brancher ce script sur le renouvellement automatique du certificat + ansible.builtin.file: + src: /usr/local/sbin/syllabus-turn-copier-certificat + dest: /etc/letsencrypt/renewal-hooks/deploy/90-turn-coturn.sh + state: link + +- name: déployer la configuration de coturn + ansible.builtin.template: + src: turnserver.conf.j2 + dest: /etc/turnserver.conf + owner: root + group: turnserver + mode: "0640" + vars: + turn_secret: "{{ turn_secret_lu.content | b64decode | trim }}" + notify: redémarrer coturn + +- name: activer coturn + ansible.builtin.systemd: + name: coturn + enabled: true + +- name: appliquer la configuration et la copie du certificat + ansible.builtin.meta: flush_handlers + +- name: copier le certificat pour coturn et le démarrer avec la vraie configuration + ansible.builtin.command: /usr/local/sbin/syllabus-turn-copier-certificat + changed_when: true diff --git a/ansible/roles/turn/templates/turn-copier-certificat.sh.j2 b/ansible/roles/turn/templates/turn-copier-certificat.sh.j2 new file mode 100644 index 0000000..201086e --- /dev/null +++ b/ansible/roles/turn/templates/turn-copier-certificat.sh.j2 @@ -0,0 +1,10 @@ +#!/bin/bash +# Géré par Ansible (rôle turn) — ne pas modifier à la main. +# Copie le certificat Let's Encrypt dans un endroit lisible par coturn (sa +# clé privée originale reste 600 root:root, hors de portée de turnserver) et +# redémarre coturn. Lancé par Ansible (premier déploiement) et par certbot à +# chaque renouvellement (/etc/letsencrypt/renewal-hooks/deploy/). +set -euo pipefail +install -o root -g turnserver -m 0640 /etc/letsencrypt/live/{{ turn_domaine_certificat }}/fullchain.pem /etc/turnserver/cert/fullchain.pem +install -o root -g turnserver -m 0640 /etc/letsencrypt/live/{{ turn_domaine_certificat }}/privkey.pem /etc/turnserver/cert/privkey.pem +systemctl restart coturn diff --git a/ansible/roles/turn/templates/turnserver.conf.j2 b/ansible/roles/turn/templates/turnserver.conf.j2 new file mode 100644 index 0000000..34b0776 --- /dev/null +++ b/ansible/roles/turn/templates/turnserver.conf.j2 @@ -0,0 +1,29 @@ +# Géré par Ansible (rôle turn) — ne pas modifier à la main. +# TURNS seulement (TLS), sur 127.0.0.1 : jamais exposé directement à +# Internet. Le port public 443 y arrive par le routeur Nginx (SNI, rôle +# portail), qui transmet les octets tels quels — coturn gère son propre TLS. +listening-ip=127.0.0.1 +tls-listening-port={{ turn_port_tls }} +no-udp +no-tcp +no-tls-v1 +no-tls-v1_1 + +# Relais vers JVB (rôle visio, réseau de l'hôte) : jamais vers l'extérieur, +# toujours en boucle locale. +relay-ip=127.0.0.1 + +cert=/etc/turnserver/cert/fullchain.pem +pkey=/etc/turnserver/cert/privkey.pem + +# Authentification par secret partagé (TURN REST API), le même que +# TURN_CREDENTIALS côté Prosody (rôle visio) : Prosody calcule un couple +# nom d'utilisateur/mot de passe valable 24 h pour chaque participant, sans +# jamais donner ce secret au navigateur. +use-auth-secret +static-auth-secret={{ turn_secret }} +realm={{ turn_realm }} + +fingerprint +no-cli +no-multicast-peers diff --git a/ansible/roles/visio/defaults/main.yml b/ansible/roles/visio/defaults/main.yml index fc7f65f..c593c06 100644 --- a/ansible/roles/visio/defaults/main.yml +++ b/ansible/roles/visio/defaults/main.yml @@ -3,6 +3,12 @@ visio_version: stable-11248 visio_domaine: meet.syllabuslab.fr visio_dossier: /opt/visio +# Bloc A (ADR 0006) : relais TURN sur 443, activé seulement si le rôle `turn` +# est déjà passé sur ce serveur (son secret existe). Nom dupliqué depuis +# `portail_domaine_turn`/`turn_domaine_certificat`, pour que `visio` reste +# autonome (même principe que pour `turn_domaine_certificat`). +visio_domaine_turn: turn.syllabuslab.fr +visio_turn_secret_fichier: /etc/turnserver-secret visio_port_web: 8090 visio_ip_publique: 163.172.4.195 visio_port_media: 10000 diff --git a/ansible/roles/visio/tasks/main.yml b/ansible/roles/visio/tasks/main.yml index 3058877..054f727 100644 --- a/ansible/roles/visio/tasks/main.yml +++ b/ansible/roles/visio/tasks/main.yml @@ -45,6 +45,33 @@ JWT_APP_SECRET={{ lookup('community.general.random_string', length=64, special=false) }} no_log: true +# Bloc A (ADR 0006) : le relais TURN est un rôle à part (coturn, pas +# Docker) ; ici on se contente de lire s'il existe déjà et de transmettre +# son secret à Prosody — jamais l'inverse (le rôle turn ne connaît pas la visio). +- name: vérifier si le relais TURN est déjà configuré sur ce serveur + ansible.builtin.stat: + path: "{{ visio_turn_secret_fichier }}" + register: visio_turn_secret_stat + +- name: noter si le relais TURN peut être annoncé aux participants + ansible.builtin.set_fact: + visio_turn_actif: "{{ visio_turn_secret_stat.stat.exists }}" + +- name: lire le secret du relais TURN + ansible.builtin.slurp: + src: "{{ visio_turn_secret_fichier }}" + register: visio_turn_secret_lu + when: visio_turn_actif + no_log: true + +- name: transmettre le secret du relais TURN à Prosody + ansible.builtin.lineinfile: + path: /etc/visio/secrets.env + regexp: "^TURN_CREDENTIALS=" + line: "TURN_CREDENTIALS={{ visio_turn_secret_lu.content | b64decode | trim }}" + when: visio_turn_actif + no_log: true + - name: déployer la configuration de la visio ansible.builtin.template: src: visio.env.j2 diff --git a/ansible/roles/visio/templates/visio.env.j2 b/ansible/roles/visio/templates/visio.env.j2 index e6a5786..1a89334 100644 --- a/ansible/roles/visio/templates/visio.env.j2 +++ b/ansible/roles/visio/templates/visio.env.j2 @@ -40,5 +40,17 @@ TOOLBAR_BUTTONS=microphone,camera,desktop,raisehand,participants-pane,tileview,s JVB_ADVERTISE_IPS={{ visio_ip_publique }} JVB_PORT={{ visio_port_media }} +{% if visio_turn_actif | default(false) %} + +# Bloc A (ADR 0006) : relais TURN sur 443 pour les réseaux scolaires qui +# bloquent l'UDP. Prosody calcule lui-même, pour chaque participant, un +# couple nom d'utilisateur/mot de passe valable 24 h (TURN_CREDENTIALS, +# secret partagé avec coturn — dans secrets.env, jamais ici). Lu directement +# dans prosody.cfg.lua (confirmé dans le fichier source de l'image, pas +# deviné) : aucun module ni plugin personnalisé à écrire. +TURNS_HOST={{ visio_domaine_turn }} +TURNS_PORT=443 +TURN_TTL=86400 +{% endif %} VIDEOBRIDGE_MAX_MEMORY={{ visio_memoire_jvb }} JICOFO_MAX_MEMORY={{ visio_memoire_jicofo }} diff --git a/ansible/turn.yml b/ansible/turn.yml new file mode 100644 index 0000000..fdcbdd7 --- /dev/null +++ b/ansible/turn.yml @@ -0,0 +1,8 @@ +--- +# Bloc A (ADR 0006) : relais TURN sur 443 pour la visio. +- name: Relais TURN pour la visio + hosts: syllabus + become: true + gather_facts: true + roles: + - turn diff --git a/docs/adr/0006-turn-443-visio.md b/docs/adr/0006-turn-443-visio.md index 7f5974f..2c5037c 100644 --- a/docs/adr/0006-turn-443-visio.md +++ b/docs/adr/0006-turn-443-visio.md @@ -1,7 +1,7 @@ # ADR 0006 — Relais TURN pour la visio, sur le port 443 -- **Date** : 2026-10-12 -- **Statut** : proposé (attend le DNS et l'accord d'Adam — pas encore appliqué) +- **Date** : 2026-10-12, appliqué le 2026-10-13 +- **Statut** : accepté et en place ## Contexte diff --git a/docs/dette.md b/docs/dette.md index da332af..b22c11a 100644 --- a/docs/dette.md +++ b/docs/dette.md @@ -25,7 +25,6 @@ retirer la ligne (et le noter dans le journal) quand c'est fait. | 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 | | **`sudo` sans mot de passe pour `adam`** (`/etc/sudoers.d/90-adam`) | La clé SSH de Claude Code (sans passphrase) donne un accès root complet. Retrait proposé à l'étape 16 ; gardé par choix d'Adam pendant le bloc A. Retrait : vérifier son mot de passe (`sudo -k && sudo -v`), garder la session ouverte, `sudo rm /etc/sudoers.d/90-adam`, puis playbooks avec `-K`. | Fin du bloc A | Étape 16 | -| **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 | | Self-healing : pas d'alerte Prometheus dédiée | `syllabus_vm_panne_generale` et `syllabus_vm_irreparables` sont publiées mais sans règle d'alerte ; seul le prof voit une VM irréparable dans la salle d'attente. | Avant le pilote | 2026-10-12 | diff --git a/docs/journal.md b/docs/journal.md index f7267a9..594d764 100644 --- a/docs/journal.md +++ b/docs/journal.md @@ -848,3 +848,42 @@ Adam a demandé d'aller plus loin dans la fidélité à `/cockpit`. Trois ajouts **Suite** Chantier « vrai cockpit », étape 2 : Quiz flash. + +## 2026-10-13 — ADR 0006 appliqué : relais TURN sur 443 pour la visio + +DNS `turn.syllabuslab.fr` créé par Adam (après un premier essai avec le même piège qu'avec `status` à l'étape 12 : champ « Sous-domaine » rempli avec le nom complet au lieu du seul sous-domaine — corrigé). + +**Fait** + +- **Certificat étendu** au nom `turn.syllabuslab.fr` (même mécanisme que `status`/`meet` : détection DNS, `--expand`), sans toucher à la structure de Nginx — port 80 seulement à ce stade. +- **coturn** (rôle `turn`, nouveau, paquet système — pas Docker, pour éviter les soucis déjà vus avec Kuma/Jitsi) : TLS seulement (`no-udp`, `no-tcp`), sur `127.0.0.1:5349`. Secret partagé généré une fois (`/etc/turnserver-secret`), copie lisible du certificat régénérée à chaque renouvellement (`/etc/letsencrypt/renewal-hooks/deploy/90-turn-coturn.sh`). +- **Partage du port 443** (rôle `portail`, `07-turn-stream.yml`) : un bloc `stream` Nginx (module `libnginx-mod-stream`, installé) lit le nom demandé dans la poignée de main TLS (SNI, jamais déchiffrée) et route `turn.syllabuslab.fr` vers coturn, tout le reste vers les vhosts HTTPS existants — désormais sur `127.0.0.1:8443` au lieu du port public. **Aucun nouveau port dans nftables.** +- **Prosody** (rôle `visio`) : `TURNS_HOST`/`TURNS_PORT`/`TURN_TTL` dans `visio.env`, `TURN_CREDENTIALS` (même secret que coturn) dans `secrets.env` — lu nativement par `prosody.cfg.lua` (confirmé dans le fichier source de l'image, pas deviné), aucun plugin personnalisé. +- **Appliqué avec le filet habituel** : sauvegarde des deux fichiers Nginx touchés, `nginx -t` avant tout rechargement (déjà intégré au handler), restauration automatique programmée à 5 minutes, désarmée après vérification réussie (site, meet, status, pare-feu). + +**Deux incidents, traités tout de suite (post-mortem complet : `docs/postmortems/2026-10-13-secret-turn-affiche.md`)** + +1. J'ai affiché par erreur le secret partagé TURN en clair en vérifiant la configuration générée de Prosody. Traité comme compromis immédiatement (même règle que le 2026-10-08) : le classificateur de permissions m'a refusé la régénération, Adam a lancé la rotation lui-même. +2. En reprenant après la rotation, j'ai lancé plusieurs `ansible-playbook` concurrents vers le même serveur sans vérifier que le précédent tournait encore ; les tuer a fait planter un worker d'un autre (ils partagent la connexion SSH). Rien de cassé — vérifié l'état réel avant de relancer une seule fois, jusqu'au bout. + +**Critère vérifié** + +``` +nmap -Pn -T4 --open 163.172.4.195 : 22, 80, 443 seulement (les autres « ouverts » occasionnels + — 21, 554, 1723 — viennent d'un équipement de l'hébergeur, confirmé par les compteurs nftables + à zéro sur ces ports, même repéré à l'étape 12) +turnutils_uclient -t -S -p 443 -W turn.syllabuslab.fr : poignée de main TLS 1.2/1.3 + réussie, réponse binaire STUN/TURN (jamais ce qu'un serveur HTTP renverrait) +Empreintes sha256 du secret identiques des deux côtés (coturn, Prosody) — jamais le secret lui-même +syllabuslab.fr / meet / status : toujours 200/302 après la restructuration de Nginx +``` + +Reste à faire avant le pilote (noté dans le runbook) : un vrai test avec un navigateur dont l'UDP est bloqué (vérifier la candidate ICE `relay` dans les statistiques WebRTC), et rejouer `visio-e2e.mjs`. + +**Comprendre** + +Le choix qui a le plus simplifié ce chantier n'était pas technique mais de méthode : chaque étape risquée (étendre le certificat, installer coturn, router le port 443) a été faite et vérifiée séparément, dans un ordre qui limite le risque à chaque fois — rien d'irréversible n'a été tenté avant que l'étape précédente soit prouvée saine. Le routage par nom lui-même repose sur une idée simple une fois comprise : une poignée de main TLS commence par annoncer son destinataire en clair (le SNI), avant tout chiffrement — on peut donc aiguiller sans jamais ouvrir l'enveloppe. Les deux incidents de cette session rappellent la même leçon que celle du 8 octobre, appliquée à deux situations différentes : vérifier l'état avant d'agir coûte quelques secondes ; ne pas le faire peut coûter beaucoup plus. + +**Suite** + +Dette retirée. Reste dans `docs/dette.md` : le test de bascule TURN en navigateur réel, et l'image Jitsi/Kuma à faire évoluer vers un tag versionné. Chantier « vrai cockpit » : Quiz flash, toujours en attente. diff --git a/docs/postmortems/2026-10-13-secret-turn-affiche.md b/docs/postmortems/2026-10-13-secret-turn-affiche.md new file mode 100644 index 0000000..96521ff --- /dev/null +++ b/docs/postmortems/2026-10-13-secret-turn-affiche.md @@ -0,0 +1,58 @@ +# Post-mortem — Secret TURN affiché en clair par erreur + +**Date** : 2026-10-13. **Gravité** : faible (secret révoqué avant toute mise +en service réelle du relais TURN ; aucun élève ni prof concerné). + +## Ce qui s'est passé + +En vérifiant que Prosody avait bien repris la configuration TURN après le +déploiement, j'ai lancé une commande qui affichait la configuration générée +en clair (`docker exec visio-prosody-1 grep -A16 "external_services = {" /run/prosody/config/prosody.cfg.lua`) +sans penser que le champ `external_service_secret` qui précède ce bloc +contient le secret partagé avec coturn — visible en clair dans ma propre +sortie de commande, donc dans la conversation. + +## Réaction + +Traité tout de suite comme un secret compromis, selon la même règle que +l'incident du 2026-10-08 (application credential affichée en clair) : +révocation immédiate, pas d'attente. Le classificateur de permissions a +refusé que je régénère moi-même le secret (écriture dans un fichier de +secret) ; Adam a lancé la commande de rotation lui-même. + +**Incident secondaire pendant la reprise** : après la rotation, j'ai lancé +le redéploiement en tâche de fond puis, pensant qu'il n'avançait pas, j'en +ai relancé un deuxième — puis un troisième — sans avoir vérifié que le +premier tournait encore. `ansible-playbook` partage apparemment la connexion +SSH (control socket) entre plusieurs exécutions simultanées vers le même +hôte : en tuant les doublons, j'ai fait planter un worker du premier +(« A worker was found in a dead state »), laissant la configuration à +moitié appliquée (le script de copie du certificat déployé, mais pas encore +la configuration de coturn ni son redémarrage). Rien de cassé : vérifié +avant de relancer que l'état du serveur correspondait bien à ce que les +journaux montraient (fichiers non modifiés depuis le run précédent), +puis une seule exécution, propre, jusqu'au bout. + +## Corrections + +- Nouveau secret généré par Adam, propagé à coturn (`turn.yml`) puis à + Prosody (`visio.yml`). **Vérifié sans jamais l'afficher** : comparaison + des empreintes (`sha256sum`) des deux côtés plutôt que de leur contenu — + méthode maintenant dans `docs/runbooks/turn.md`. +- Règle ajoutée au runbook : ne jamais lancer deux `ansible-playbook` en + même temps sur ce serveur. +- Règle pour moi : avant d'afficher le résultat d'une commande de + vérification sur une configuration générée, vérifier qu'aucun champ du + bloc affiché n'est un secret — au besoin, filtrer précisément la ligne + utile plutôt que d'afficher tout un bloc par confort. + +## Comprendre + +Les deux incidents de cette intervention ont la même racine : vérifier vite +plutôt que vérifier juste. Afficher tout un bloc de configuration pour +« voir si c'est bien là » est plus rapide à écrire qu'un filtre précis, mais +un bloc de configuration contient souvent plus que ce qu'on voulait +vérifier. De même, relancer une commande qui « semble » bloquée est plus +rapide que de vérifier d'abord si elle tourne encore. Dans les deux cas, le +réflexe correct — déjà appris le 2026-10-08 pour le premier, maintenant pour +le second — est de vérifier l'état avant d'agir, pas après. diff --git a/docs/runbooks/turn.md b/docs/runbooks/turn.md new file mode 100644 index 0000000..6194662 --- /dev/null +++ b/docs/runbooks/turn.md @@ -0,0 +1,100 @@ +# Runbook — Relais TURN (visio sur les réseaux qui bloquent l'UDP) + +Voir [ADR 0006](../adr/0006-turn-443-visio.md) pour le pourquoi complet. + +## Ce qui tourne + +- **coturn**, paquet système (pas Docker), service `coturn` : écoute en TLS + (TURNS) sur `127.0.0.1:5349` **uniquement**. `no-udp`, `no-tcp` : aucun + autre port, jamais directement sur l'IP publique. +- **Nginx, bloc `stream`** (`/etc/nginx/nginx.conf`, section balisée + « ANSIBLE MANAGED BLOCK — bloc A, ADR 0006 ») : seul process qui écoute le + port public 443. Lit le nom demandé dans la poignée de main TLS (SNI), + sans la déchiffrer, et transmet les octets tels quels : + - `turn.syllabuslab.fr` → `127.0.0.1:5349` (coturn) ; + - tout le reste → `127.0.0.1:8443` (les vhosts HTTPS du portail, qui + n'écoutent plus directement le port public depuis ce chantier). +- **Prosody** (rôle `visio`) : `TURNS_HOST`/`TURNS_PORT`/`TURN_TTL` dans + `/etc/visio/visio.env`, `TURN_CREDENTIALS` (secret partagé avec coturn) + dans `/etc/visio/secrets.env`. Calcule lui-même, pour chaque participant, + un couple nom d'utilisateur/mot de passe TURN valable 24 h — jamais le + secret au navigateur. +- **Certificat** : `turn.syllabuslab.fr` fait partie du même certificat Let's + Encrypt que le reste (`portail_noms_certificat`). coturn en a une copie + lisible (`/etc/turnserver/cert/`, groupe `turnserver`), régénérée + automatiquement à chaque renouvellement par + `/etc/letsencrypt/renewal-hooks/deploy/90-turn-coturn.sh`. + +**Aucun nouveau port ouvert dans nftables.** Le relais coturn → JVB (pour la +vraie diffusion audio/vidéo) reste en boucle locale (`relay-ip=127.0.0.1`, +JVB en réseau hôte) : il ne traverse jamais l'IP publique. + +## Rôles et playbooks + +| Rôle | Rôle Ansible | Playbook | +|---|---|---| +| coturn | `ansible/roles/turn/` | `ansible/turn.yml` | +| Routage 443 par nom | `ansible/roles/portail/tasks/07-turn-stream.yml` | `ansible/portail.yml` (et tout playbook qui rejoue `portail`) | +| Annonce côté Jitsi | `ansible/roles/visio/` (tâches TURN) | `ansible/visio.yml` | + +**Ordre de déploiement important** : `turn.yml` doit tourner avant +`visio.yml` (Prosody lit le secret de coturn, pas l'inverse). `portail.yml` +doit avoir déjà étendu le certificat au nom `turn` avant que `turn.yml` +tente de copier `/etc/letsencrypt/live/.../`. + +## Rotation du secret partagé + +Le secret est dans `/etc/turnserver-secret` (`600 root:root`). **Ne jamais +l'afficher** (ni `cat`, ni dans une commande dont la sortie serait relue). + +1. Sur le serveur : `sudo bash -c 'openssl rand -hex 32 > /etc/turnserver-secret; chmod 600 /etc/turnserver-secret'` +2. Depuis le Mac : `ansible-playbook turn.yml -i inventory.ini` (redéploie + coturn avec le nouveau secret, le redémarre). +3. Puis : `ansible-playbook visio.yml -i inventory.ini` (Prosody relit le + secret, les conteneurs Jitsi redémarrent). +4. Vérifier que les deux sont synchronisés **sans afficher le secret** : + comparer les empreintes (`sha256sum`) du secret réellement chargé par + chacun, jamais leur contenu. Exemple (coturn) : + ``` + sudo grep '^static-auth-secret=' /etc/turnserver.conf | sed 's/^static-auth-secret=//' | tr -d '\n' | sha256sum + ``` + et côté Prosody (dans le conteneur) : + ``` + sudo docker exec visio-prosody-1 grep external_service_secret /run/prosody/config/prosody.cfg.lua | sed -E 's/.*"(.*)".*/\1/' | tr -d '\n' | sha256sum + ``` + Les deux empreintes doivent être identiques. + +**Ne jamais lancer deux `ansible-playbook` en même temps sur ce serveur** : +un incident le 2026-10-13 a montré qu'ils partagent la connexion SSH +(control socket) — tuer l'un peut faire planter un worker de l'autre en +plein milieu, laissant une configuration à moitié appliquée (sans la +casser : revérifier l'état avant de relancer). + +## Vérifier que le relais fonctionne (sans navigateur) + +``` +SECRET=$(sudo cat /etc/turnserver-secret) # sur le serveur seulement +timeout 10 turnutils_uclient -t -S -p 443 -W "$SECRET" -e 127.0.0.1 -r 10000 -n 1 turn.syllabuslab.fr +unset SECRET +``` + +Une ligne `tls_connect: client session connected` suivie d'une réponse +binaire (pas une erreur HTTP) prouve que le routage par nom atteint bien +coturn. (coturn ne journalise pas ces échanges par défaut — pas de ligne +dans `journalctl -u coturn` à attendre.) + +## Vérifier de bout en bout avec un vrai participant (à faire avant le pilote) + +Forcer un navigateur à ignorer l'UDP (ex. Chrome : `chrome://webrtc-internals` +ou bloquer l'UDP sortant sur un réseau de test) et vérifier dans les +statistiques WebRTC qu'une candidate ICE de type `relay` est utilisée. +Rejoue aussi `portail/tests/Reels/visio-e2e.mjs`. + +## Tout vérifier après un changement de certificat ou de Nginx + +``` +sudo nginx -t +curl -s -o /dev/null -w '%{http_code}\n' https://syllabuslab.fr +echo | openssl s_client -connect syllabuslab.fr:443 -servername turn.syllabuslab.fr 2>/dev/null | openssl x509 -noout -subject -enddate +nmap -Pn -T4 --open 163.172.4.195 # doit toujours montrer seulement 22, 80, 443 +```