diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c4e02b..32ba71c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # CHANGELOG — Set-OPS +## 2026-08-09 — P33 : deux rôles co-localisés ne revendiquent pas le même port + +Deuxième des trois chantiers ouverts par la reconstruction. Il retrouve son défaut nº 6 à +froid, sans machine. + +Un port n'appartient à personne : le premier service démarré le prend, l'autre échoue. +Sur `infra-mail-01`, le SASL de Dovecot (12345, choix délibéré) et l'interface HTTP d'Alloy +(12345, défaut amont) se le disputaient **depuis le premier jour** — et c'est Dovecot qui +perdait, sans que rien ne le dise. Il a fallu inverser l'ordre de démarrage, ce que fait un +rejeu depuis zéro, pour que ça devienne audible. + +**Le contrôle n'était possible qu'après avoir déclaré le port d'Alloy.** C'est la vraie +leçon du défaut nº 6 : un port **subi** — le défaut amont d'un logiciel qu'on n'a pas +choisi — n'existe pour aucun registre, donc aucune preuve ne peut le voir. Il faut +l'imposer pour pouvoir le vérifier. + +**`partage: true`**, nouveau mot du registre des flux, distingue deux situations qu'il +confondait : un rôle qui **ouvre** une écoute, et un rôle qui **décrit** celle d'un autre — +`serveur_backup` empruntant le sshd de `serveur_debian`. Sans lui, la seule co-location +légitime de la flotte (`tcp/22` sur `backup-01`) serait signalée à tort. Une preuve qui +crie sur un cas sain finit par être ignorée : c'est pire que de ne pas l'avoir. + +**Vérifié dans les deux sens.** Sur la flotte réelle : 32 revendications, aucune collision. +En remettant le port d'Alloy à 12345 comme hier : `infra-mail-01 : tcp/12345 revendiqué par +client_journal et serveur_dovecot`, code 1 — le défaut nº 6 reproduit sans toucher à une +machine. + +Harnais : **33 preuves, 0 échec, 0 sautée.** + ## 2026-08-09 — P32 : un `assert` de rôle est un contrat, et l'instance doit l'honorer Le premier des trois chantiers que la reconstruction avait rendus évidents. Il aurait diff --git a/Makefile b/Makefile index 3989c08..a31cf20 100644 --- a/Makefile +++ b/Makefile @@ -438,6 +438,9 @@ certificats-plan: ansible-runtime ## Ecart entre les certificats sur disque et c @ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-certificats.yml >/dev/null @python3 scripts/devis_certificats.py +ports-verifier: ## Deux roles co-localises revendiquent-ils le meme port ? (lecture seule) + @python3 scripts/verifier_ports.py + intrants-verifier: ## Tout intrant qu'un role EXIGE est-il fourni par l'instance ? (lecture seule) @python3 scripts/verifier_intrants.py diff --git a/docs/audit/preuve-2026-08-09.md b/docs/audit/preuve-2026-08-09.md index 24c31f1..88c59a0 100644 --- a/docs/audit/preuve-2026-08-09.md +++ b/docs/audit/preuve-2026-08-09.md @@ -7,7 +7,7 @@ > [`docs/audit/affirmations.md`](affirmations.md). - **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml` -- **Verdict** : ✅ CONFORME (32 OK · 0 echec · 0 saute) +- **Verdict** : ✅ CONFORME (33 OK · 0 echec · 0 saute) ## Preuves @@ -43,8 +43,9 @@ | P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 2 pool(s) Proxmox, 28 VM placee(s), aucun nom ni VMID en collision. | | P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 23 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 12, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv | | P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 2 zone(s), 12 VNet(s), 12 sous-reseau(x), aucune collision. | -| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 38 scripts expliques et atteignables, 88 cibles make documentees, 54 roles avec README. | +| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 39 scripts expliques et atteignables, 89 cibles make documentees, 54 roles avec README. | | P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 30 exigence(s) de role, toutes satisfaites (126 cle(s) declaree(s) par l'instance). | +| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 32 revendication(s) de port, aucune collision entre roles co-localises (33 groupes). | ## Couverture des affirmations ✅ du registre diff --git a/docs/carte-set-ops.md b/docs/carte-set-ops.md index 8b64076..c920b94 100644 --- a/docs/carte-set-ops.md +++ b/docs/carte-set-ops.md @@ -23,7 +23,7 @@ code + les README de rôles). Cette page comble ces deux trous. | **Ordre de déploiement** | `docs/couches-deploiement.yml` (couches) + `docs/dependances-groupes.yml` (graphe) → `playbooks/site.yml` (**généré**, `make site`) | | **Conformité du déployé** | `docs/devis-services.md` — les **cinq devis de service** (`make identite-plan`, `certificats-plan`, `expositions-plan`, `postgresql-plan`, `courriel-plan`). Répondent à ce que `make prouver` ne demande jamais : *ce qui tourne correspond-il à ce qui est déclaré ?* | | **Preuve / recette** | `docs/audit/affirmations.md` (registre), `make prouver` → `docs/audit/preuve-.md` — **statique** : lit le dépôt, aucun appel réseau ; la conformité du déployé est l'affaire des devis de service (ligne au-dessus), `docs/audit/plan-de-recette.md` (**généré** du wiki), `docs/audit/protocole-operateur-independant.md` | -| **Décisions d'architecture** | `docs/decisions-architecture.md` — **69 décisions en vigueur** (D-01 → D-72, 3 renversées), pourquoi, où lire le détail, et ce qui les garde ; plus les **décisions renversées** et leur cause | +| **Décisions d'architecture** | `docs/decisions-architecture.md` — **70 décisions en vigueur** (D-01 → D-73, 3 renversées), pourquoi, où lire le détail, et ce qui les garde ; plus les **décisions renversées** et leur cause | | **SDN / routage** | `docs/sdn-evpn.md` — décision du 2026-08-02 : le routage inter-zone passe des commutateurs aux hyperviseurs (zones EVPN = VRF). **Non éprouvé** : spike avant génération | | **Migration de tenant** | `docs/migration-tenant.md` — recette en 8 étapes, machine à états, gardes ; le receveur se construit **avant** tout gel | | **Exploitation courante** | `docs/runbooks-exploitation.md`, `docs/intrants-communs.md`, `docs/intrants-base-gui-conception.md`, `docs/theme-forgejo-hors-flotte.md` | diff --git a/docs/decisions-architecture.md b/docs/decisions-architecture.md index 08ca2e3..1b69bad 100644 --- a/docs/decisions-architecture.md +++ b/docs/decisions-architecture.md @@ -108,6 +108,7 @@ sont les seules vérifiables. | **D-68** | On **écrit, puis on relit et on compare** — quelle que soit l'interface ; on choisit celle dont le chemin de **lecture** parle le même langage que le chemin d'**écriture** | « toujours préférer l'API » n'aurait prédit aucune des pannes du 2026-08-08 : sur six familles de défauts, deux venaient d'un CLI, une d'un module Ansible (`ldap_entry` crée sans jamais modifier), une d'un `grep` de fichier, une de la précédence Ansible, une de mon comparateur. Le facteur commun est d'avoir écrit sans relire. Et la plupart de la flotte n'a **pas** d'API — Postfix, Dovecot, nginx, slapd, nftables : `postconf -h` / `postconf -e` sont symétriques, c'est tout ce qu'on demande | `devis-services.md` | les 5 devis | | **D-69** | Sur Keycloak : **l'API pour toute map ou collection** (`smtpServer`, `attributes`, `config`), `kcadm` pour les scalaires et les créations | `kcadm -s` sur une map accepte la commande, **sort en succès et n'écrit rien** — mesuré deux fois le 2026-08-08 (`smtpServer` resté vide après deux déploiements verts, puis `post.logout.redirect.uris`). Le CLI reste préféré ailleurs : c'est le vocabulaire de la documentation du produit, donc lisible sans IA | `roles/serveur_keycloak/tasks/` | `make identite-plan` | | **D-72** | Un `assert` de rôle est un **contrat d'intrant**, et l'instance doit l'honorer — vérifié hors ligne | la première reconstruction from-zero s'est arrêtée sur `amorcage_acces_courriel` : obligatoire depuis le matin, déclaré par aucun tenant, et invisible parce que le compte existait déjà — la garde n'avait jamais eu l'occasion de se déclencher. Un intrant est satisfait par un **défaut non vide**, un `set_fact` de résolveur, ou une déclaration de l'inventaire (fichier `hosts.yml` compris) | `scripts/verifier_intrants.py`, `make intrants-verifier` | **P32** | +| **D-73** | Tout port **lié** se déclare, et deux rôles co-localisés ne peuvent pas revendiquer le même | un port n'appartient à personne : le premier démarré le prend, l'autre échoue — parfois **en silence**. Sur `infra-mail-01`, le SASL de Dovecot et l'interface d'Alloy se disputaient le 12345 depuis le premier jour, et c'est Dovecot qui perdait sans que rien ne le dise. Le contrôle n'était possible qu'une fois le port d'Alloy — un défaut amont **subi** — déclaré. `partage: true` distingue « j'ouvre cette écoute » de « je décris celle d'un autre » | `scripts/verifier_ports.py`, `make ports-verifier` | **P33** | | **D-70** | La documentation **dit et explique tout ce que le dépôt fait** — et l'exigence est **outillée**, pas seulement énoncée | une exigence qu'on n'outille pas pourrit en silence : la carte annonçait « 28 décisions » quand il y en avait 66, et disait les accès « non construits » alors qu'ils tournaient en production. **P31** garde le couvert — chaque script s'explique et reste atteignable, chaque cible `make` porte son aide (sauf les internes préfixées `_`), chaque rôle a son README. Elle ne garde **pas** la qualité du « pourquoi » : ça se juge en revue, et ça vit dans `CHANGELOG.md` et ici | `devis-services.md`, `CHANGELOG.md` | **P31** | | **D-71** | **Une PKI et un DNS fonctionnels avant toute chose** ; puis, par VM : socle → enrôlement PKI → enregistrement DNS (A **et** PTR) | `deployer-tout` déroule par COUCHES — correct, mais chaque VM réclame alors un certificat à une autorité pas encore debout, et l'échec se lit comme un défaut du rôle et non d'ordre. Les deux hôtes d'amorçage se **dérivent** de `applications..hote` : déplacer l'autorité déplace l'amorçage. **Deux exceptions structurelles assumées** — l'AC s'auto-signe, le DNS pose son propre enregistrement | `Makefile` `_amorcer-socle`, `scripts/socle_amorcage.py` | — | diff --git a/docs/flux-conception.md b/docs/flux-conception.md index 88d7f7d..4597d87 100644 --- a/docs/flux-conception.md +++ b/docs/flux-conception.md @@ -56,3 +56,24 @@ non activé* tant que le registre n'est pas complet et validé. 2. le **résolveur** (agrégation → règles + registre) ; 3. **remplir** tous les rôles (large transcription du travail zéro-confiance déjà fait) ; 4. **générer** + registre d'audit ; puis **activer** nftables nœud par nœud. + +## `partage:` — décrire une écoute plutôt que l'ouvrir + +Ajouté le 2026-08-09 avec **P33**, qui refuse que deux rôles co-localisés revendiquent le +même port. + +Le registre confondait deux situations : + +| Situation | Exemple | `partage` | +|---|---|---| +| le rôle **ouvre** l'écoute | `serveur_dovecot` lie 12345 (SASL réseau) | absent | +| le rôle **décrit** celle d'un autre | `serveur_backup` emprunte le sshd de `serveur_debian` | `true` | + +Sans cette distinction, la seule co-location légitime de la flotte — `tcp/22` sur +`backup-01` — serait signalée à tort. Une preuve qui crie sur un cas sain finit par être +ignorée, ce qui est pire que de ne pas l'avoir. + +**Corollaire à retenir** : un port qu'on **subit** (le défaut amont d'un logiciel) doit être +imposé et déclaré comme les autres. Celui d'Alloy ne l'était pas, et c'est la seule raison +pour laquelle la collision a pu durer des semaines. + diff --git a/roles/serveur_backup/meta/flux.yml b/roles/serveur_backup/meta/flux.yml index ebb4b2f..a43aa10 100644 --- a/roles/serveur_backup/meta/flux.yml +++ b/roles/serveur_backup/meta/flux.yml @@ -6,4 +6,9 @@ flux: protocole: tcp pair: client_backup chiffrement: ssh + # Ce role N'OUVRE PAS ce port : il decrit l'ecoute de `serveur_debian`, qu'il + # emprunte avec un utilisateur restreint. Sans ce mot, P33 y verrait une collision + # avec le SSH d'administration sur `backup-01` — deux roles co-localises revendiquant + # le 22 — alors qu'il n'y a qu'un seul sshd. + partage: true raison: "Dépôt restic servi par SSH (utilisateur restreint restic + clé) ; chaque client_backup pousse ses instantanés." diff --git a/scripts/prouver.py b/scripts/prouver.py index d4225dd..12792af 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -573,6 +573,8 @@ PREUVES: list[dict] = [ "func": preuve_documentation_outillage}, {"id": "P32", "titre": "Intrants exiges par les roles : tous fournis", "refs": [], "cmds": [[sys.executable, "scripts/verifier_intrants.py"]]}, + {"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [], + "cmds": [[sys.executable, "scripts/verifier_ports.py"]]}, ] diff --git a/scripts/verifier_ports.py b/scripts/verifier_ports.py new file mode 100755 index 0000000..2635f6e --- /dev/null +++ b/scripts/verifier_ports.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Deux rôles co-localisés revendiquent-ils le même port ? + +Un port n'appartient à personne : le premier service démarré le prend, et l'autre boucle +sur `bind: address already in use`. Rien ne vérifiait qu'un hôte ne porte pas deux rôles +qui veulent le même. + +Le défaut n'est pas théorique. Sur `infra-mail-01`, l'écoute SASL de Dovecot (12345, +choix délibéré de Set-OPS) et l'interface HTTP d'Alloy (12345, défaut amont) se disputent +le port depuis le premier jour. En exploitation courante Alloy le tenait, et c'est Dovecot +qui échouait — **en silence**. Il a fallu une reconstruction from-zero, qui inverse l'ordre +de démarrage, pour que ça devienne audible (2026-08-08). + +**La condition pour que ce contrôle existe** : que tout port lié soit DÉCLARÉ. Celui +d'Alloy ne l'était pas — c'était un défaut amont subi, donc invisible. Il l'est désormais. + +**`partage: true`** distingue les deux situations que le registre confondait : + + - un rôle qui **ouvre** une écoute → il revendique le port ; + - un rôle qui **décrit** l'écoute d'un autre pour la documenter ou l'autoriser + (`serveur_backup` empruntant le sshd de `serveur_debian`) → pas une revendication. + +Sans cette distinction, la seule co-location légitime de la flotte serait signalée à tort, +et on apprendrait à ignorer la preuve — ce qui est pire que de ne pas l'avoir. +""" + +from __future__ import annotations + +import argparse +import collections +import sys +from pathlib import Path + +import yaml + +RACINE = Path(__file__).resolve().parent.parent + + +def _groupes(inventaire: Path) -> dict[str, set[str]]: + """Groupe -> hotes, en descendant les `children` de l'inventaire YAML.""" + inv = yaml.safe_load(inventaire.read_text(encoding="utf-8")) or {} + trouves: dict[str, set[str]] = collections.defaultdict(set) + + def marcher(noeud, nom): + if not isinstance(noeud, dict): + return + if nom and isinstance(noeud.get("hosts"), dict): + trouves[nom] |= set(noeud["hosts"]) + for enfant, sous in (noeud.get("children") or {}).items(): + marcher(sous, enfant) + + marcher(inv.get("all") or {}, "all") + return trouves + + +def _revendications() -> dict[tuple[str, str], set[str]]: + """(protocole, port) -> roles qui OUVRENT cette ecoute.""" + par_port: dict[tuple[str, str], set[str]] = collections.defaultdict(set) + for f in sorted(RACINE.glob("roles/*/meta/flux.yml")): + role = f.parts[-3] + d = yaml.safe_load(f.read_text(encoding="utf-8")) or {} + for fl in d.get("flux") or []: + if fl.get("sens") != "ingress" or fl.get("partage"): + continue + port = str(fl.get("port")) + if not port.isdigit(): # `derive`, codes ICMP : pas une ecoute fixe + continue + par_port[(str(fl.get("protocole", "tcp")).lower(), port)].add(role) + return par_port + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--inventaire", default=None) + a = ap.parse_args(argv) + + inventaire = Path(a.inventaire) if a.inventaire else \ + (RACINE / "instance" / "inventories" / "principal" / "hosts.yml") + if not inventaire.is_file(): + print(f"Inventaire introuvable : {inventaire}", file=sys.stderr) + return 2 + + groupes = _groupes(inventaire) + par_port = _revendications() + + conflits: list[str] = [] + for (proto, port), roles in sorted(par_port.items()): + if len(roles) < 2: + continue + hotes = set().union(*[groupes.get(r, set()) for r in roles]) + for hote in sorted(hotes): + ici = sorted(r for r in roles if hote in groupes.get(r, set())) + if len(ici) > 1: + conflits.append(f"{hote} : {proto}/{port} revendique par " + " et ".join(ici)) + + if conflits: + print(f"{len(conflits)} collision(s) de port entre roles co-localises :\n") + for c in conflits: + print(f" {c}") + print("\nLe premier service demarre prendra le port ; l'autre echouera, peut-etre\n" + "en silence. Deplacer l'un des deux, ou marquer `partage: true` si les deux\n" + "decrivent la MEME ecoute.") + return 1 + + total = sum(len(r) for r in par_port.values()) + print(f"CONFORME : {total} revendication(s) de port, aucune collision " + f"entre roles co-localises ({len(groupes)} groupes).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())