Paillasse/docs/journal.md
Adam Belkacemi 30e14b1c54 ajoute le portail Laravel : connexion prof/élève, session unique, gestion de classe, salle d'attente
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 02:01:58 +02:00

37 KiB
Raw Blame History

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_claude avait 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 paillasse créé dans ~/.ssh/config (163.172.4.195, utilisateur adam, 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_askpass absent). Solution : Adam a retiré la passphrase lui-même avec ssh-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/) + playbook ansible/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 (jail sshd, backend nftables car pas d'iptables sur l'image), chrony (remplace systemd-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-run de 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

  1. Bug dans le rôle : le notify pour recharger sshd manquait sur la tâche de dépôt de la config SSH → sshd jamais rechargé au 1er essai → le filet de sécurité s'est déclenché (normal, rien de cassé) → corrigé en ajoutant le notify.
  2. Le test de vérification nmap -p- (65535 ports) était beaucoup trop lent face à une politique drop (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).
  3. 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 = 5 de 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) avec fail2ban-client set sshd unbanip. Post-mortem complet : docs/postmortems/2026-10-07-fail2ban-auto-ban.md. Correctif : maxretry relevé à 10.

Critère vérifié

  • Connexion par clé : OK. Mot de passe : refusé (Permission denied (publickey), sans mention de password dans les méthodes offertes). Root : refusé. nmap -Pn -T4 --open 163.172.4.195 depuis 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/) + playbook ansible/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, /root en tar.zst, chiffre en flux avec age, envoie en FTP vers Dedibackup avec lftp, 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 par bash si 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), car tar retire le / initial à la création (etc/..., sans ./). Corrigé dans le runbook.
  • etc/os-release n'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, /root seulement) — 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/hostname extrait 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 + playbook ansible/reseau-openstack.yml (qui rejoue aussi durcissement pour 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, futur network_interface / kolla_internal_vip_address (Kolla sans HAProxy).
  • Paire veth : veth-ext-ovs (sans IP, futur neutron_external_interface) ↔ veth-ext-gw (172.24.4.1/24, passerelle du réseau externe OpenStack).
  • Persistance par systemd-networkd, limité à ces trois interfaces : eno1 reste sous ifupdown, 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îne forward resserrée à la demande d'Adam : seulement veth-ext-gw → eno1 depuis 172.24.4.0/24, et les réponses eno1 → 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éé par durcissement, qui tourne après reseau-openstack. Échec survenu avant toute modification du pare-feu. Corrigé en créant aussi le dossier dans reseau-openstack.
  • Un lancement a été bloqué par le garde-fou de Claude Code parce que la règle forward avait é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-table de 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, dummy0 et 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] sur md0 et md1.
  • nmap depuis 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 + playbook ansible/kolla.yml : dépendances système, venv /opt/kolla-venv (Kolla-Ansible stable/2025.2, version 21.3.1.dev4, ansible-core 2.19, client openstack 8.2.0, SDK docker et dbus-python), /etc/kolla/globals.yml et all-in-one copiés depuis le dépôt (kolla/), passwords.yml généré par kolla-genpwd sur le serveur uniquement (600 root:root, jamais dans Git), install-deps.
  • kolla/globals.yml : images Debian bookworm (tag vérifié sur quay.io), API sur dummy0, réseau externe veth-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ésactiver ufw (absent) et ne touche ni à nftables ni à l'heure.
  • bootstrap-servers, prechecks, deploy (lancé comme service systemd-run pour survivre à une coupure SSH, 22 min), post-deploy.
  • ADR docs/adr/0001-kolla-2025-2-sur-debian-12.md, runbook docs/runbooks/openstack.md.
  • Correction fail2ban validée par Adam à l'étape 4 : le handler recharger nftables relance aussi fail2ban (handler listen), et une option -e durcissement_forcer_rechargement=true force un rechargement du pare-feu.

