Jitsi (docker-jitsi-meet, version figée) derrière Nginx sur meet.syllabuslab.fr, accès par jeton JWT signé par le portail, modération du micro, mains levées synchronisées avec l'application. Pare-feu : UDP 10000 et pont interne br-visio (accords d'Adam). Jicofo n'authentifie plus lui-même : il donnait le rôle de propriétaire aux élèves. Capacité ramenée à 20 VM. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
86 KiB
Journal de bord — Syllabus (ex-Paillasse)
2026-10-07 — Étape 1 : reconnaissance du serveur
Fait
- Déblocage de l'accès SSH : la clé dédiée
~/.ssh/id_ed25519_claudeavait une passphrase sans invite interactive disponible dans Claude Code. Adam a retiré la passphrase lui-même (ssh-keygen -p -f ~/.ssh/id_ed25519_claude, nouvelle passphrase vide). - ⚠️ Incident mineur : au premier essai, Adam a collé l'ancienne passphrase directement dans la conversation avec Claude Code au lieu de la taper dans le prompt du terminal. Elle n'a pas été utilisée ni répétée, et elle est devenue sans objet dès que la passphrase de la clé a été retirée. Point d'attention pour la suite : ne jamais coller de secret dans le chat, toujours dans le terminal.
- Alias
paillassecréé dans~/.ssh/config(163.172.4.195, utilisateuradam,IdentityFile ~/.ssh/id_ed25519_claude,IdentitiesOnly yes). - Reconnaissance en lecture seule via SSH : OS, noyau, CPU, virtualisation, RAM, disques/RAID, réseau, état actuel de SSH et du pare-feu, paquets de durcissement déjà présents ou non.
- Vérification dans la documentation officielle OpenStack (support matrix + page des séries) de la version de Kolla-Ansible à utiliser : 2025.2 (Flamingo), seule série récente qui supporte encore Debian 12 bookworm et qui est encore maintenue.
- Écriture de
docs/inventaire-serveur.md.
Commandes clés
ssh -i ~/.ssh/id_ed25519_claude -o IdentitiesOnly=yes adam@163.172.4.195 ...
ssh paillasse "lscpu; ls -la /dev/kvm; cat /proc/mdstat; ip -br a; sudo nft list ruleset; sudo sshd -T"
Problèmes rencontrés et solution
- Connexion SSH refusée (
Permission denied (publickey,password)) : la clé privée avait une passphrase, et l'environnement Claude Code n'a pas de terminal interactif pour la saisir (ssh_askpassabsent). Solution : Adam a retiré la passphrase lui-même avecssh-keygen -p.
Comprendre
Avant de changer quoi que ce soit, il fallait savoir précisément sur quoi on allait travailler : est-ce que la virtualisation matérielle marche vraiment (sans ça, pas d'OpenStack utilisable), le RAID est-il sain, combien de RAM reste disponible, et surtout quelle version de Kolla-Ansible est compatible avec Debian 12. C'est important car OpenStack évolue vite : la version la plus récente (2026.1) a justement arrêté de supporter Debian 12, donc l'installer aveuglément aurait bloqué tout le projet dès l'étape 5. On a aussi remarqué que le pare-feu est aujourd'hui totalement ouvert et que root peut encore se connecter par clé : ce sont exactement les deux points que l'étape 2 (durcissement) va corriger. Rien n'a été modifié sur le serveur à cette étape, uniquement des commandes de lecture.
Suite
Étape 2 : durcissement SSH + pare-feu nftables + fail2ban/chrony/unattended-upgrades, via un rôle Ansible depuis le Mac. C'est une étape qui touche SSH et le réseau : je vais détailler la commande avant de l'exécuter, prévoir un retour arrière automatique (anti-coupure), et ne redémarrer/couper rien sans l'accord explicite d'Adam pour les points couverts par la règle 3 du prompt de déploiement.
2026-10-07 — Étape 2 : durcissement du serveur
Fait
- Rôle Ansible
durcissement(ansible/roles/durcissement/) + playbookansible/durcissement.yml, appliqués après accord explicite d'Adam. - Pare-feu nftables : tout refusé en entrée sauf loopback, trafic établi, ICMP, et les ports 22/80/443.
- SSH :
PermitRootLogin no,PasswordAuthentication no(connexion par clé uniquement), via un drop-in/etc/ssh/sshd_config.d/99-durcissement.conf. fail2ban(jailsshd, backendnftablescar pas d'iptablessur l'image),chrony(remplacesystemd-timesyncd),unattended-upgrades(sécurité auto, redémarrage automatique explicitement désactivé — un reboot reste une décision d'Adam).- Filet de sécurité anti-coupure : timer
systemd-runde 5 minutes armé avant toute modification du pare-feu/SSH, script de retour à l'état précédent (/usr/local/sbin/durcissement-rollback.sh), désarmé seulement après vérification réussie depuis une connexion SSH toute neuve +nmap. - Vérifié deux fois l'idempotence (rejouer le playbook ne change rien d'inattendu).
Commandes clés
cd ansible && script -q /dev/null ansible-playbook durcissement.yml -i inventory.ini
ssh paillasse "sudo systemctl list-timers | grep durcissement" # filet de sécurité
nmap -Pn -T4 --open 163.172.4.195
Problèmes rencontrés et solution
- Bug dans le rôle : le
notifypour rechargersshdmanquait sur la tâche de dépôt de la config SSH →sshdjamais rechargé au 1er essai → le filet de sécurité s'est déclenché (normal, rien de cassé) → corrigé en ajoutant lenotify. - Le test de vérification
nmap -p-(65535 ports) était beaucoup trop lent face à une politiquedrop(chaque port fermé attend un timeout) → dépassait les 5 minutes du filet de sécurité → 2ᵉ déclenchement automatique du rollback (normal, rien de cassé) → corrigé en limitant le scan aux ports par défaut +-T4(4,5 s au lieu de plusieurs minutes). - Incident réel : auto-bannissement par fail2ban. Les tentatives volontairement ratées (mot de passe, root) de nos propres tests de vérification, répétées sur plusieurs runs rapprochés, ont dépassé le seuil
maxretry = 5de fail2ban : l'IP du Mac d'Adam s'est fait bannir par son propre pare-feu. Résolu via la console web Scaleway (hors bande) avecfail2ban-client set sshd unbanip. Post-mortem complet :docs/postmortems/2026-10-07-fail2ban-auto-ban.md. Correctif :maxretryrelevé à 10.
Critère vérifié
- Connexion par clé : OK. Mot de passe : refusé (
Permission denied (publickey), sans mention depassworddans les méthodes offertes). Root : refusé.nmap -Pn -T4 --open 163.172.4.195depuis le Mac : seul le port 22 est ouvert (80/443 autorisés par le pare-feu mais rien n'écoute encore dessus — normal, le portail arrive à l'étape 8).
Comprendre
Durcir un serveur, c'est fondamentalement risqué pour l'accès qu'on a déjà : chaque changement de pare-feu ou de SSH peut potentiellement nous enfermer dehors. D'où le filet de sécurité (un minuteur qui annule tout automatiquement si on ne confirme pas que ça marche dans les 5 minutes) : il s'est déclenché deux fois pendant la mise au point, et les deux fois c'était le bon comportement — un bug ailleurs, pas un défaut du filet. L'incident fail2ban est différent et plus subtil : ce n'est pas le pare-feu qui nous a bloqués, c'est un service de protection qu'on vient d'installer qui a réagi à nos propres tests comme à une attaque. C'est un vrai piège classique en administration système, qui vaut la peine d'être raconté en entretien : on protège un système contre tout le monde, y compris soi-même si on ne fait pas attention au volume de ses propres tentatives.
Suite
Étape 3 : sauvegardes vers Dedibackup (archives tar.zst chiffrées age, rotation 7 quotidiennes + 4 hebdomadaires, timer systemd).
2026-10-07 — Étape 3 : sauvegardes vers Dedibackup
Fait
- Rôle Ansible
sauvegardes(ansible/roles/sauvegardes/) + playbookansible/sauvegardes.yml. Pas de risque réseau/pare-feu/SSH ici, donc appliqué directement sans attendre d'accord explicite (règle 3). - Paire de clés age générée sur le serveur (
/root/.age/cle-sauvegardes.txt,600) : la clé privée ne quitte jamais le serveur. - Script
/usr/local/sbin/sauvegarde-dedibackup.sh: archive/etc,/home,/rootentar.zst, chiffre en flux avecage, envoie en FTP vers Dedibackup aveclftp, duplique en hebdomadaire le dimanche, applique la rotation (7 quotidiennes + 4 hebdomadaires, suppression du surplus sur Dedibackup). - Timer systemd
sauvegarde-dedibackup.timer: tous les jours à 03:15 (±5 min). docs/runbooks/sauvegardes.mdécrit (déclenchement manuel, liste, restauration, rappel sur la clé age).
Commandes clés
cd ansible && script -q /dev/null ansible-playbook sauvegardes.yml -i inventory.ini
ssh paillasse "sudo systemctl start sauvegarde-dedibackup.service"
# restauration testée : voir docs/runbooks/sauvegardes.md
Problèmes rencontrés et solution
- 1er envoi FTP :
530 Bad credentials. Diagnostic sans jamais afficher les identifiants (longueur et forme des variables seulement). Cause probable : valeurs non protégées par des guillemets dans/root/.dedibackup, risque de troncature silencieuse parbashsi le mot de passe contient#,$ou une espace. Adam a corrigé le fichier lui-même (jamais Claude, le fichier contient un secret). Après correction, l'envoi a réussi. - Extraction de test avec un chemin
./etc/...: échec (Not found in archive), cartarretire le/initial à la création (etc/..., sans./). Corrigé dans le runbook. etc/os-releasen'a pas pu être lu après extraction : c'est un lien symbolique vers../usr/lib/os-release, hors du périmètre sauvegardé (/etc,/home,/rootseulement) — comportement normal, pas un bug.
Critère vérifié
- Sauvegarde envoyée :
quotidienne-20261007.tar.zst.age, confirmée présente sur Dedibackup (lftp cls). - Fichier restauré avec succès dans
/tmp:etc/hostnameextrait et relu, contenu conforme (sd-124443). Dossier de test nettoyé après coup.
Comprendre
Chiffrer avant d'envoyer (plutôt que de compter sur un transport chiffré) a un avantage : Dedibackup n'a accès qu'à un FTP en clair, mais comme le contenu est déjà chiffré par age avant de partir, intercepter le transfert ne révèle rien d'utile. La contrepartie, c'est qu'il ne faut jamais perdre la clé privée age : sans elle, toutes les sauvegardes passées deviennent illisibles pour toujours, même si elles sont bien là sur Dedibackup. D'où le rappel à Adam de garder une copie de cette clé ailleurs que sur le serveur.
⚠️ Reste à faire par Adam : copier /root/.age/cle-sauvegardes.txt en dehors du serveur (gestionnaire de mots de passe, clé USB chiffrée…) — voir docs/runbooks/sauvegardes.md.
Suite
Étape 4 : réseau pour OpenStack (dummy0, paire veth, NAT). Touche le réseau → accord explicite d'Adam obligatoire avant d'appliquer, filet anti-coupure, et redémarrage du serveur seulement avec son accord.
2026-10-07 — Étape 4 : réseau pour OpenStack
Fait
- Rôle Ansible
reseau-openstack+ playbookansible/reseau-openstack.yml(qui rejoue aussidurcissementpour le pare-feu et son filet anti-coupure). Appliqué après accord explicite d'Adam, puis redémarrage avec son accord. dummy0: 10.10.0.1/24, futurnetwork_interface/kolla_internal_vip_address(Kolla sans HAProxy).- Paire veth :
veth-ext-ovs(sans IP, futurneutron_external_interface) ↔veth-ext-gw(172.24.4.1/24, passerelle du réseau externe OpenStack). - Persistance par
systemd-networkd, limité à ces trois interfaces :eno1reste sousifupdown, inchangé. net.ipv4.ip_forward=1(/etc/sysctl.d/99-reseau-openstack.conf).- nftables : NAT masquerade de 172.24.4.0/24 vers
eno1(/etc/nftables.d/10-nat-openstack.conf, inclus depuis/etc/nftables.conf). Chaîneforwardresserrée à la demande d'Adam : seulementveth-ext-gw → eno1depuis 172.24.4.0/24, et les réponseseno1 → veth-ext-gw(established,related). Tout le reste est refusé ; rien n'entre depuis Internet vers les réseaux internes.
Règles nftables finales (relues après redémarrage)
chain forward { policy drop;
iifname "veth-ext-gw" oifname "eno1" ip saddr 172.24.4.0/24 accept
iifname "eno1" oifname "veth-ext-gw" ct state established,related accept
}
table ip nat { chain postrouting {
ip saddr 172.24.4.0/24 oifname "eno1" masquerade
} }
Problèmes rencontrés et solution
- 1er lancement :
Destination directory /etc/nftables.d does not exist. Le dossier était créé pardurcissement, qui tourne aprèsreseau-openstack. Échec survenu avant toute modification du pare-feu. Corrigé en créant aussi le dossier dansreseau-openstack. - Un lancement a été bloqué par le garde-fou de Claude Code parce que la règle
forwardavait été modifiée après l'accord d'Adam sans lui être remontrée. On a fait une simulation--check --diff, Adam a validé le diff, puis on a appliqué. - Table
f2b-tablede fail2ban absente après rechargement du pare-feu et après redémarrage : c'est normal, fail2ban 1.0 ne la crée qu'au premier bannissement. Point encore à vérifier :flush rulesetà chaque rechargement efface une table de bannissement déjà créée. fail2ban devrait la recréer au bannissement suivant (vérification automatique de ses actions), mais ce n'est pas encore testé.
Critère vérifié (après redémarrage à 11:37, SSH revenu à 11:39)
- SSH répond,
dummy0et la paire veth sont présents avec leurs IP,ip_forward=1. - Internet joignable depuis le serveur (ping 9.9.9.9, résolution de
deb.debian.org), route par défaut inchangée. - Services actifs : ssh, nftables, fail2ban, chrony, systemd-networkd, timer de sauvegarde. RAID
[UU]surmd0etmd1. nmapdepuis le Mac : seul le port 22 est ouvert.
Comprendre
OpenStack a besoin de deux réseaux en plus de l'accès public : un réseau interne pour que ses services se parlent, et un réseau « externe » pour que les routeurs virtuels puissent sortir. Sur un serveur qui n'a qu'une seule IP publique, mettre Neutron directement sur eno1 reviendrait à lui confier notre seule porte d'entrée. On a donc créé des interfaces virtuelles : dummy0, une carte réseau factice qui porte une adresse privée stable pour les services, et une paire veth, qui fonctionne comme un câble virtuel dont un bout ira à Neutron et l'autre sert de passerelle. Le NAT fait sortir ce réseau externe vers Internet en se faisant passer pour l'IP publique. Le pare-feu n'autorise que ce sens-là, plus les réponses, si bien que personne ne peut entrer depuis Internet vers les réseaux internes. Le redémarrage prouve que tout cela est persistant, et pas seulement appliqué en mémoire.
Suite
Étape 5 : Kolla-Ansible 2025.2 tout-en-un. bootstrap-servers peut toucher au pare-feu et à Docker : vérifier avant, rejouer durcissement après, et demander l'accord d'Adam pour ce qui touche au pare-feu.
2026-10-07 — Étape 5 : OpenStack avec Kolla-Ansible 2025.2
Fait
- Rôle Ansible
kolla+ playbookansible/kolla.yml: dépendances système, venv/opt/kolla-venv(Kolla-Ansiblestable/2025.2, version21.3.1.dev4, ansible-core 2.19, clientopenstack8.2.0, SDKdockeretdbus-python),/etc/kolla/globals.ymletall-in-onecopiés depuis le dépôt (kolla/),passwords.ymlgénéré parkolla-genpwdsur le serveur uniquement (600 root:root, jamais dans Git),install-deps. kolla/globals.yml: images Debian bookworm (tag vérifié sur quay.io), API surdummy0, réseau externeveth-ext-ovs, OVN, sans HAProxy, keepalived, ProxySQL, Horizon ni Cinder ; avec Heat, console série, KVM, Prometheus, Grafana.disable_firewall: false. Docker sans iptables ni ip6tables.- Avant de lancer
bootstrap-servers, j'ai lu son code installé localement pour savoir s'il touchait au pare-feu. Sur Debian, il ne fait qu'essayer de désactiverufw(absent) et ne touche ni à nftables ni à l'heure. bootstrap-servers,prechecks,deploy(lancé comme servicesystemd-runpour survivre à une coupure SSH, 22 min),post-deploy.- ADR
docs/adr/0001-kolla-2025-2-sur-debian-12.md, runbookdocs/runbooks/openstack.md. - Correction fail2ban validée par Adam à l'étape 4 : le handler
recharger nftablesrelance aussi fail2ban (handlerlisten), et une option-e durcissement_forcer_rechargement=trueforce un rechargement du pare-feu.
Problèmes rencontrés et solution
install-deps:No such file or directory: 'ansible-galaxy'→ lebindu venv doit être dans lePATH.bootstrap-serversa modifié le pare-feu (arrêt et accord d'Adam). Docker 29 a créé des tablesip6 natetip6 filtermalgréiptables: false: depuis Docker 27, l'IPv6 a son propre réglage. Aucun impact sur la sécurité, car notre chaîneforwardendropl'emporte. Correction :docker_custom_config: {ip6tables: false}, puis un rechargement forcé du pare-feu sous filet anti-coupure. J'ai vérifié qu'un redémarrage de Docker ne recrée plus les tables.prechecks:No module named 'docker'→ Kolla utilise le Python du venv ;dockeretdbus-pythonajoutés au venv.- 1er
deployen échec : MariaDBBind on TCP/IP port. Got error: 98: Address already in use. ProxySQL écoute sur la VIP:3306, et sans HAProxy la VIP est l'IP du nœud, donc il occupait le port de MariaDB. Correction :enable_proxysql: "no". Le conteneur et le volumeproxysqlont été supprimés avec l'accord d'Adam. Le volumeproxysql_socketest encore présent (non validé, à supprimer avec son accord). Au 2ᵉdeploy, MariaDB a démarré sans qu'il faille réinitialiser son volume.
Critère vérifié
openstack service list: keystone, glance, nova, neutron, placement, heat, heat-cfn.openstack compute service list: nova-scheduler, nova-conductor, nova-computeup. Agents OVN (Controller Gateway, Metadata) vivants.nmapdepuis le Mac sur les ports OpenStack (5000, 8774, 8775, 8778, 9292, 9696, 6080-6083, 3000, 9090, 9091, 9093, 9100, 3306, 5672, 15672, 11211, 6641, 6642, 16509…) + 1-1024 : seul 22 est ouvert.- Pare-feu identique à la référence ; seule
f2b-table(fail2ban) en plus. - Correction fail2ban testée : bannissement de l'adresse de test
192.0.2.10(plage réservée à la documentation), rechargement forcé du pare-feu ;f2b-tablerecréée et le bannissement toujours en place. Adresse débannie ensuite. - 46 conteneurs ; RAM utilisée 11 Gio sur 31 (19 Gio disponibles) avant toute VM.
Dettes notées
Les bases OVN (corrigé le 2026-10-07 (voir plus bas).ovsdb-server, 6641/6642) écoutent sur0.0.0.0- Kolla est installé depuis une branche, pas un tag : figer la version pour la reproductibilité (ADR 0001).
Correctifs du 2026-10-07 (suite), demandés par Adam
- Volume Docker résiduel
proxysql_socketsupprimé (accord d'Adam), aprèsproxysql_socketlui-même supprimé lors du correctif ProxySQL. - Écoute OVN restreinte à
10.10.0.1. Cause :ansible/roles/ovn-db/tasks/bootstrap-db.ymlde Kolla faitovn-nbctl/ovn-sbctl set-connection ptcp:PORT:0.0.0.0en dur à la création des bases, sans tenir compte deapi_interface. Les ports de cluster (6643/6644) ne sont pas concernés : ils respectent déjàapi_interface_address. Le pare-feu bloquait déjà ces ports depuis Internet (vérifié à l'étape 5), donc pas de faille réelle, mais la bonne pratique de défense en profondeur ne coûte rien à appliquer.- Correctif codé dans
ansible/roles/kolla/tasks/corrections.yml(idempotent : relit l'écoute actuelle avant de la changer, ignoré si OpenStack n'est pas encore déployé), importé à la fin deansible/roles/kolla/tasks/main.yml, tagcorrections. - Vérifié :
ss -tlnpmontre les 4 ports OVN (6641-6644) sur10.10.0.1uniquement ; un second passage du playbook ne rapporte aucun changement (idempotence confirmée) ; les agents OVN et les services Nova restentupaprès coup.
- Correctif codé dans
Comprendre
Kolla-Ansible déploie OpenStack sous forme de conteneurs Docker : chaque service (Keystone, Nova, Neutron…) tourne dans son propre conteneur, configuré par Ansible à partir d'un seul fichier, globals.yml. Les deux incidents de cette étape viennent de valeurs par défaut prévues pour un vrai cluster de plusieurs serveurs. Docker crée des règles de pare-feu IPv6 pour ses réseaux, et ProxySQL suppose une adresse virtuelle distincte de celle du serveur. Sur un seul nœud, ces mécanismes ne servent à rien et se gênent entre eux. La leçon : lire le code de l'outil avant de le lancer, et comparer le pare-feu avant et après, plutôt que de faire confiance aux options. C'est ce qui a permis de repérer les tables Docker en quelques secondes.
Suite
Étape 6 : projet paillasse, utilisateur de service avec application credentials, image paillasse-debian (virt-customize), flavor eleve.petit, première VM et console série par WebSocket.
2026-10-07 — Étape 6 : première VM et console série
Fait
- Rôle Ansible
images+ playbookansible/images.yml: installelibguestfs-tools, déposeopenstack/images/construire-paillasse-debian.shetpaillasse-contenus/07-sql/lycee.sqlsur le serveur. paillasse-contenus/07-sql/lycee.sqlcréé : tablesclasses,eleves,notesavec données d'exemple. Volontairement sans la vuemoyennes, la tableclubsni l'utilisateurprof: c'est le travail demandé à l'élève.- Projet OpenStack
paillasse, utilisateur de servicepaillasse-service(mot de passe généré aléatoirement, jamais affiché, jeté après usage), application credentialportail-syllabus(rôlemember) enregistrée dans/etc/kolla/clouds-paillasse.yaml(600 root:root, jamais dans Git —.gitignoreélargi àclouds-*.yamlet*application-credential*.json). - Image
paillasse-debianconstruite avecvirt-customizedepuisdebian-12-genericcloud-amd64.qcow2officiel (somme SHA512 vérifiée) :qemu-guest-agent, getty surttyS0, utilisateureleve(sudo, mot de passeeleve), outils réseau,nginx/mariadb-server/frrinstallés mais désactivés,/opt/labs/07/lycee.sql,/opt/paquets/tree_*.deb, sans cloud-init (retiré : inutile et seulement source de lenteur sur des réseaux de lab sans DHCP ni métadonnées). Importée dans Glance avechw_qemu_guest_agent=yes, renduepublic(sinon invisible aux autres projets). - Flavor
eleve.petit: 1 vCPU, 768 Mo, 5 Go. - VM de test
vm-test-consoledémarrée sur un réseau de test (reseau-test, sans DHCP, port security désactivée — même style que les futurs réseaux de lab), en ~25 s. - Console série récupérée via
openstack console url show --serial, testée avec un petit script Python (openstack/images/test-console-serie.py, modulewebsocket-client) : connexioneleve/eleve, commande exécutée.
Écart avec paillasse-contenus/README.md
Le README liste tree parmi les outils préinstallés, mais le lab 02 demande explicitement à l'élève de l'installer lui-même depuis /opt/paquets/ (sans Internet dans les VM). J'ai tranché en faveur du lab, qui a une vérification automatique dessus : tree n'est pas préinstallé, son .deb est dans /opt/paquets/. À signaler à Adam : README.md est à corriger (retirer tree de la liste des outils), ou le lab à revoir si c'était voulu.
Problèmes rencontrés et solution
openstack user create --password-prompt:No terminal detected, aucun TTY dans ce contexte. Remplacé par--passwordavec un mot de passe généré paropenssl rand, jamais affiché ni réutilisé (l'authentification définitive passe par l'application credential).openstack application credential create -f json:KeyError: 'id'. Le client renvoie des clés en majuscule (ID,Secret...), pas en minuscule. Corrigé.openstack server create --project paillasse:--projectn'existe pas sur cette sous-commande (la portée vient du token). Résolu en utilisant directement le cloudpaillasse-service(application credential), déjà scopé au bon projet.- Image invisible pour
paillasse-service: créée avec visibilitéshared(nécessite une acceptation explicite par projet). Passée enpublic, cohérent avec un usage par tous les établissements plus tard. - Console série : la première tentative du script de test a échoué (
socket is already closed) en envoyant des trames texte — nova-serialproxy attend des trames binaires (sous-protocolebinary). Corrigé avecws.send_binary(). - La console série est un flux continu, pas une session par connexion : un essai de login interrompu laisse le prompt au milieu d'une saisie pour la connexion suivante. Un script de test doit lire l'état actuel avant d'envoyer quoi que ce soit, pas supposer qu'il part d'un prompt frais.
Critère vérifié
- VM créée et
ACTIVEen ~25 s. - Script connecté au WebSocket de la console série : prompt
localhost login:affiché, connexion réussie, commandewhoami && hostname && echo ---OK---exécutée avec la sortie attendue (eleve,localhost,---OK---).
Comprendre
Une image de VM, ce n'est pas juste un système d'exploitation : c'est tout ce dont les labs ont besoin pour fonctionner sans accès Internet, préparé à l'avance une fois pour toutes. virt-customize permet de modifier une image disque sans jamais la démarrer, en manipulant directement son système de fichiers. Les application credentials sont le bon réflexe pour un compte de service : contrairement à un mot de passe, elles sont limitées à un projet et un rôle précis, et peuvent être révoquées sans toucher au compte lui-même. La console série, elle, prouve qu'on peut atteindre une VM même si son réseau est complètement cassé ou absent — exactement ce que demande CLAUDE.md pour l'espace de lab : « elle doit marcher même si l'élève casse le réseau de sa VM ».
Nettoyage (accord d'Adam)
- Correction de
paillasse-contenus/README.md:treeretiré de la liste des outils préinstallés (confirmé par Adam : le lab 02 garde son intérêt pédagogique). - VM
vm-test-consoleet réseaureseau-testsupprimés (accord d'Adam) : ils n'avaient servi qu'à la preuve de fonctionnement de la console série.
Suite
Étape 7 : supervision (Prometheus, Grafana, Uptime Kuma) et alertes, accessibles par tunnel SSH en attendant un nom de domaine.
2026-10-07 — Étape 7 : supervision et alertes
Fait
- Prometheus, Alertmanager, Grafana, node_exporter, cAdvisor, openstack-exporter, libvirt/mysqld/memcached-exporters étaient déjà déployés par Kolla depuis l'étape 5 (
enable_prometheus/enable_grafana: "yes"), tous liés uniquement à10.10.0.1— jamais exposés sureno1(déjà vérifié par nmap à l'étape 5, re-confirmé ici). - Rôle Ansible
supervision+ playbookansible/supervision.yml: vérifie la présence et les permissions (600) de/root/.alertes-webhooksans jamais afficher son contenu, déploie le relais Alertmanager → ntfy (/usr/local/sbin/alertmanager-ntfy.py, service systemd,127.0.0.1:9099uniquement), et Uptime Kuma (conteneur Docker,127.0.0.1:3001, volume nommé). kolla/config/prometheus/alertes.rules: 4 règles (DisqueSature> 80 %,MemoireSaturee> 90 %,ServiceOpenStackArretevia les métriquesopenstack_nova_agent_state/openstack_neutron_agent_state,SauvegardeAncienne> 26 h).kolla/config/prometheus/prometheus-alertmanager.yml: remplace le gabarit Kolla par défaut (receveur sans destination) par un webhook vers le relais local. Déposé via le mécanisme standard de Kolla (node_custom_config/prometheus/...), pas de template Jinja Kolla à casser.- Collecteur "textfile" activé sur node_exporter (
prometheus_node_exporter_cmdline_extras,prometheus_node_exporter_extra_volumesdansglobals.yml) :sauvegarde-dedibackup.shy écrit, de façon atomique, l'horodatage de la dernière sauvegarde réussie à chaque exécution.
Problèmes rencontrés et solution
- Oubli répété (2 fois) : modifier un fichier du dépôt (
globals.yml, le script de sauvegarde) sans le repousser sur le serveur avant de relancer Kolla. Dans les deux cas,kolla-ansible reconfigurea tourné sans erreur mais sur l'ancienne configuration — aucun message ne le signale. Corrigé en relançant le playbook Ansible concerné avantreconfigure. Noté dansdocs/runbooks/supervision.mdcomme piège à ne pas refaire. - Mesure du mot de passe Alertmanager/Prometheus pour les tests
curl: évitépython3-yaml(absent du Python système) au profit d'un simpleawksur le formatclef: valeurdepasswords.yml; jamais affiché, seulement sa longueur vérifiée une fois pour confirmer qu'il avait bien été lu.
Critère vérifié
- Alerte de test injectée via l'API d'Alertmanager (
POST /api/v2/alerts), sans toucher à rien de réel sur le serveur. Confirmé par Adam : notification ntfy bien reçue. Testé aussi en contournant Alertmanager (requête directe au relais) :HTTP 200. - Métrique de sauvegarde vérifiée de bout en bout : fichier textfile →
node_exporter(node_textfile_scrape_error 0) → Prometheus (/api/v1/queryrenvoie la valeur). - Règles d'alerte confirmées chargées dans Prometheus (
/api/v1/rules: les 4 règles listées).
Dette assumée, à trancher avec Adam
Pas d'alerte « serveur injoignable » : toute la chaîne de supervision tourne sur le serveur qu'elle surveille, donc si le serveur tombe complètement, personne n'est prévenu par ce mécanisme. L'architecture d'origine (CLAUDE.md) prévoyait une sonde hors du serveur (Hetzner CX23) ; ce déploiement n'a qu'un seul serveur. Documenté dans docs/runbooks/supervision.md, pas construit : décision à prendre par Adam (qui commande et paie lui-même toute ressource supplémentaire).
Comprendre
La bonne nouvelle de cette étape, c'est que Kolla avait déjà fait le plus gros : Prometheus, Grafana et une demi-douzaine d'exportateurs tournaient depuis l'étape 5 sans qu'on ait rien demandé de spécial, juste en activant deux options. Le travail réel a surtout été de les relier à une vraie notification (Alertmanager ne sait parler qu'un format webhook générique, pas le format de ntfy, d'où le petit relais) et d'ajouter une alerte qui n'existe dans aucun exportateur standard : l'âge de la dernière sauvegarde. Le collecteur « textfile » de node_exporter est fait exactement pour ça — transformer n'importe quel script maison en métrique Prometheus, juste en écrivant un fichier texte à un endroit précis. La vraie leçon de l'étape, à retenir pour la suite : modifier un fichier dans le dépôt ne change rien tant qu'il n'est pas repoussé sur le serveur — une évidence une fois dite, mais qui s'oublie vite quand on va vite.
Suite
Étape 8 : portail Laravel (Blade, Vite, MariaDB/Redis pour l'app, modèles, connexion prof/élève, salle d'attente). Le dossier design/ doit exister : à vérifier avant de commencer.
2026-10-07 — Étape 8 : portail Laravel
Fait
- Projet Laravel 13 créé dans
portail/(développé en local sur le Mac : PHP 8.4/Composer/Node 22 y étaient déjà installés), plateforme Composer forcée à PHP 8.3 pour que les dépendances restent installables sur le serveur (PHP 8.3, via Sury). Pest 4 (Pest 5 exige PHP 8.4).concurrentlyretiré du starter (2 vulnérabilités critiques viashell-quote, et en réalité inutilisé :php artisan devde Laravel 13 gère déjà le lancement parallèle nativement). - Vues, routes, JS, CSS, images de
design/repris tels quels dansportail/resourcesetportail/public. Toutes les pages vitrine (une cinquantaine) fonctionnent sans rien construire de plus. - Modèles + migrations :
Etablissement,User(+etablissement_id,jeton_session_actif),Classe(code unique auto-généré),Eleve(identifiant unique,code_secrethaché,jeton_qrà usage unique,jeton_session_actif),Lab,Seance,LabSession. - Deux guards d'authentification (
config/auth.php) :web(profs, email/mot de passe) eteleve(identifiant/code secret, sans e-mail — minimisation des données mineurs).App\Models\Eleveauthentifiable via le traitIlluminate\Auth\AuthenticatableetgetAuthPasswordName(). - Session unique par compte : trait
GereSessionUnique(un jeton en base, recréé à chaque connexion) + middlewareVerifieSessionUnique(session.unique:web/session.unique:eleve), qui déconnecte et prévient si le jeton de la session en cours ne correspond plus à celui, plus récent, enregistré en base. - Connexion (
/connexion, un seul écran, deux formulaires), inscription (/inscription: crée établissement + prof + première classe en une transaction), gestion de classe (/mes-classes/...: ajout rapide, import CSV, génération identifiant + code à 6 caractères + QR à usage unique viabacon/bacon-qr-codeen SVG, fiches imprimables), salle d'attente de séance (/seances/..., rafraîchissement automatique, le prof admet). - Service
GenerateurCompteEleve: identifiantprenom.initiale(suffixe numérique si homonyme), code secret à 6 caractères sans caractères ambigus (0/O,1/I/L). - 11 tests Pest (connexion prof, connexion élève, session unique × 2, création de classe × 3) : tous passent, base SQLite en mémoire.
- Déploiement : rôle Ansible
portail(PHP 8.3 via Sury, Nginx, Composer avec vérification de signature officielle, Node 22 LTS via NodeSource, MariaDB+Redis de l'app en Docker Compose sur127.0.0.1:3307/127.0.0.1:6380— jamais sur10.10.0.1, réservé à Kolla), synchronisation du code parrsync,.envde production généré une seule fois (secrets jamais dans Git).
Deux décisions de design tranchées par Adam
- Champ mot de passe ajouté au formulaire d'inscription (absent de la maquette) : sans lui, la connexion prof décrite dans le prompt n'aurait pas pu fonctionner sans serveur mail.
- Écrans sans maquette (gestion des élèves, salle d'attente) : design minimal sobre, mêmes jetons visuels que le reste du site, pas de recherche d'effet « wow ».
Problèmes rencontrés et solution
@auth(sans argument) dans le layout partagé retournaittruepour un élève connecté, affichant le menu du prof. Cause : le middlewareauth:eleveappelleAuth::shouldUse('eleve'), ce qui change le guard par défaut pour le reste de la requête —@authnu teste alors ce guard-là, pas forcémentweb. Corrigé en précisant partout@auth('web')/auth('eleve').- Déploiement : j'ai modifié
kolla/globals.ymlet le script de sauvegarde dans le dépôt sans les repousser sur le serveur avant de relancer les outils correspondants (déjà rencontré à l'étape 7 — même piège, maintenant documenté deux fois). php artisan key:generateéchouait : lancé avantcomposer install, donc avant quevendor/autoload.phpexiste. Réordonné : code → composer install → clé/.env→ npm → migrations.php artisan view:cacheéchouait (« View path not found ») :storage/framework/viewsavait été exclu dursyncmais n'était créé par rien d'autre. Plus largement,storage/etbootstrap/cache/ne doivent jamais être synchronisés depuis le poste de dev (ce sont de l'état et du cache runtime, pas du code — unrsync --deletey effacerait un jour les travaux déposés par les élèves dansstorage/app/). Corrigé : leur structure est créée une fois sur le serveur, propriétéwww-datadès le départ, et ils sont exclus en bloc dursync(pas juste leurs sous-dossiers).- Un handler Ansible qui notifie un autre handler ne s'est pas propagé avant la fin du play : Nginx a continué de servir sa page par défaut malgré un déploiement sans erreur apparente. Corrigé en fusionnant les deux handlers (
nginx -tpuissystemctl reload nginx) en un seul. Détecté uniquement parce que le critère de fin a été vérifié sur le serveur réel, pas seulement sur le code local. - Vérification Playwright visuelle (demandée par le prompt) non faite : l'extension Chrome n'était pas connectée dans cette session. Vérification faite autrement : les mêmes fichiers Blade que
design/sont repris sans changement de structure, et le flux complet a été vérifié par capture HTML (curl) côté connexion, tableau de bord et gestion de classe.
Critère vérifié (sur le serveur réel, pas seulement en local)
- 11/11 tests Pest passent (en local, avant déploiement).
- Un élève fictif (
lea.m) s'est connecté surhttp://163.172.4.195/connexionet a vu son tableau de bord (« Salut Léa », nom de sa classe) — données de test supprimées après coup (accord d'Adam). nft list tablesaprès déploiement : toujours seulementinet filter+ip nat(+f2b-table) — Nginx/PHP-FPM n'ont rien ajouté au pare-feu.
Comprendre
Cette étape a un piège intéressant pour la suite : Laravel a deux systèmes de comptes (profs et élèves), et un mécanisme Laravel conçu pour un seul système (« quel est l'utilisateur connecté ? ») suppose souvent qu'il n'y en a qu'un — le bug du menu qui affichait « Mes classes » à un élève connecté vient exactement de là. Chaque fois qu'on ajoutera un rôle (établissement, formateur, opérateur), il faudra se souvenir d'être explicite sur le guard, partout. L'autre leçon, plus générale : un déploiement qui se termine « sans erreur » ne veut pas dire que le résultat est correct — c'est en testant le vrai critère de fin sur le serveur réel (pas juste en relisant les logs Ansible) qu'on a trouvé le bug du handler Nginx.
Suite
Étape 9 : proxy console Go et xterm.js (jeton Redis à usage unique, relais WebSocket vers nova-serialproxy, lecture seule pour le prof).
2026-10-08 — Étape 9 : proxy console Go et xterm.js
Fait
- Vérifié avant d'écrire du code : l'appel Nova exact (
POST /servers/{id}/remote-consoles) et le format d'authentification Keystone par application credential (POST /v3/auth/tokens, méthodeapplication_credential), par observation directe (openstack --debug) et un testcurlréel — pas de mémoire. - Go 1.27.1 installé localement (Homebrew) pour développer
proxy-console/sur le Mac, comme pour Laravel à l'étape 8. Déployé en compilant sur le serveur à partir du code source (pas de binaire committé), Go 1.27.1 installé là-bas depuis l'archive officielle (somme SHA256 vérifiée via le flux JSON officiel de go.dev, pas depuis un résumé de page). - Service Go (
proxy-console/, utilisateur système dédié sans shell, durci avecProtectSystem=strict) : unHubpar VM (instance_id) maintient une seule connexion amont versnova-serialproxy(elle n'en accepte qu'une), et duplique ce qu'il reçoit vers tous les navigateurs abonnés à cette VM. Seul l'abonné en rôleecriturepeut faire passer ses frappes ; les autres sont ignorées côté serveur. - Côté Laravel :
NovaClient(jeton Keystone mis en cache ~50 min),JetonConsole(écritconsole:{jeton}dans Redis, 5 minutes, format lu par le proxy Go),ConsoleController(vérifie que l'élève est bien propriétaire de sa session de lab et admis ; que le prof est bien propriétaire de la séance). - Nginx :
location /console/relayée en WebSocket vers127.0.0.1:8081. - Front : nouveau script
console-reelle.js(vraie connexion WebSocket, xterm.js) — distinct depaillasse-terminal.jsqui est un faux shell simulé pour la vitrine, explicitement prévu pour être remplacé en production. Intégré dans la salle d'attente élève (une fois admis, si une VM est associée) et dans une nouvelle page prof en lecture seule.
Problèmes rencontrés et solution
- VM de test créée dans le mauvais projet OpenStack (
adminplutôt quepaillasse) : Nova renvoyait404 Instance could not be foundà l'application credential du portail (scopée àpaillasse), pas une erreur d'authentification. Recréée avec le cloudpaillasse-service, comme à l'étape 6. .envde production existait déjà depuis l'étape 8 et n'est jamais régénéré : les nouvelles variablesOS_*devaient être ajoutées sans y retoucher le reste (APP_KEYen particulier). Ajouté via des tâcheslineinfileidempotentes, séparées du template de première génération.go build, lancé avec$HOMEpointé sur le dossier de l'app, y crée.cache/etgo/pkg/mod/appartenant àroot: lersyncsuivant (sansbecome) ne pouvait plus les supprimer. Exclus dursync.- Premier test manuel de la console : « Invalid WebSocket Header ». Le script de test demandait le sous-protocole
binary, nécessaire pour parler directement ànova-serialproxy(étape 6) mais pas à notre propre proxy, qui ne le renvoie pas — un vrai navigateur ne le demande pas non plus. Corrigé dans le script de test ; rien à changer côté proxy ni côtéconsole-reelle.js.
Critère vérifié
- Une élève fictive a tapé
ip adans sa VM et vu le résultat, via le chemin complet navigateur → Nginx → proxy Go →nova-serialproxy. - Un autre élève n'a pas pu obtenir de jeton pour cette session de lab (
403). - Vérification allant au-delà du critère minimal : prof et élève connectés en même temps sur la même VM — le prof voit ce que tape l'élève (duplication du flux), et sa propre tentative d'écrire n'apparaît jamais dans le flux renvoyé par la VM (protection côté serveur, pas seulement côté interface).
Comprendre
La vraie contrainte de cette étape, c'est qu'une console série ne supporte qu'un seul lecteur à la fois — c'est une caractéristique de QEMU/libvirt, pas un choix arbitraire. Tout l'intérêt du proxy Go est d'être le seul à parler à cette connexion fragile et unique, et de se comporter comme un standard téléphonique pour tout le monde d'autre : il décroche une fois, et fait écouter qui il veut sur la même ligne. C'est aussi pour ça que le jeton que porte le navigateur ne doit jamais révéler l'adresse réelle de la console : s'il fallait donner cette adresse directement à chaque élève, rien n'empêcherait d'en ouvrir une deuxième connexion et de faire planter la première.
Suite
Étape 10 : liaison Laravel ↔ OpenStack (client HTTP ou php-opencloud/openstack) et modèle Heat du lab 08 — poser la vraie mécanique qu'on a seulement simulée à la main à cette étape (associer une VM à une session de lab).
2026-10-08 — Étape 10 : liaison Laravel ↔ OpenStack et modèle Heat du lab 08
Fait
GenerateurTemplateHeat: traduit unlab.yaml(format paillasse-contenus : réseaux + machines, chaque machine listant les réseaux auxquels elle est reliée, dans l'ordre) en un vrai gabarit Heat (HOT) — un réseau/sous-réseau Neutron par réseau du lab (port_security_enabled: false,enable_dhcp: false: pas de pare-feu réseau ni de DHCP, les élèves configurent eux-mêmes, aucune route vers l'extérieur), un port + une instance Nova par machine, les ports attachés dans l'ordre déclaré (c'est cet ordre qui décide si une interface seraens3ouens4dans la VM), une sortie Heat par machine pour récupérer son identifiant une fois la stack créée.HeatClient(créer/lire/supprimer une stack) etKeystoneAuth(authentification Keystone partagée, jeton mis en cache 50 min) : voir ADR 0002, la vraie découverte de cette étape.- Une stack Heat par session de lab (par élève, par tentative), pas par classe :
CreerStackLab(job Redis, déclenché à l'admission de l'élève) génère le gabarit, crée la stack, attendCREATE_COMPLETE(jusqu'à 2 minutes), enregistre uneLabSessionMachine(nom, identifiant d'instance) par machine du lab.SupprimerStackLab(job Redis, déclenché à l'expiration) supprime la stack et ses VM, et classe la session enexpireoutermineeselon le cas. ExpirerSessionsLab(commande Artisanlabs:expirer-sessions), planifiée chaque minute viaSchedule::command()dansroutes/console.php+ un cron système qui appelleartisan schedule:run.- Les deux jobs ont besoin d'un worker : service systemd
portail-worker(queue:work), déployé et redémarré par Ansible à chaque déploiement de code (sans ce redémarrage, un worker déjà lancé continue de faire tourner l'ancien code en mémoire). labs/: nouveau dossier à la racine du dépôt, un sous-dossier par lab, copié par Ansible vers/opt/labssur le serveur (distinct depaillasse-contenus/, qui reste la source éditoriale ;labs/est la copie de travail que lit le portail). Lab 08 (deux réseaux locaux reliés par un routeur, 3 machines) copié en premier.- Salle d'attente élève et console prof passées à plusieurs terminaux (un onglet par VM de la session), avec un préfixe de route supplémentaire (
{labSessionMachine}) pour identifier quelle VM ouvrir.
Problèmes rencontrés et solution
- La vraie découverte de l'étape : création de stack Heat refusée (
500, trace dansheat-engine.log: « Delegated tokens cannot manage trusts »). Le compte de service du portail utilisait une application credential (choix de l'étape 6) ; Heat a besoin de créer un trust Keystone pour gérer le cycle de vie d'une stack, et Keystone interdit par construction à un jeton déjà délégué (une application credential en est un) d'en déléguer un second. Vérifié avec un compte à mot de passe classique avant de rien changer en dur ; confirmé avec Adam ; appliqué partout (voir ADR 0002). Un seul mécanisme d'authentification désormais pour tout ce que le portail demande à OpenStack. openstack user set --passwordavec le cloudkolla-adminetOS_PROJECT_NAME=paillasse→401: même limitation que l'étape 6 (le compteadminn'a pas de rôle sur le projetpaillasse, donc pas de jeton scopé à ce projet). Pas réinvestigué, contourné en utilisant directement le cloudpaillasse-service.sudo -u www-data php artisan tinkeréchouait (« Writing to directory /var/www/.config/psysh is not allowed ») :www-datan'a pas de$HOMEinscriptible. Contourné avecHOME=/tmpà chaque appel.
Critère vérifié (sur le serveur réel)
- Classe, élève, séance de test créés ; admission de l'élève sur le lab 08 →
CreerStackLabexécuté en 26 s → stackCREATE_COMPLETE, 3 VM créées.ip link showdans chaque VM confirme la topologie exacte dulab.yaml: le routeur a 2 interfaces (ens3,ens4), les deux postes (pc-a,pc-b) n'en ont qu'une chacun, sur des réseaux différents. - Les 3 terminaux se sont ouverts côté élève, chacun relié à la bonne VM.
- Vérification allant au-delà du critère minimal : expiration forcée (
expire_amis dans le passé) →labs:expirer-sessions→SupprimerStackLabexécuté en 1 s → stack et 3 VM confirmées absentes (openstack stack list/server listvides) → session classéeexpire. Le cycle de vie complet (création, usage, expiration automatique) fonctionne de bout en bout sans intervention manuelle.
Comprendre
La leçon de cette étape dépasse Heat : dans Keystone, un jeton porte une histoire (« comment a-t-il été obtenu ? »), pas seulement une identité et des droits. Une application credential produit un jeton marqué comme délégué ; peu importe les droits du compte derrière, ce jeton ne pourra jamais en déléguer un second — c'est une protection contre les chaînes de délégation, pas un détail de configuration qu'on pourrait activer. Avant d'ajouter un service OpenStack qui gère un cycle de vie (Heat aujourd'hui ; Octavia, Magnum plus tard), il faut vérifier s'il a besoin d'un trust — et donc d'un compte à mot de passe, jamais d'une application credential.
Suite
Étape 11 : stock chaud de VM (files Redis) et correction automatique (agent invité QEMU).
2026-10-08 — Entre les étapes 10 et 11 : questions d'Adam sur le compte de service
Fait
--unrestrictedtesté sur le serveur réel : une application credential--unrestrictedéchoue avec la même erreur Keystone que la restreinte (Delegated tokens cannot manage trusts). Le mot de passe reste la seule option pour Heat. Ajouté à l'ADR 0002.- Contrôle du compte
paillasse-service: seul rôlemembersurpaillasse(readeren découle, rôle impliqué), mot de passe de 32 octets aléatoires,clouds-paillasse.yamlen600 root:root. Il manquait le runbook de rotation : écrit (docs/runbooks/compte-service-openstack.md) et testé par une vraie rotation. Le classifieur de permissions a bloqué la fin de la rotation (écriture de secrets) : Adam a lancé lui-même le playbook et l'effacement du cache, puis j'ai vérifié l'authentification sans afficher de secret. - Défaut corrigé : un changement de
.envpar Ansible ne redémarrait pas le worker de file, qui aurait gardé l'ancien mot de passe en mémoire.
Incident (de ma part)
En créant l'application credential de test --unrestricted, j'ai demandé une sortie -f json qui a affiché son secret en clair, puis je l'ai écrit dans un fichier temporaire. La credential et le fichier ont été supprimés juste après le test : le secret est révoqué et sans valeur. Règle pour la suite : toute commande qui crée un secret doit l'envoyer directement dans un fichier 600 sur le serveur, sans jamais l'afficher, même pour un test.
2026-10-08 — Étape 11 : stock chaud et correction automatique
Fait
- Fiabilité des créations (demande d'Adam) :
FabriqueStack, commune aux sessions et au stock. Un essai raté supprime sa stack et lève une exception ; Laravel relance le job 2 fois (après 15 puis 45 s), chaque essai sous son propre nom (-e1,-e2,-e3). Avant un essai, les restes des précédents sont supprimés (cas d'un processus tué). Après le 3ᵉ échec : session enechec, raison visible du prof, bouton « Réessayer ». L'admission est protégée contre le double clic par une mise à jour SQL conditionnelle. - Stock chaud : une liste Redis de stacks prêtes par lab, prise atomique par un script Lua, cible = 1 stack par lab + élèves des séances qui commencent dans 10 min ou déjà ouvertes, plafond de capacité, ménage des stacks orphelines par comparaison avec Heat, disjoncteur après 3 échecs. Séances planifiables (date et heure, heure de Paris convertie en UTC) : le stock monte au nombre d'élèves 10 min avant. Détails et alternatives : ADR 0003.
- Laravel Horizon 5.50 remplace le
queue:workunique : superviseurssessions(4 processus) etstock(2), tableau de bord fermé. Timeouts emboîtés : job 280 s < superviseur 300 s <retry_after330 s (avant : 90 s, un job long aurait été exécuté deux fois). - Correcteur (Go, choix d'Adam) : service systemd root, groupe
www-data, socket Unix0660, identité du client contrôlée parSO_PEERCRED. N'accepte que{instance, lab, verification}(champs en plus refusés), lit la commande dans lelab.yaml, vérifie dans les métadonnées libvirt que la VM appartient au projetpaillasseet correspond à la machine visée, exécute parguest-execviadocker exec nova_libvirt virsh(10 s max). Ne renvoie jamais la sortie de la commande, seulement réussi/échoué et un code d'erreur. Installation de Go sortie dans un rôlegopartagé avec le proxy console. - Côté élève : bouton « Vérifier mon travail », note, détail par vérification avec le
message_echecdu lab (ou un message technique si la VM ne répond pas). La page d'attente interroge l'état chaque seconde au lieu de se recharger toutes les 5 s. Côté prof : note dans la salle d'attente. - 7 nouveaux tests Pest (18 au total), 4 tests Go.
Calcul de la capacité (LABS_CAPACITE_VM = 15)
- Mesuré sans VM : 15,5 Go utilisés par Kolla et le portail sur 32 Go, 16,5 Go disponibles.
- Mesuré avec les 3 VM d'une stack de stock au repos : 260 à 290 Mo réels par VM (sur 768 Mo alloués).
- Pire cas retenu (chaque VM utilise toute sa RAM) : (16,5 Go − 2 Go de marge pour l'hôte) / (768 Mo + ≈ 200 Mo de surcoût QEMU) ≈ 15 VM. Au repos, le serveur en tiendrait trois fois plus : décider d'un surengagement (et de KSM) sera le sujet de l'étape 13, mesures en charge à l'appui. Stock par défaut : 1 stack par lab (3 VM pour le lab 08, 20 % du plafond).
Commandes clés
cd ansible && script -q /dev/null ansible-playbook correcteur.yml -i inventory.ini # portail + correcteur
ssh paillasse "cd /var/www/portail && sudo -u www-data HOME=/tmp php8.3 artisan labs:remplir-stock"
ssh paillasse "journalctl -u correcteur -f"
Problèmes rencontrés et solution
- La première touche frappée coupait la console (défaut de l'étape 9) :
nova-serialproxyferme la connexion à la première trame WebSocket texte, et xterm.js envoie du texte. Le test de l'étape 9 envoyait du binaire et ne l'avait pas vu. Le proxy envoie désormais tout en binaire. Post-mortem :docs/postmortems/2026-10-08-console-trames-texte.md. - Aucun bouton pour qu'un élève rejoigne une séance (défaut de l'étape 8, masqué aux étapes 9 et 10 où la session était créée en base) : ajouté au tableau de bord élève.
- « Se déconnecter » renvoyait 419 : les deux formulaires du layout n'avaient pas de
@csrf. Corrigé, et un test vérifie la présence du jeton (Laravel désactive le contrôle CSRF pendant les tests, il fallait tester le HTML). - Une VM du stock démarrée depuis longtemps n'affiche rien sur sa console série : option
reveildansconsole-reelle.js, qui envoie un retour chariot à la connexion (côté élève seulement). - Les séances n'avaient pas d'heure de début : colonne
prevue_aet formulaire de planification. - Larastan (PHPStan niveau 6, exigé par le CLAUDE.md) n'est pas installé depuis l'étape 8 : dette notée, pas traitée ici.
- Durée : l'étape a dépassé 1 h 30 (trois défauts d'étapes précédentes trouvés et corrigés en route), sans blocage.
Critère vérifié (sur le serveur réel, portail/tests/Reels/parcours-lab.py, joué comme deux navigateurs)
1. Admission → invite de connexion : 4.06 s (OK, critère < 5 s)
machines ouvertes : pc-a, routeur, pc-b
2. Sans routage activé : « Pas encore réussi » 6/10 (vérification en 5.8 s)
- Le routeur ne transmet pas les paquets : regarde net.ipv4.ip_forward.
- PC-A ne joint pas PC-B. Vérifie les adresses, les passerelles et le routage, dans cet ordre.
- La configuration du routeur disparaîtra au redémarrage (sysctl et fichier d'interfaces).
3. Routage activé : « Réussi » 10/10 (vérification en 4.7 s)
- Une seule mesure du temps d'ouverture, et pessimiste (le script récupère les 3 consoles l'une après l'autre, le navigateur en parallèle). Le p95 sur une série reste à établir à l'étape 13.
- Échec provoqué (lab temporaire dont la stack ne peut pas se construire) :
heat-engine.logmontrelab-session-5-e1,-e2,-e3chacuneCREATE FAILEDpuisDELETE COMPLETE; session enechec75 s après l'admission, aucune stack restante. Le stock du même lab : disjoncteur ouvert après les échecs, plus aucune commande. - Correcteur interrogé directement : vérification légitime exécutée ; machine non conforme, uuid hors projet, chemin
../../etc, champcommandeajouté et vérification inconnue tous refusés ; un utilisateur autre quewww-dataou root ne peut pas ouvrir le socket. - Données, lab temporaire et fichiers de test supprimés (accord d'Adam). État final : une stack de stock pour le lab 08, rien d'autre.
Comprendre
Le stock chaud transforme une attente de 30 s en une attente de 4 s en faisant le travail lent avant que quelqu'un en ait besoin — comme un restaurant qui prépare ses fonds de sauce le matin. La difficulté n'est pas de construire d'avance, c'est de ne jamais donner deux fois la même chose ni de perdre ce qu'on a construit : d'où le script Lua (Redis exécute « retirer de la liste et marquer comme en cours d'attribution » d'un seul bloc) et le ménage qui compare Redis à Heat. Côté correction, la règle de sécurité est simple et non négociable : le portail désigne une vérification par son nom, il n'envoie jamais de commande ; le correcteur, seul à toucher les VM, ne fait confiance qu'au lab.yaml et vérifie même que la VM désignée est bien celle du lab. Enfin, cette étape montre l'intérêt d'un test qui imite vraiment l'utilisateur : trois défauts des étapes précédentes, invisibles pour les tests « techniques », sont apparus dès qu'un script a joué le parcours comme un navigateur.
Suite
Étape 12 : domaine, HTTPS et sécurité. Arrêt obligatoire avant de commencer : Adam doit me donner le domaine (il l'achète et configure le DNS lui-même).
2026-10-09 — Correctif : Uptime Kuma injoignable par le tunnel SSH
Symptôme (signalé par Adam) : conteneur uptime-kuma « healthy », mais rien n'écoute sur 127.0.0.1:3001 ; le tunnel SSH donne « connexion refusée ».
Cause : depuis l'étape 5, Docker tourne avec "bridge": "none" et "iptables": false pour ne jamais toucher au pare-feu. Le conteneur était rattaché au pont par défaut, qui n'existe pas : pas de réseau, et le ports: 127.0.0.1:3001:3001 du rôle n'a jamais été publié (docker inspect : ports demandés présents, ports effectifs vides). Le contrôle de santé de l'image interroge le localhost du conteneur, d'où le « healthy » trompeur. Le défaut date de l'étape 7 : sa vérification avait porté sur l'état du conteneur, pas sur l'écoute côté hôte.
Correctif (rôle supervision) : network_mode: host et écoute forcée sur 127.0.0.1 par UPTIME_KUMA_HOST / UPTIME_KUMA_PORT (noms vérifiés dans server/config.js de l'image). Plus de publication Docker, ni NAT ni règle de pare-feu. Le rôle vérifie désormais à chaque passage que le port écoute et uniquement sur 127.0.0.1 (échec du playbook sinon). Données conservées (volume uptime-kuma-data).
Vérifié : ss -tlnp → LISTEN 127.0.0.1:3001 users:(("node",...)) et rien d'autre ; curl http://127.0.0.1:3001/ → 302 (redirection vers la page d'accueil de Kuma) ; port 3001 fermé sur l'IP publique depuis le Mac ; tables nftables inchangées ; second passage du playbook changed=0.
Comprendre : « healthy » veut dire que le conteneur répond à lui-même, pas qu'il est joignable de l'extérieur. Le seul test qui prouve qu'un service est accessible, c'est de s'y connecter par le chemin réel (ici : écoute sur l'hôte, puis tunnel). En réseau hôte, le conteneur partage la pile réseau du serveur : l'adresse d'écoute devient la seule barrière, d'où la vérification automatique ajoutée au rôle.
2026-10-09 — Étape 12 : domaine, HTTPS et sécurité
Fait
- DNS vérifié :
syllabuslab.fretwww→163.172.4.195, pas d'AAAA ni de CAA. - Certificat Let's Encrypt (certbot 2.1 du dépôt Debian,
certonlyavec l'authentificateur nginx, sans e-mail) :syllabuslab.fr+www, valable jusqu'au 2027-01-07. Le rôleportailsert d'abord le site en HTTP avec les bons noms, obtient le certificat, puis passe le vhost en HTTPS ; aux passages suivants, certbot ne fait rien tant que le certificat est valide. Renouvellement parcertbot.timer, rechargement de Nginx en crochet. - Alerte d'expiration : sans e-mail Let's Encrypt, c'est Prometheus qui prévient (métrique publiée chaque jour par le collecteur textfile, règles
CertificatExpireBientotà moins de 14 jours etCertificatNonMesure). - TLS : recommandations Mozilla v6.0 « intermediate », lues à la source (TLS 1.2/1.3, suites ECDHE), sans
X25519MLKEM768(OpenSSL 3.0) ni OCSP stapling (abandonné par Let's Encrypt). HTTP/2. - Redirections : HTTP → HTTPS,
www→ domaine principal, IP → domaine. - En-têtes (Nginx) : HSTS 2 ans
includeSubDomains,nosniff,X-Frame-Options DENY,Referrer-Policy,Permissions-Policy,Cross-Origin-Opener-Policy,server_tokens off. Cookie de sessionsecure; httponly; samesite=lax,APP_URLen HTTPS. - CSP en deux niveaux (Laravel, ADR 0004) : stricte avec nonce sur l'application, adaptée sur la vitrine. xterm.js est maintenant servi par le portail (Vite) au lieu de jsDelivr. Point de collecte
/csp-rapport. - Exposition (choix d'Adam) : Grafana et Uptime Kuma restent accessibles par tunnel SSH seulement. Page de statut publique prévue sur
status.syllabuslab.fr, filtrée par Nginx (seuls la page de statut et son API en lecture passent). Elle s'activera toute seule au passage du rôle quand le DNS existera. docs/dette.mdcréé : la dette éparpillée dans le journal est regroupée, avec la demande d'Adam (figer l'image Uptime Kuma et la mettre à jour par Ansible).- 5 nouveaux tests Pest (23 au total) : CSP stricte et adaptée, nonce sur chaque script, rapport sans CSRF, toutes les pages publiques en 200.
Problèmes rencontrés et solution
- Faille XSS dans la page de classe (étape 8) :
onsubmit="return confirm('… {{ $eleve->prenom }} …')". Blade échappe l'apostrophe en', que le navigateur décode avant d'exécuter le JavaScript : un prénom importé par CSV commex');alert(1);//s'exécutait chez le prof. Corrigé (attributdata-confirmer+ écouteur), et la CSP stricte bloquerait désormais ce genre de script. - Page
/coursen erreur 500 depuis l'étape 8 : la maquettedesign/elle-même a perdu la boucle et les données des cursus lors de sa refonte (3@foreachpour 4@endforeach), etconfig/cours.phpn'avait pas été copié. Rétabli d'aprèsdesign/_backup-ludique(sans toucher àdesign/). Le journal de l'étape 8 affirmait que « toutes les pages vitrine fonctionnent » : faux pour celle-ci. Un test ouvre maintenant toutes les pages publiques ; vérifié qu'il échoue sur l'ancienne version. - NodeSource relancé en root à chaque déploiement : la condition
createsvisaitnodesource.list, alors que le script créenodesource.sources. Corrigé ; remplacement par un dépôt déclaratif noté en dette. - Le scan depuis le Mac montrait 21, 554 et 1723 ouverts : c'est la box d'Adam (même résultat vers
192.0.2.1, adresse inexistante). Côté serveur, rien n'écoute et nftables n'accepte que 22/80/443. - Vérification dans un vrai navigateur impossible (extension Chrome non connectée) : la CSP est surveillée par ses rapports ; parcours manuel demandé à Adam.
- Ma commande de contrôle des en-têtes a affiché la valeur d'un cookie de session anonyme (ouvert par
curl, sans connexion, donc sans valeur) ; le filtre de masquage visait un mauvais nom de cookie.
Critère vérifié
subject=CN = syllabuslab.fr ; issuer=Let's Encrypt (YE2) ; notAfter=Jan 7 07:21:22 2027 GMT
https://syllabuslab.fr -> HTTP 200, TLS vérifié: 0 (0 = OK), HTTP/2
http://163.172.4.195/ -> 301 https://syllabuslab.fr/ ; https://www.syllabuslab.fr/cours -> 301 https://syllabuslab.fr/cours
TLS 1.1 : refusé ; TLSv1.3, TLS_AES_256_GCM_SHA384
certbot renew --dry-run : all simulated renewals succeeded
Prometheus : CertificatExpireBientot:ok, CertificatNonMesure:ok ; jours restants : 90.0
Parcours complet par https://syllabuslab.fr, consoles en wss:// :
1. Admission → invite de connexion : 4.35 s (OK, critère < 5 s)
2. Sans routage activé : « Pas encore réussi » 6/10, avec les messages du lab
3. Routage activé : « Réussi » 10/10
- Ports : depuis Internet, seuls 22, 80 et 443 répondent pour le serveur ; les ports internes (Grafana, Prometheus, Kuma, API OpenStack, proxy console, MariaDB, Redis) sont tous filtrés.
- VM d'élèves : aucune IP flottante, aucun routeur Neutron dans le projet ; depuis
pc-a, Internet et l'hôte sont injoignables (« Network is unreachable »).
Comprendre
HTTPS ne se résume pas au cadenas : le certificat prouve l'identité du site, HSTS interdit au navigateur de revenir en HTTP, et la CSP décide quels scripts ont le droit de s'exécuter. Cette dernière est la vraie protection contre l'injection de code : même si un prénom piégé arrivait dans une page, le navigateur refuserait de l'exécuter faute du nonce, ce jeton aléatoire que seul le serveur connaît pour chaque page. La faille trouvée dans la page de classe montre pourquoi : l'échappement de Blade protège le HTML, mais pas du JavaScript placé dans un attribut — deux langages imbriqués, deux règles d'échappement. Enfin, le renouvellement automatique n'est fiable que s'il est surveillé : d'où l'alerte Prometheus, puisque plus personne n'enverra d'e-mail avant l'expiration.
Suite
- Adam : enregistrement DNS
status, page de statutsyllabusdans Kuma, sonde du portail passée enhttps://syllabuslab.fr, parcours manuel dans un navigateur (pour la CSP). - Étape 13 : tests de capacité et de résistance.
2026-10-09 — Étape 12 (suite) : site de statut activé
- DNS
statusd'abord créé sousstatus.syllabuslab.fr.syllabuslab.fr(le champ « Sous-domaine » d'OVH n'attend questatus) ; corrigé par Adam. Une sonde nommée « syllabus » avait été créée à la place de la page de statut : Kuma 1.23 ne répond alors jamais à l'API de la page (d'oùproxy_read_timeout 15scôté Nginx). Runbookhttps.mdprécisé. - Le rôle a détecté le nom, étendu le certificat (
DNS:status.syllabuslab.fr, DNS:syllabuslab.fr, DNS:www.syllabuslab.fr) et activé le site filtré. - Vérifié depuis le Mac :
/→ 302 vers/status/syllabus;/dashboard,/manage-status-page,/settings/general,/metrics,/socket.io/…,/api/push/…→ 404 ;POST /api/status-page/syllabus→ 403 ; HTTP → 301 vers HTTPS./status/syllabus→ 404 renvoyé par Kuma lui-même (page pas encore créée), pas par le filtre. - Reste (Adam) : créer la page de statut
syllabusdans Kuma, supprimer la sonde en double « syllabus ». - Page de statut créée par Adam (slug
syllabus, publiée). Vérifié depuis le Mac parhttps://status.syllabuslab.fr: page, API, pulsations, scripts, CSS et icône en 200. Seul/docker-latest-warning.jsest bloqué (404) : script que Kuma injecte dans ses images « historiques »latest/1, actif seulement sur/setup, inutile au public — et argument de plus pour figer l'image (dette).
2026-10-09 — Étape 13 : tests de capacité et de résistance
Fait
- Constats avant de tester (valeurs par défaut de Nova lues dans son code installé, pas de mémoire) :
reserved_host_memory_mb = 512(Nova se croyait libre de distribuer 31,5 Go alors que Kolla en occupe 15,5 : il aurait accepté ~41 VM et l'hôte aurait manqué de mémoire avant tout refus),resume_guests_state_on_host_boot = false(après un redémarrage, toutes les VM — dont le stock chaud — seraient restées éteintes), quota du projet à 10 instances (défaut OpenStack : une classe aurait été bloquée bien avant la capacité réelle). - Surcharge Nova versionnée (
kolla/config/nova/nova-compute.conf, déposée par le rôlekolla) : reprise des VM au démarrage, réserve mémoire. Rôleopenstack-projet: gabariteleve.petitet quota (40/40/30 Go) rejouables. - Test de capacité (
openstack/tests/capacite.py, en service systemd) : 20 VM lancées en même temps, puis repos, CPU à 100 %, mémoire chargée. Résultats et décisions :docs/capacite.md. Capacité retenue par Adam : 22 VM (Nova et portail alignés ; Placement confirme 16 993 Mo distribuables = 22 VM). - Sauvegarde complétée : la base du portail (
mariadb-dump --single-transaction), son.env(APP_KEY) etstorage/appn'étaient pas sauvegardés (la sauvegarde date de l'étape 3, le portail de l'étape 8). Le mot de passe MariaDB est lu dans le conteneur, jamais sur la ligne de commande de l'hôte. - Test de restauration (
/usr/local/sbin/syllabus-test-restauration, chaque lundi 04:30, alerteRestaurationNonTesteeau-delà de 8 jours) : télécharge, déchiffre, restaure la base dans un conteneur jetable, fait tourner le code du portail contre la copie, compare avec la production. - Redémarrage du serveur (accord d'Adam) avec relevé d'état avant/après (
/root/etat-serveur.sh). - Unité systemd
kolla-proxysql-containeren échec depuis le 7 octobre (ProxySQL désactivé à l'étape 5, unité restée activée) : retirée par le rôlekolla(accord d'Adam), rangée dans/root/unites-retirees/.systemctl --failed: 0.
Problèmes rencontrés et solution
- Le playbook
kollaa perdu sa connexion SSH pendant une tâche longue (« Shared connection closed ») : ni bannissement fail2ban ni panne ; relancé, il est allé au bout. Leskolla-ansible reconfiguresont désormais lancés en service systemd (systemd-run), insensibles à une coupure SSH. - Mesure du temps d'ouverture trompeuse : après le redémarrage, le parcours a donné 5,51 s (critère < 5 s). Décomposition : le script demandait les 3 jetons de console l'un après l'autre (≈ 1,1 s chacun) alors que le navigateur les demande en parallèle. Script corrigé ; 3 mesures réelles fidèles au navigateur : 2,80 s, 2,85 s, 2,85 s. Les 4,06 et 4,35 s des étapes 11 et 12 étaient donc pessimistes aussi.
- Ma boucle d'attente du retour des services ne sortait jamais (sous zsh,
set -- $sne découpe pas la chaîne) : les valeurs affichées montraient pourtant le retour complet ; boucle arrêtée, relevé fait à la main. Mon premierdiffavant/après a échoué sur des droits (sudone s'applique pas aux substitutions de processus) : comparaison faite ligne à ligne. - Premier « retour SSH » après la demande de redémarrage : faux positif (connexion établie juste avant l'arrêt effectif, uptime de 2 jours). Attente refaite sur l'uptime (< 10 min).
- Le projet
paillasse, le compte de service et le gabarit avaient été créés à la main à l'étape 6 sans être codifiés (règle « tout est du code ») : gabarit et quota codifiés ; projet et compte notés en dette. La base d'OpenStack elle-même n'est pas sauvegardée : dette prioritaire.
Critère vérifié
- Chiffres de capacité écrits :
docs/capacite.md. 20/20 VM démarrées sans erreur (prêtes en 53 s médiane, 75 s p95) ; ≈ 250 Mo par VM au repos, ≈ 600 Mo active ; 20 vCPU à 100 % → hôte à 89 %, commande de correction en 0,6 s. - Restauration réussie :
quotidienne-20261009.tar.zst.age (108M) APP_KEY restaurée identique à la production (valeur non affichée) migrations dans la copie : 14 appliquées, 0 en attente 17 tables : copie = production pour chacune == RESTAURATION RÉUSSIE - Tout est revenu seul après le redémarrage (19:50 → SSH 19:53 → services Kolla et VM avant 20:01) : services, 46 unités Kolla, 49 conteneurs (0 en mauvaise santé), pare-feu, écoutes, réseau OpenStack, Nova/Neutron, 3 VM du stock relancées et dont l'agent invité répond, correcteur, Horizon, HTTPS, page de statut — identiques à l'état d'avant. Seule différence attendue :
f2b-table(créée au premier bannissement). Parcours complet rejoué ensuite : « Pas encore réussi » 6/10 puis « Réussi » 10/10.
Comprendre
Un serveur qui « marche » n'est pas un serveur qui revient : trois valeurs par défaut raisonnables pour un cloud générique (VM éteintes après redémarrage, réserve mémoire minime, quota de 10 instances) auraient fait échouer une vraie classe, et aucune ne se voyait en usage normal. On ne les trouve qu'en provoquant la situation : charger le serveur, le redémarrer, restaurer une sauvegarde. C'est aussi vrai des sauvegardes : celle-ci « marchait » depuis l'étape 3, mais ne contenait pas la base du portail — seule une vraie restauration l'a montré. Enfin, une mesure dépend de la manière de mesurer : le même chemin a donné 5,5 s ou 2,8 s selon que le script imitait ou non le navigateur.
Suite
Étape 14 : pages légales et RGPD.
2026-10-09 — Étape 14 : pages légales et RGPD
Fait
- Purge RGPD : bouton « Archiver la classe » côté prof (plus de séance possible, date de suppression affichée, désarchivage possible d'ici là) et commande
classes:purger-archivees, chaque nuit : une classe archivée depuis 90 jours est supprimée avec, en cascade, ses élèves, séances, sessions, machines et corrections. Le compte du prof reste ; le journal ne garde ni nom ni prénom. - Pages légales réécrites d'après le système réel :
/legal(mentions légales : Esquisse Formation, SIREN et adresse à compléter par Adam ; hébergeur vérifié à la source, mentions légales de Scaleway ; CGU) et/confidentialite(données réellement traitées, finalités et bases légales, durées vérifiées sur le serveur, hébergement et tiers, sécurité, cookies, droits, sous-traitance)./statutredirige vers la page de statut. - Plus aucun envoi d'adresse IP chez Google : Archivo et IBM Plex Mono sont téléchargées à la compilation et servies par le portail (
Vite::fonts()), au lieu de Google Fonts, chargé jusque-là sur toutes les pages, espace élève compris. xterm.js et CodeMirror servis depuispublic/vendor/(copiés des paquets npm à la compilation) au lieu de jsDelivr. - Journaux : Laravel passe d'un fichier unique jamais purgé à un fichier par jour gardé 14 jours (comme Nginx).
- Interdits du prompt : aucun logo Marianne, ministère, drapeau de l'UE ni « logo RGPD » sur le site. Bandeau « Aperçu d'une fonction en préparation — données fictives » sur les 32 pages d'aperçu (choix d'Adam) ; vrais établissements des tableaux de bord remplacés par des noms fictifs ; « 20 cursus » corrigé en « 6 cursus » ; « 99 cours » présenté comme un programme en cours de publication ; « Conforme au RGPD » relié à
/confidentialite; « Hébergé en France · Paris » ramené à « Hébergé en France » (commune du datacenter non vérifiable sur le site de Scaleway). - 8 nouveaux tests Pest (33 au total) ; runbook
docs/runbooks/rgpd.md.
Problèmes rencontrés et solution
- La page
/legaldu design décrivait un système qui n'existe pas : « aucun traceur tiers » et « les données ne quittent pas l'UE » alors que Google Fonts était chargé partout, un « mode examen » qui enregistre les changements d'onglet (non construit), « temps passé, réponses aux QCM » (non collectés). Réécrite affirmation par affirmation. - Faux clients et chiffres inventés dans les pages d'aperçu (« Lycée Vauban Brest », « 608 élèves »…). Mon premier test cherchait nom et ville collés et a laissé passer les tableaux où ils sont dans deux cellules (« Lycée Vauban | Brest ») : test réécrit pour chercher chaque nom seul, sur les 32 aperçus — il a ensuite trouvé
/paiementet les dernières lignes de/academie. - La CSP de l'étape 12 cassait probablement l'exécution de code sur
/ide(Pyodide doit télécharger ses fichiers et compiler du WebAssembly) :connect-srcet'wasm-unsafe-eval'ajoutés pour la vitrine. Non vérifié dans un navigateur (extension non connectée). - Deux erreurs dans mes tests :
toContain($texte, $message)de Pest traite le second argument comme un autre texte à trouver ; le test « toutes les pages en 200 » ne prévoyait pas la redirection voulue de/statut. - Tinker lancé avec un fichier est resté bloqué en attente de saisie : remplacé par des tests Pest permanents (aucune police externe sur aucune page ; aucune ressource externe sur les pages de l'application).
Critère vérifié
- Pages en ligne :
/legal200,/confidentialite200,/statut→ 302 vershttps://status.syllabuslab.fr/; page de statut publique opérationnelle (étape 12). - La purge fonctionne, prouvée par Pest (
PurgeRgpdTest, 5 tests) : classe archivée depuis 91 jours supprimée avec élèves, séances, sessions, machines et corrections, compte du prof conservé ; classes archivées depuis 89 jours et classes actives épargnées ; seules les classes concernées touchées ; archivage par le prof, refus pour un autre prof. En production :classes:purger-archivees→ « 0 classe(s) purgée(s) » (aucune classe archivée), planifiée à 02:30. /connexionne charge plus que des ressources desyllabuslab.fr; aucune mention de Google Fonts sur l'accueil ; 0 violation CSP depuis le déploiement.
Comprendre
Une politique de confidentialité n'est pas un texte juridique à recopier : c'est une description exacte de ce que fait le système, et elle engage. Celle du design promettait « aucun traceur tiers » pendant que chaque page, y compris celles des élèves mineurs, envoyait l'adresse IP du visiteur à Google pour charger une police. Le bon réflexe est de partir de la réalité (que collecte-t-on ? où ? combien de temps ? qui d'autre la voit ?), de la vérifier sur le serveur, puis de corriger le système quand la réalité n'est pas défendable — ici, servir les polices nous-mêmes — plutôt que d'adapter le texte. La purge suit la même logique : une durée de conservation écrite n'a de valeur que si un programme l'applique, et un test prouve qu'il l'applique.
Suite
- Adam : compléter forme juridique, SIREN, adresse du siège (et DPO s'il y en a un) dans
/legalet/confidentialite; vérifier/idedans un navigateur. - Étape 15 : visio de classe — seulement après validation des étapes 1 à 11 (le prompt l'exige).
2026-10-09 — Étape 14 (suite) : retours du test d'Adam dans son navigateur
Adam a ouvert /ide et transmis la console du navigateur, plus sa fiche INPI.
/iden'a jamais fonctionné en production : « Uncaught SyntaxError: Unexpected token '.' ». Dans la maquettedesign/elle-même, des!!x(conversion en booléen) sont devenus.x(4 occurrences, seulement dans/ide, vérifié par une recherche sur tous les scripts). Corrigé dans le portail (pas dansdesign/). Nouveau test Pest : chaque script en ligne de chaque page publique est analysé parnode --check; vérifié qu'il échoue sur l'ancienne version.- Aperçu HTML de
/idehors de son bac à sable :sandbox="allow-scripts allow-same-origin"faisait tourner le code de l'élève avec l'originesyllabuslab.fr. Passé àallow-scriptsseul ; la vérification automatique s'exécute dans l'aperçu et renvoie ses résultats parpostMessage(délai de 3 s si la page de l'élève ne répond pas). - Régression introduite à l'étape 14 :
public/css/syllabus-theme.cssimportait Mulish (police principale de tout le site) depuis Google par@import. Mon test ne regardait que le HTML ; la CSP durcie bloquait l'appel, donc plus de fuite d'IP, mais le site s'affichait avec une police de remplacement. Mulish ajoutée aux polices servies par le portail,@importretiré, test étendu aux feuilles de style depublic/css. - Déploiement en échec passé inaperçu :
npm run build, lancé en root sur le serveur, a créépublic/vendor/au nom de root ; lersyncsuivant (compteadam) n'a pas pu y écrire. Mon filtre de sortie n'affichait ni l'échec ni le récapitulatif : je l'ai pris pour un succès, et c'est la vérification en production qui a montré que rien n'avait changé.public/vendor/exclu dursync(même piège qu'à l'étape 9). Règle pour la suite : toujours lire la lignePLAY RECAP(et le code de sortie) avant de conclure. - Mentions légales complétées d'après la fiche INPI : Adam Belkacemi, entrepreneur individuel (EI), nom commercial Esquisse Formation, SIREN 838 093 268, SIRET 838 093 268 00087, 30 rue Lorenzaccio, 38100 Grenoble. La date de naissance de la fiche n'est pas reprise. Restent à compléter : l'article « Disponibilité et responsabilité » des CGU, et un éventuel délégué à la protection des données.
Vérifié en production : /ide 1 script en ligne, 0 erreur de syntaxe, sandbox="allow-scripts" ; Mulish déclarée par le portail ; aucun @import vers Google ; /vendor/xterm/xterm.js et /vendor/codemirror/codemirror.js en 200. 35 tests Pest.
2026-10-09 — Accès des élèves : rejoindre la classe avec son code, fiches en lot
Question d'Adam : comment l'élève obtient-il un accès après sa création ? Faut-il un e-mail automatique pour créer un mot de passe ?
Réponse et décision : pas d'e-mail. Le CLAUDE.md l'interdit pour les mineurs (minimisation) et range l'invitation par e-mail en V2 (pour les majeurs) ; le serveur n'a pas non plus de service d'envoi (il faudrait un compte chez un prestataire, ouvert par Adam). Le parcours existant (identifiant + code secret + QR à usage unique, sur une fiche imprimable) restait valable, avec deux défauts : la fiche ne s'affichait qu'une fois, et le code de classe affiché ne servait à rien. Adam n'ayant pas répondu au choix proposé, j'ai construit les deux options sans dépendance extérieure :
- Rejoindre avec le code de classe (façon n°1 du CLAUDE.md) :
/rejoindre(code + prénom + nom facultatif, limité à 10 envois par minute) → demande visible sur la page de la classe (rafraîchie toute seule quand une demande arrive) → le prof accepte → le navigateur de l'élève affiche une seule fois son identifiant et son code, et le connecte. Le code en clair n'existe que dans la demande (chiffré parAPP_KEY), supprimée à l'affichage ; une demande non traitée est supprimée au bout d'une heure (model:prune, tous les quarts d'heure). Seul le navigateur qui a envoyé la demande peut la suivre (jeton en session, empreinte SHA-256 en base). Classe archivée ou code faux : refus. Le prof projette le code ou un QR qui pré-remplit le code. - Fiches en lot : cases à cocher dans la liste des élèves, « Nouvelles fiches pour la sélection » (nouveaux codes et QR, anciens codes invalidés).
- Lien « Rejoins ta classe avec son code » sur la page de connexion ; politique de confidentialité mise à jour (nouvelle donnée : la demande, au plus une heure).
- 6 tests Pest (41 au total) ; vérifié en production :
/rejoindre200 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.
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 Ansiblevisio: configuration d'après ledocker-compose.ymlofficiel, 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-visioautorisé enforwardpour 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.jsservi parmeet). - Main levée synchronisée : l'événement Jitsi
raiseHandUpdatedmet à 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.frajouté au certificat ; site intégrable dans le seul portail (frame-ancestors) ;Permissions-Policydu portail : caméra, micro et partage d'écran délégués àmeetseulement ; CSP stricte complétée (frame-srcetscript-srcversmeet).- 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
- Conteneurs Jitsi en boucle : leurs volumes doivent appartenir à l'uid 1000 (ils tournent sous cet utilisateur).
- Jicofo ne joignait pas Prosody (« Connect timed out ») : le module
br_netfilterfait passer le trafic entre conteneurs d'un même réseau Docker par la chaîneforward, dont la politique estdrop. Jamais vu avant parce que tous les autres conteneurs ne sont joints que depuis l'hôte. Règlebr-visio(accord d'Adam). - DNS de
meetabsent au départ : test complet d'abord joué en résolvantmeetvers 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. - Tinker resté ouvert en attente de saisie sur le serveur, bloquant la suite : lancé désormais avec
< /dev/null(runbook). - 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_affiliationseul attribue les rôles. Vérifié dans les journaux : élèvesPARTICIPANT, 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.