ajoute le relais TURN sur 443 pour la visio (ADR 0006), avec coturn et le routage Nginx par nom

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Adam Belkacemi 2026-10-13 11:45:53 +02:00
parent ee079ddffb
commit 45f8a69e26
19 changed files with 457 additions and 14 deletions

View file

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

View file

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

View file

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

View file

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

100
docs/runbooks/turn.md Normal file
View file

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