Problèmes rencontrés et solution

  1. install-deps : No such file or directory: 'ansible-galaxy' → le bin du venv doit être dans le PATH.
  2. bootstrap-servers a modifié le pare-feu (arrêt et accord d'Adam). Docker 29 a créé des tables ip6 nat et ip6 filter malgré iptables: false : depuis Docker 27, l'IPv6 a son propre réglage. Aucun impact sur la sécurité, car notre chaîne forward en drop l'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.
  3. prechecks : No module named 'docker' → Kolla utilise le Python du venv ; docker et dbus-python ajoutés au venv.
  4. 1er deploy en échec : MariaDB Bind 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 volume proxysql ont été supprimés avec l'accord d'Adam. Le volume proxysql_socket est 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-compute up. Agents OVN (Controller Gateway, Metadata) vivants.
  • nmap depuis 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-table recréé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 (ovsdb-server, 6641/6642) écoutent sur 0.0.0.0 corrigé le 2026-10-07 (voir plus bas).
  • 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_socket supprimé (accord d'Adam), après proxysql_socket lui-même supprimé lors du correctif ProxySQL.
  • Écoute OVN restreinte à 10.10.0.1. Cause : ansible/roles/ovn-db/tasks/bootstrap-db.yml de Kolla fait ovn-nbctl/ovn-sbctl set-connection ptcp:PORT:0.0.0.0 en dur à la création des bases, sans tenir compte de api_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 de ansible/roles/kolla/tasks/main.yml, tag corrections.
    • Vérifié : ss -tlnp montre les 4 ports OVN (6641-6644) sur 10.10.0.1 uniquement ; un second passage du playbook ne rapporte aucun changement (idempotence confirmée) ; les agents OVN et les services Nova restent up après coup.

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 + playbook ansible/images.yml : installe libguestfs-tools, dépose openstack/images/construire-paillasse-debian.sh et paillasse-contenus/07-sql/lycee.sql sur le serveur.
  • paillasse-contenus/07-sql/lycee.sql créé : tables classes, eleves, notes avec données d'exemple. Volontairement sans la vue moyennes, la table clubs ni l'utilisateur prof : c'est le travail demandé à l'élève.
  • Projet OpenStack paillasse, utilisateur de service paillasse-service (mot de passe généré aléatoirement, jamais affiché, jeté après usage), application credential portail-syllabus (rôle member) enregistrée dans /etc/kolla/clouds-paillasse.yaml (600 root:root, jamais dans Git — .gitignore élargi à clouds-*.yaml et *application-credential*.json).
  • Image paillasse-debian construite avec virt-customize depuis debian-12-genericcloud-amd64.qcow2 officiel (somme SHA512 vérifiée) : qemu-guest-agent, getty sur ttyS0, utilisateur eleve (sudo, mot de passe eleve), outils réseau, nginx/mariadb-server/frr installé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 avec hw_qemu_guest_agent=yes, rendue public (sinon invisible aux autres projets).
  • Flavor eleve.petit : 1 vCPU, 768 Mo, 5 Go.
  • VM de test vm-test-console dé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, module websocket-client) : connexion eleve/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

  1. openstack user create --password-prompt : No terminal detected, aucun TTY dans ce contexte. Remplacé par --password avec un mot de passe généré par openssl rand, jamais affiché ni réutilisé (l'authentification définitive passe par l'application credential).
  2. openstack application credential create -f json : KeyError: 'id'. Le client renvoie des clés en majuscule (ID, Secret...), pas en minuscule. Corrigé.
  3. openstack server create --project paillasse : --project n'existe pas sur cette sous-commande (la portée vient du token). Résolu en utilisant directement le cloud paillasse-service (application credential), déjà scopé au bon projet.
  4. Image invisible pour paillasse-service : créée avec visibilité shared (nécessite une acceptation explicite par projet). Passée en public, cohérent avec un usage par tous les établissements plus tard.
  5. 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-protocole binary). Corrigé avec ws.send_binary().
  6. 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 ACTIVE en ~25 s.
  • Script connecté au WebSocket de la console série : prompt localhost login: affiché, connexion réussie, commande whoami && 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 : tree retiré de la liste des outils préinstallés (confirmé par Adam : le lab 02 garde son intérêt pédagogique).
  • VM vm-test-console et réseau reseau-test supprimé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 sur eno1 (déjà vérifié par nmap à l'étape 5, re-confirmé ici).
  • Rôle Ansible supervision + playbook ansible/supervision.yml : vérifie la présence et les permissions (600) de /root/.alertes-webhook sans jamais afficher son contenu, déploie le relais Alertmanager → ntfy (/usr/local/sbin/alertmanager-ntfy.py, service systemd, 127.0.0.1:9099 uniquement), et Uptime Kuma (conteneur Docker, 127.0.0.1:3001, volume nommé).
  • kolla/config/prometheus/alertes.rules : 4 règles (DisqueSature > 80 %, MemoireSaturee > 90 %, ServiceOpenStackArrete via les métriques openstack_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_volumes dans globals.yml) : sauvegarde-dedibackup.sh y é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 reconfigure a tourné sans erreur mais sur l'ancienne configuration — aucun message ne le signale. Corrigé en relançant le playbook Ansible concerné avant reconfigure. Noté dans docs/runbooks/supervision.md comme 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 simple awk sur le format clef: valeur de passwords.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/query renvoie 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). concurrently retiré du starter (2 vulnérabilités critiques via shell-quote, et en réalité inutilisé : php artisan dev de Laravel 13 gère déjà le lancement parallèle nativement).
  • Vues, routes, JS, CSS, images de design/ repris tels quels dans portail/resources et portail/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_secret haché, jeton_qr à usage unique, jeton_session_actif), Lab, Seance, LabSession.
  • Deux guards d'authentification (config/auth.php) : web (profs, email/mot de passe) et eleve (identifiant/code secret, sans e-mail — minimisation des données mineurs). App\Models\Eleve authentifiable via le trait Illuminate\Auth\Authenticatable et getAuthPasswordName().
  • Session unique par compte : trait GereSessionUnique (un jeton en base, recréé à chaque connexion) + middleware VerifieSessionUnique (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 via bacon/bacon-qr-code en SVG, fiches imprimables), salle d'attente de séance (/seances/..., rafraîchissement automatique, le prof admet).
  • Service GenerateurCompteEleve : identifiant prenom.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 sur 127.0.0.1:3307/127.0.0.1:6380 — jamais sur 10.10.0.1, réservé à Kolla), synchronisation du code par rsync, .env de production généré une seule fois (secrets jamais dans Git).

Deux décisions de design tranchées par Adam

  1. 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.
  2. É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

  1. @auth (sans argument) dans le layout partagé retournait true pour un élève connecté, affichant le menu du prof. Cause : le middleware auth:eleve appelle Auth::shouldUse('eleve'), ce qui change le guard par défaut pour le reste de la requête — @auth nu teste alors ce guard-là, pas forcément web. Corrigé en précisant partout @auth('web') / auth('eleve').
  2. Déploiement : j'ai modifié kolla/globals.yml et 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).
  3. php artisan key:generate échouait : lancé avant composer install, donc avant que vendor/autoload.php existe. Réordonné : code → composer install → clé/.env → npm → migrations.
  4. php artisan view:cache échouait (« View path not found ») : storage/framework/views avait été exclu du rsync mais n'était créé par rien d'autre. Plus largement, storage/ et bootstrap/cache/ ne doivent jamais être synchronisés depuis le poste de dev (ce sont de l'état et du cache runtime, pas du code — un rsync --delete y effacerait un jour les travaux déposés par les élèves dans storage/app/). Corrigé : leur structure est créée une fois sur le serveur, propriété www-data dès le départ, et ils sont exclus en bloc du rsync (pas juste leurs sous-dossiers).
  5. 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 -t puis systemctl 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.
  6. 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é sur http://163.172.4.195/connexion et 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 tables après déploiement : toujours seulement inet 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).