diff --git a/CHANGELOG.md b/CHANGELOG.md index f423cbf..3fcb795 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,86 @@ # CHANGELOG — Set-OPS +## 2026-10-09 (124) — La raison du flux doit survivre au chargement du pare-feu + +**94 preuves.** Les huit tests de libellés passent avec `make test`, ainsi que +`make flux-verifier` et `make syntaxe`. `make lint` passe avec trois avertissements +Jinja dans des playbooks de devis non modifiés. Le rapport du harnais est conservé dans +`docs/audit/preuve-2026-10-09.md` ; sa création porte les pièces d'audit à 52, reportées +dans la carte d'orientation pour P48. + +### Une raison au registre, absente du champ que l'opérateur lit + +Le registre expliquait chaque flux, mais nftables ne recevait que des commentaires de +fichier `#`, perdus au chargement. Proxmox ne gardait que le pair ou le rôle, OPNsense +l'identifiant technique et le rôle. Lire les corps envoyés aux API et le rendu nftables +a montré où la justification disparaissait. + +Les règles reçoivent maintenant leur commentaire natif : rôle et raison du registre, +pair nommé pour Proxmox, clé de rapprochement inchangée pour OPNsense. Le socle nftables +et son gabarit de repli décrivent aussi leurs règles communes. Les textes sont ramenés +sur une ligne ; les limites de longueur préservent les caractères UTF-8 et la clé +OPNsense. Une raison vide est refusée à la validation. + +### Corriger une description ne doit pas remplacer une règle + +Les devis ignoraient les changements de commentaire : enrichir la création seule aurait +laissé toutes les règles déjà posées avec leurs anciens libellés. Ils détectent désormais +ces écarts, mettent à jour le seul champ sur la règle existante, puis le relisent. Les +tests gardent l'activation et la politique, exigent un second passage sans changement, +et refusent une API qui annonce le succès sans conserver l'écriture. Les descriptions +de NAT et de redirection, et les affectations de groupes Proxmox, sont également couvertes. + +### Ce qui a été mesuré avant l'application + +Le devis OPNsense relu annonce **335 descriptions à corriger, aucune création et aucun +retrait**. Le devis Proxmox ne peut pas lire le cluster : connexion refusée, même hors +du bac à sable. Les 36 fichiers nftables du site et des trois instances ont été +régénérés et relus : les règles sont identiques après retrait des annotations dans +la comparaison. Aucun pare-feu n'a été rechargé. + +La vérification native `nft --check` en espace réseau isolé n'a pas pu s'exécuter : le +poste refuse la création de cet espace, y compris hors du bac à sable. L'écart ancien +sur `sorties_externes` dans le JSON de connectivité du laboratoire est signalé et laissé +intact ; aucun plan ni inventaire appliqué n'a été modifié. + +### Une relecture d'API compare les valeurs, pas la langue des menus + +L'application confirmée par l'opérateur a d'abord arrêté sa comparaison OPNsense après +une seule description : l'API venait aussi de remplir son audit (auteur et date). +Les lectures suivantes rendaient certains choix de menus en français ou en anglais. +Comparer les réponses brutes faisait donc croire à un changement de règle. La +comparaison a conservé les identifiants et les sélections de tous les choix, ainsi +que les notes d'audit ; seuls les textes traduits et l'audit automatique de création +et de mise à jour ont été neutralisés, en plus du champ descriptif attendu. + +**335 descriptions OPNsense appliquées et relues** : tous les paramètres de filtrage +restent identiques à la sauvegarde. Les quatre rechargements (alias, filtre, NAT +sortant et redirections) ont répondu en succès ; le devis final ne porte plus d'écart. + +### Le cluster se lit depuis son runner, et nftables se vérifie sur les VM + +Le refus d'accès direct à Proxmox n'était pas une indisponibilité du cluster : son +runner y accède. Le code local a été exécuté en mémoire depuis ce runner, sans +publication ni remplacement de son dépôt. **108 commentaires de règles et 90 +commentaires d'affectations**, sur les 26 VM des deux locataires, ont été appliqués +et relus ; tous les autres paramètres, dont l'activation, sont identiques. Le devis +final est sans écart. + +Sur les **neuf VM de SITE-Chezlepro**, `nft --check` passe. Une compilation dans un +espace réseau éphémère de chaque VM a permis de comparer le candidat aux règles +réellement chargées avant écriture. Les **237 règles** portent désormais leur +commentaire natif ; la relecture du noyau confirme le même filtrage et les commentaires +attendus. Une nouvelle connexion SSH a réussi après chaque chargement, le runner en +dernier. Les fichiers et règles précédents sont conservés sur chaque VM sous +`/var/backups/setops-libelles-20261009/` ; les relevés et sauvegardes d'API sont hors +dépôt, sur le poste, dans `/tmp/setops-libelles-application-20261009/`. + +Le lot confirmé excluait le déploiement nftables des **26 VM des locataires** (clés +d'hôte SSH changées, à vérifier) et du **laboratoire** (injoignable). Ils restent +non déployés. Le moteur et les fichiers générés des deux locataires et du site doivent +être publiés ensemble sur les deux forges (`make publier`) pour qu'une reconstruction +conserve ces libellés. + ## 2026-10-09 (123) — Chezlepro reconstruite sur `34e961f`, d'une traite : les deux locataires sans PyPI ni Galaxy **94 preuves.** `make reconstruire-locataire TENANT=OPS-Chezlepro` (43 min, journal diff --git a/Makefile b/Makefile index f8ab478..02486f8 100644 --- a/Makefile +++ b/Makefile @@ -334,6 +334,7 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire) python3 scripts/tests/test_empreinte_plancher.py python3 scripts/tests/test_expositions_sans_port.py python3 scripts/tests/test_proxmox_fw_externe.py + python3 scripts/tests/test_libelles_parefeu.py python3 scripts/tests/test_sonde_tcp.py python3 scripts/tests/test_restauration.py python3 scripts/tests/test_repertoires_partages.py diff --git a/docs/audit/preuve-2026-10-09.md b/docs/audit/preuve-2026-10-09.md new file mode 100644 index 0000000..1cd5b7f --- /dev/null +++ b/docs/audit/preuve-2026-10-09.md @@ -0,0 +1,130 @@ +# Preuve de conformite — Set-OPS — 2026-10-09 + +> Genere par `make prouver` (`scripts/prouver.py`). **Rejouable** : relancer +> reproduit ce rapport. Chaque preuve rejoue l'outillage existant du depot ; +> aucune validation n'est reimplementee ici. Voir le mode d'emploi : +> [`docs/audit/README.md`](README.md), et le registre trace : +> [`docs/audit/affirmations.md`](affirmations.md). + +- **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml` +- **Verdict** : ✅ CONFORME (94 OK · 0 echec · 0 saute) + +## Preuves + +| # | Preuve | Affirmations | Statut | Detail | +|---|---|---|---|---| +| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK | } \| to_nice_json }}`. | +| P02 | Tests unitaires (inventaire, raser, ecritures du plan, rendu du GUI) | — | ✅ OK | OK | +| P03 | Diff-vide du plan — TOUTES les instances | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | 3 instance(s) verifiee(s) — OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre : plan et inventaire applique coincident. | +| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | | +| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | | +| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. | +| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check), 1 nom(s) surveille(s) sans reference orpheline. | +| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 42 groupes classes, aucun cycle, aucune arete en arriere ; playbooks/site.yml a jour. | +| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 40 rôles, 119 flux, schéma + matrice OK. | +| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). | +| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | serveur_resolveur_site | +| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. | +| P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. | +| P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. | +| P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. | +| P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ OK | 13 hotes, 33 groupes (inventaire dechiffre et parse). | +| P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. | +| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 35 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) | +| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 30 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. | +| P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) et 7 zone(s) de site : adressage 100% derive du seed index. | +| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 4 instance(s) federee(s), aucun index en collision. | +| P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (22 sections). | +| P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 14 reseau(x), aucune collision avec la plage tenant. | +| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | note : serveur_powerdns declare un port `derive` que le plan du site ne resout pas — aucune regle emise. | +| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 2 tenant(s), 40 groupe(s), 108 regle(s). | +| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 13 hote(s) x 6 integration(s) universelle(s) : aucune lacune, aucune recopie (0 exemption(s) derivee(s) du service rendu). | +| P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 8 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. | +| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 35 VM placee(s), aucun nom ni VMID en collision. | +| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 34 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 23, 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 | 78 scripts expliques et atteignables, 151 cibles make documentees, 69 roles avec README. | +| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 37 exigence(s) de role, toutes satisfaites (148 cle(s) declaree(s) par l'instance). | +| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 44 revendication(s) de port, aucune collision entre roles co-localises (35 groupes). | +| P34 | Chaque document declare son lecteur | — | ✅ OK | 50 document(s) declarent leur lecteur (47 genere(s) exempte(s)). | +| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 4 application(s) exigeant une base l'ont toutes (4 entree(s) au registre). | +| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 9 hote(s) de l'ecosysteme et 3 du site detiennent de l'etat, tous porteurs de `client_backup` (11 groupe(s) au catalogue). | +| P37 | Le placement du tenant existe chez son hebergeur | — | ✅ OK | placement confronte a l'hebergeur monte (SITE-Chezlepro) : noeud, stockage, pont — tous offerts. | +| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 42 role(s) serveur/client tous nommes, 42 groupe(s) cite(s) en table existent tous. | +| P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 97 page(s) de wiki toutes atteignables. | +| P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. | +| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 74 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). | +| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 3 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal). | +| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 9 machine(s) du plan retrouvees, 201 regle(s) du site. | +| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 5 integration(s) appliquent leur serveur avant leurs clients. | +| P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). | +| P46 | Plancher /etc/hosts : un seul role en decide | — | ✅ OK | Un seul maitre du plancher — roles/hosts_statiques/tasks/main.yml : manage_etc_hosts: false ; et le gabarit maitre est pose (roles/hosts_statiques/templates/hos | +| P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). | +| P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 102 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. | +| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (137 lignes). | +| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 304 regles `pass`), tous non consignes et tous motives. | +| P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 | +| P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. | +| P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 13 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere : WAN muet, interfa | +| P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 9 role(s) applique(s), aucun secret de tenant reclame. | +| P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 13 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 13. | +| P56 | Gabarit minimal, et rien de retire n'est perdu | — | ✅ OK | Gabarit minimal : 4 role(s), tous indispensables au premier demarrage ; 14 role(s) retire(s), tous repris par le socle ou le durcissement. | +| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (94 preuves, 69 roles, 42 groupes). | +| P58 | Habilitations : chaque service dit a quel GROUPE, et par quoi | — | ✅ OK | 8 habilitation(s) declarees, toutes nommant un groupe, un mecanisme connu et une raison ; les `role-realm` sont projetees. | +| P59 | Enumerations annoncees : le nombre correspond a ce qui suit | — | ✅ OK | 2 enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent (formes non ambigues seulement). | +| P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `e9215a2` (publie le 2026-10-07). | +| P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 56 champ(s) sur 7 registres ; il couvre tout ce que les plans reels contiennent, et la FORME de chaque champ (scalaire / objet / table) corresp | +| P62 | Schema du plan : il decrit tout ce que le MOTEUR accepte | AFF-033 | ✅ OK | Les 4 validateurs n'acceptent aucun champ que le schema ignore (applications:8, bases_donnees:4, domaines_publics:9, serveurs:3 champ(s) lus par validateur). | +| P63 | cloud-init nait avec la VM et ne lui survit pas | — | ✅ OK | cloud-init est au gabarit (la premiere seconde), absent du socle (pas de va-et-vient), et retire par le durcissement — avec la garde qui verifie que le reseau s | +| P64 | Sondes de supervision : declarees ET deposees | — | ✅ OK | 47 sonde(s) declaree(s) ET deposee(s), chacune avec sa raison et son `ttl` : client_journal/journaux, client_metrique/metriques, client_pki/certificat, client_s | +| P65 | Depots tiers : demandes au cache, jamais en HTTPS direct | — | ✅ OK | 4 depot(s) tiers relaye(s) par le cache, aucun role ne les vise en https:// ecrit en dur. | +| P66 | Clients OIDC : chaque URI vise un nom que le plan expose | — | ✅ OK | 4 client(s) OIDC, toutes leurs URI visent un FQDN que le plan expose (6 exposition(s)). | +| P67 | Nom public : le service porte celui du plan, pas celui du role | — | ✅ OK | 13 service(s) expose(s) portent le nom du plan, sur 2 inventaire(s) : instance, SITE. | +| P68 | Cle de depot telechargee : mesuree avant d'etre utilisee | — | ✅ OK | 5 role(s) telechargent une cle de depot, tous la mesurent avant de s'en servir. | +| P69 | Amorcage d'un tenant : l'adresse designe le site REEL | — | ✅ OK | 2 adresse(s) d'amorcage designent bien une machine du site. | +| P70 | Depot de binaires : il tient tout ce que les roles vont chercher | — | ✅ OK | 27 artefact(s) direct(s) tenus par le depot du site. | +| P71 | Pool du site : le genome ne nait pas chez un tenant | — | ✅ OK | `site-creer` nomme `--pool-site` ; le pool du genome ne peut plus etre celui d'un tenant. | +| P72 | Annuaire : aucun service ne se lie avec le compte du maitre | — | ✅ OK | 5 role(s) consultent l'annuaire, chacun avec SON compte de service ; seul `amorcage_acces` garde celui d'administration, et il provisionne au lieu de consommer. | +| P73 | Le locataire designe les services de son site REEL | — | ✅ OK | CONFORME : 10 intrant(s) du locataire concordent avec ce que le site expose (1 non declare(s), donc derive(s) ou non utilise(s)). | +| P74 | Gabarit dore : une seule declaration, au plan du site | — | ✅ OK | Gabarit declare une seule fois : VMID 9006 « modeleSetOPS-minimal ». | +| P75 | Les parametres de clonage traversent les trois maillons | — | ✅ OK | 14 parametre(s) de clonage, tous emis par l'inventaire. | +| P76 | Tout gabarit de role se rend vraiment | — | ✅ OK | 189 gabarits de role : tous se rendent. | +| P77 | Panneaux declares : assemblables, et gradues | — | ✅ OK | 8 panneau(x) declare(s) dans 2 role(s), tous avec titre, expression, raison et une unite que la table sait traduire. | +| P78 | Un consommateur de base suit le verrou TLS de son serveur | — | ✅ OK | 3 consommateur(s) suivent la posture de leur serveur ; 2 sans reglage TLS (serveur_icingaweb2, serveur_nextcloud). | +| P79 | Replis silencieux : une derivation vide ne passe pas pour un succes | — | ✅ OK | 4 ecosysteme(s) (OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, SITE-Chezlepro) : pattes, edges, certificats, rechargements, jumeaux d'amorcage, sorties du | +| P80 | Remise au client : inscrite, nommee, et son second temps a l'heure | — | ✅ OK | Aucun ecosysteme remis a un client : rien a tenir. | +| P81 | La console dit sa portee, et ne sert pas un inventaire vide en silence | — | ✅ OK | Portee `poste` derivee des voutes portees, source d'inventaire `instance/inventories/principal/hosts.yml`, et 15 route(s) POST exigent toutes un pouvoir. | +| P82 | DNS public : les zones publiees sont servies, signees avant d'etre exposees | — | ✅ OK | 2 zone(s) publique(s) declaree(s), toutes servies par le site ; 2 replication(s) declaree(s) des deux cotes ; transfert ouvert a la cle seule ; aucune expositio | +| P83 | Assistants : le registre des runbooks ne prend pas de retard sur le Makefile | — | ✅ OK | 17 runbooks, 144 etapes, 150 cibles documentees : chacune portee par un assistant ou exemptee avec son motif. | +| P84 | La fiche du site dit exactement ce que chaque locataire porte | — | ✅ OK | 2 fiche(s) de site : chacune dit exactement ce que son locataire porte (copies, racine, inventaire genere). | +| P85 | La face reseau du locataire dit exactement ce que le site en tire | — | ✅ OK | 2 face(s) reseau : chacune dit exactement ce que le site en tire (decouverte, devis Proxmox, frontiere, DNS public, sauvegarde). | +| P86 | Machine par machine, le locataire et Proxmox admettent les memes entrees | — | ✅ OK | 2 locataire(s) : machine par machine et port par port, nftables et Proxmox admettent les memes sources. | +| P87 | La frontiere designe chaque locataire par les identites qu'il publie | — | ✅ OK | 2 locataire(s) : la frontiere les designe par les identites qu'ils publient (supernet, administration, tunnel, groupes, routes, sortie). | +| P88 | Ce qui entre depuis l'Internet chez un locataire, la frontiere le tient de lui | — | ✅ OK | 2 locataire(s) : regles WAN, redirections et tunnel disent exactement ce que leur face reseau ouvre a tous. | +| P89 | Ce que l'administration atteint chez un locataire, la frontiere le tient de lui | — | ✅ OK | 2 locataire(s) : par la gestion, le VPN et le tunnel, l'administration atteint exactement ce que leur face reseau lui ouvre. | +| P90 | Ce qui sort d'un locataire vers l'Internet, la frontiere le tient de lui | — | ✅ OK | 2 locataire(s) : la frontiere laisse sortir exactement ce que leur face reseau publie. | +| P91 | La fiche du site est deposee a jour, et l'inventaire se genere sans le site | — | ✅ OK | 2 locataire(s) : fiche du site deposee a jour, et inventaire genere sans le site identique a l'octet pres. | +| P92 | Chaque locataire a publie sa face reseau a jour | — | ✅ OK | 2 locataire(s) : face reseau publiee, a jour. | +| P93 | La face publie, machine par machine, les parametres de clonage de l'inventaire | — | ✅ OK | 26 machine(s) : la face publie exactement les parametres de clonage de l'inventaire. | +| P94 | Creer, raser et placer un locataire par sa face visent les memes machines | — | ✅ OK | 2 locataire(s) : creer, raser et placer visent par la face les memes machines que par l'instance montee. | + +## Couverture des affirmations ✅ du registre + +Chaque affirmation ✅ automatisable est couverte par la preuve indiquee ci-dessus. +Les ✅ **structurelles/doctrinales** non rejouables par une commande (ex. AFF-005 +`make`=aide, AFF-014 ciblage groupe, AFF-024 `instancier-appliquer`, AFF-051 autorite +d'AGENTS.md, AFF-073/075 gardes `make`, AFF-090 wiki) ont ete verifiees a l'audit ; +elles restent hors du harnais recurrent (rien d'executable a rejouer). + +## Declarations d'intention (⚪ invérifiables localement — assumees) + +Ces affirmations ne sont pas rejouables hors production ; elles sont **assumees** +comme declarations d'intention, non comme preuves : + +- **AFF-036** — « testables avec `--check` autant que possible » : verifiable seulement + contre une flotte vivante. +- **AFF-091** — contenu pedagogique du wiki : affirmations conceptuelles. +- **AFF-096** — « GUI 100 % francais » : revue exhaustive des libelles rendus, non automatisee. +- **AFF-007** — hote d'exemple `web-frontal-01` : placeholder assume. + +_Rapport genere le 2026-10-09._ diff --git a/docs/carte-set-ops.md b/docs/carte-set-ops.md index ab28081..4eac7e6 100644 --- a/docs/carte-set-ops.md +++ b/docs/carte-set-ops.md @@ -26,7 +26,7 @@ README de rôles). Cette page comble ces deux trous. | rôles | 69 | `roles/*/` | | README de rôles | 69 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette | | documents | 46 | `docs/*.md` | -| pièces d'audit | 51 | `docs/audit/*` | +| pièces d'audit | 52 | `docs/audit/*` | | unités de wiki | 27 | `wiki/*.md` | | décisions en vigueur | 85 | lignes `\| **D-nn** \|` de `decisions-architecture.md` | | décisions renversées | 3 | lignes `\| **D-nn** —` du même document | diff --git a/docs/flux-conception.md b/docs/flux-conception.md index 11c686d..792afb8 100644 --- a/docs/flux-conception.md +++ b/docs/flux-conception.md @@ -66,6 +66,31 @@ Un **résolveur** (miroir de `instancier`) agrège, par serveur, les `flux.yml` - **le registre d'audit** : `docs/registre-flux.md` (généré) + une cible `make flux`. ## Activation prudente + +### Descriptions conservées par les pare-feux + +La `raison` non vide du flux et son rôle alimentent les champs natifs : `comment` +pour nftables et Proxmox, `description` pour le filtrage OPNsense (`descr` pour ses +redirections). Un commentaire de fichier `# ...` ne survit pas au chargement nftables. +Les règles communes (administration, connexions établies, refus) portent aussi leur motif. + +Les libellés sont sur une ligne. Le commentaire nftables est borné à 127 octets UTF-8 ; +OPNsense garde sa clé `setops:...` complète et borne le texte qui suit pour rester dans +255 octets. Une raison abrégée reste disponible en entier dans le registre des flux. +Le commentaire Proxmox conserve aussi le pair nommé dans le devis. + +Une correction de description apparaît dans `make proxmox-fw-plan` et +`make frontiere-plan`. Son application met à jour le champ sur la règle existante, +puis le relit : elle ne recrée pas la règle et ne modifie ni son activation ni sa +politique. Les confirmations habituelles restent requises pour appliquer ces devis. +`make test` vérifie notamment cette convergence et les limites des champs. + +Références des champs natifs : [nftables](https://netfilter.org/projects/nftables/manpage.html), +[API Proxmox](https://github.com/proxmox/pve-firewall/blob/master/src/PVE/API2/Firewall/Rules.pm), +[descriptions OPNsense](https://github.com/opnsense/core/blob/master/src/opnsense/mvc/app/models/OPNsense/Base/FieldTypes/DescriptionField.php). + +### Activer le filtrage + Activer nftables = **action destructive** (peut couper l'accès) → confirmation explicite + déploiement graduel (garder l'accès SSH/Ansible, tester par nœud). @@ -130,4 +155,3 @@ 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/nftables_baseline/templates/nftables.conf.j2 b/roles/nftables_baseline/templates/nftables.conf.j2 index 2d9f48a..0b1c692 100644 --- a/roles/nftables_baseline/templates/nftables.conf.j2 +++ b/roles/nftables_baseline/templates/nftables.conf.j2 @@ -7,25 +7,25 @@ table inet setops_filter { chain input { type filter hook input priority 0; policy {{ nftables_baseline_input_policy }}; - iif "lo" accept - ct state established,related accept - ct state invalid drop + iif "lo" accept comment "socle: communications locales" + ct state established,related accept comment "socle: connexions etablies et associees" + ct state invalid drop comment "socle: paquets invalides" {% if nftables_baseline_allow_icmp | bool %} - ip protocol icmp accept - ip6 nexthdr icmpv6 accept + ip protocol icmp accept comment "socle: diagnostic et erreurs reseau IPv4" + ip6 nexthdr icmpv6 accept comment "socle: diagnostic et erreurs reseau IPv6" {% endif %} {% if nftables_baseline_allow_ssh | bool %} - tcp dport {{ nftables_baseline_ssh_port }} accept + tcp dport {{ nftables_baseline_ssh_port }} accept comment "administration: acces SSH du gabarit de repli" {% endif %} {% for port in nftables_baseline_extra_tcp_ports %} - tcp dport {{ port }} accept + tcp dport {{ port }} accept comment "repli: port TCP supplementaire declare par l'instance" {% endfor %} {% for port in nftables_baseline_extra_udp_ports %} - udp dport {{ port }} accept + udp dport {{ port }} accept comment "repli: port UDP supplementaire declare par l'instance" {% endfor %} } diff --git a/scripts/appliquer_opnsense.py b/scripts/appliquer_opnsense.py index b045c3d..25f6153 100644 --- a/scripts/appliquer_opnsense.py +++ b/scripts/appliquer_opnsense.py @@ -40,6 +40,7 @@ sys.path.insert(0, str(RACINE / "scripts")) import devis_opnsense as devis_mod # noqa: E402 from proxmox_api import voute as voute_proxmox # noqa: E402 +from resoudre_flux import borner_libelle, libelle_flux # noqa: E402 def _voute(base: Path) -> dict: @@ -145,6 +146,13 @@ def cle_redirection(d: dict) -> str: f"->{d['cible']}:{d['port_local']}") +def _description(cle: str, role: str, raison: str) -> str: + """Garde l'identite complete en tete, puis le sens du flux (255 octets au total).""" + prefixe = cle + " — " + return prefixe + borner_libelle(libelle_flux(role, raison), + 255 - len(prefixe.encode("utf-8"))) + + def _corps_redirection(d: dict, k: str) -> dict: """Une redirection d'OPNsense 26.7 (`d_nat`), eprouvee le 2026-09-16. @@ -166,7 +174,8 @@ def _corps_redirection(d: dict, k: str) -> dict: "natreflection": "disable", "pass": "", "log": "0", - "descr": f"{k} — {d.get('role', '')}"[:255], + "descr": _description(k, str(d.get("role") or "publication"), + str(d.get("raison") or "Publication du service vers sa cible interne")), } @@ -232,7 +241,8 @@ def _corps_nat(n: dict, k: str) -> dict: "source_net": n["source"], "destination_net": n["destination"], "target": n["cible"], - "description": f"{k} — sortie tenant", + "description": _description(k, str(n["tenant"]), + "Traduction des adresses en sortie"), } @@ -268,7 +278,7 @@ def _corps_regle(r: dict, k: str) -> dict: "source_net": r["source"], "destination_net": dst[1:] if nie else dst, "destination_not": "1" if nie else "0", - "description": f"{k} — {r['role']}", + "description": _description(k, str(r["role"]), str(r["raison"])), } if r["protocole"] in ("tcp", "udp") and r["ports"]: corps["destination_port"] = ",".join(map(str, r["ports"])) @@ -391,6 +401,20 @@ def plan(api: Frontiere, devis: dict) -> dict: if d.startswith("setopsrdr:"): rdr_posees[d.split(" — ")[0]] = x + # Les identifiants restent stables : corriger une justification se fait par UUID, + # sans retirer/recreer la regle ni modifier son filtrage ou son activation. + descriptions_majer = [] + for objet, demandes, existantes, rendre, champ in ( + ("filter", voulues, posees, _corps_regle, "description"), + ("source_nat", nat_voulus, nat_poses, _corps_nat, "description"), + ("d_nat", rdr_voulues, rdr_posees, _corps_redirection, "descr")): + for k in sorted(demandes.keys() & existantes.keys()): + attendu = rendre(demandes[k], k)[champ] + if str(existantes[k].get(champ) or existantes[k].get("description") or "") != attendu: + descriptions_majer.append({"objet": objet, "cle": k, + "uuid": existantes[k]["uuid"], + "champ": champ, "valeur": attendu}) + rt_voulues = {cle_route(r): r for r in devis.get("routes") or []} rt_posees = {} for x in (_lire(api, "/api/routes/routes/searchroute/", @@ -403,6 +427,7 @@ def plan(api: Frontiere, devis: dict) -> dict: # et transporte dans le plan pour que l'affichage puisse le dire. _reelles = routes_du_noyau(api) return { + "descriptions_majer": descriptions_majer, "routes_noyau_lisible": _reelles is not None, # Presente dans le modele mais ETEINTE : elle compte « posee » partout ailleurs, # et n'est installee nulle part. C'est la forme exacte du defaut du 2026-09-02. @@ -442,6 +467,8 @@ def afficher(p: dict) -> bool: for k, r in sorted(p["regles_creer"].items()): print(f" + regle {r['interface']:<5} {r['protocole']:<4} " f"{str(r['ports'] or ''):<9} {r['source'][:30]:<30} -> {r['destination'][:24]}") + for d in p["descriptions_majer"]: + print(f" ~ description {d['valeur']}") for k, n in sorted(p["nat_creer"].items()): print(f" + NAT sortant {n['interface']:<5} {n['source'][:30]:<30} " f"-> {n['cible']}") @@ -495,9 +522,11 @@ def afficher(p: dict) -> bool: retirer = len(p["regles_retirer"]) + len(p["alias_retirer"]) + len(p["nat_retirer"]) \ + len(p["routes_retirer"]) + len(p["rdr_retirer"]) print(f"\n a creer : {creer} | a retirer : {retirer}" - f" | inchange : {len(p['regles_garder']) + len(p['nat_garder']) + len(p['rdr_garder'])}" + f" | descriptions a corriger : {len(p['descriptions_majer'])}" + f" | inchange : {len(p['regles_garder']) + len(p['nat_garder']) + len(p['rdr_garder']) - len(p['descriptions_majer'])}" f" + {len(p['routes_garder'])} routes") return any(p[c] for c in ("alias_creer", "alias_majer", "alias_retirer", + "descriptions_majer", "regles_creer", "regles_retirer", "nat_creer", "nat_retirer", "rdr_creer", "rdr_retirer", "routes_creer", "routes_retirer")) @@ -541,6 +570,15 @@ def appliquer(api: Frontiere, p: dict) -> int: _fait(api("/api/firewall/d_nat/add_rule/", {"rule": _corps_redirection(d, k)}), f"redirection {k}") + for d in p["descriptions_majer"]: + chemin = f"/api/firewall/{d['objet']}" + if _fait(api(f"{chemin}/set_rule/{d['uuid']}", + {"rule": {d["champ"]: d["valeur"]}}), f"description {d['cle']}"): + relu = _lire(api, f"{chemin}/get_rule/{d['uuid']}").get("rule") or {} + if relu.get(d["champ"]) != d["valeur"]: + echecs += 1 + print(f" ! ECHEC description {d['cle']} : relecture differente de l'ecriture") + # 2bis. Routes, meme ordre et pour une raison plus forte encore : une route # manquante coupe la flotte, une route en trop ne fait qu'acheminer vers un # VRF qui la jettera. En cas de doute, on reste large. diff --git a/scripts/appliquer_proxmox_fw.py b/scripts/appliquer_proxmox_fw.py index e8f8539..c5a98ff 100644 --- a/scripts/appliquer_proxmox_fw.py +++ b/scripts/appliquer_proxmox_fw.py @@ -49,6 +49,7 @@ RACINE = Path(__file__).resolve().parent.parent sys.path.insert(0, str(RACINE / "scripts")) from proxmox_api import Cluster # noqa: E402 +from resoudre_flux import libelle_flux # noqa: E402 def _prefixe(bloc: dict) -> str: @@ -90,6 +91,17 @@ def _regle_cle(r: dict) -> tuple: str(r.get("icmp_type") or r.get("icmp-type") or "")) +def _commentaire_regle(groupe: dict, regle: dict) -> str: + role = str(groupe.get("role") or groupe["nom"]) + if regle.get("via"): + role += f" / {regle['via']}" + return libelle_flux(role, str(regle["raison"])) + + +def _commentaire_affectation(hote: str, groupe: dict) -> str: + return libelle_flux(hote, f"Appliquer les flux de {groupe.get('role') or groupe['nom']}") + + def _prefixes_retires() -> tuple[str, ...]: """Les etiquettes des tenants RETIRES : `t29-` pour la zone `t29`. @@ -162,6 +174,7 @@ def plan(api: Cluster, devis: dict) -> dict: o = api(f"/nodes/{noeud}/qemu/{vmid}/firewall/options") or {} affect_posees[vmid] = { "noeud": noeud, + "regles": [r for r in regles if isinstance(r, dict) and r.get("type") == "group"], "groupes": sorted(str(r.get("action")) for r in regles if isinstance(r, dict) and r.get("type") == "group"), "actif": str(o.get("enable", "")), @@ -170,6 +183,35 @@ def plan(api: Cluster, devis: dict) -> dict: "politique": str(o.get("policy_in", "")).upper(), } + # Une justification changee ne change pas la politique. Mettre le commentaire a + # jour EN PLACE : purger le groupe pour ce seul motif couperait ses autorisations. + regles_commenter = {} + for nom, groupe in groupes_voulus.items(): + actuelles = groupes_poses.get(nom) + voulues = {_regle_cle(r): _commentaire_regle(groupe, r) for r in groupe["regles"]} + if actuelles is None or set(voulues) != {_regle_cle(r) for r in actuelles}: + continue # groupe absent ou politique differente : traitement habituel + changements = [{"pos": r["pos"], "comment": voulues[_regle_cle(r)]} + for r in actuelles + if str(r.get("comment") or "") != voulues[_regle_cle(r)]] + if changements: + regles_commenter[nom] = changements + + affect_commenter = {} + for vmid, affect in affect_voulues.items(): + posee = affect_posees.get(vmid) + if not posee: + continue + changements = [] + for r in posee["regles"]: + nom = str(r.get("action")) + if nom in affect["groupes"]: + commentaire = _commentaire_affectation(affect["hote"], groupes_voulus[nom]) + if str(r.get("comment") or "") != commentaire: + changements.append({"pos": r["pos"], "comment": commentaire}) + if changements: + affect_commenter[vmid] = changements + opts = api("/cluster/firewall/options") or {} return { "ipsets_creer": {n: v for n, v in ipsets_voulus.items() if n not in ipsets_poses}, @@ -182,6 +224,8 @@ def plan(api: Cluster, devis: dict) -> dict: and {_regle_cle(r) for r in g["regles"]} != {_regle_cle(r) for r in groupes_poses[n]}}, "groupes_retirer": sorted(n for n in groupes_poses if n not in groupes_voulus), + "regles_commenter": regles_commenter, + "affect_commenter": affect_commenter, "affect_majer": {v: a for v, a in affect_voulues.items() if v in affect_posees and (affect_posees[v]["groupes"] != a["groupes"] @@ -207,6 +251,11 @@ def afficher(p: dict) -> bool: print(f" ~ groupe {n:<22} regles differentes") for n in p["groupes_retirer"]: print(f" - groupe PERIME {n}") + for n, regles in sorted(p["regles_commenter"].items()): + print(f" ~ commentaires {n:<22} {len(regles)} regle(s), politique inchangee") + for v, regles in sorted(p["affect_commenter"].items()): + if v not in p["affect_majer"]: + print(f" ~ commentaires VM {v:<22} {len(regles)} affectation(s)") for v, a in sorted(p["affect_majer"].items()): print(f" ~ VM {v:<12} {a['hote']:<16} -> {', '.join(a['groupes'])}") if p["vm_absentes"]: @@ -230,6 +279,7 @@ def afficher(p: dict) -> bool: print(" le reste du parc. La remettre a ACCEPT.") creer = len(p["ipsets_creer"]) + len(p["groupes_creer"]) majer = len(p["ipsets_majer"]) + len(p["groupes_majer"]) + len(p["affect_majer"]) + majer += len(p["regles_commenter"]) + len(set(p["affect_commenter"]) - set(p["affect_majer"])) retirer = len(p["ipsets_retirer"]) + len(p["groupes_retirer"]) print(f"\n a creer : {creer} | a mettre a jour : {majer} | a retirer : {retirer}") return bool(creer or majer or retirer) @@ -269,13 +319,21 @@ def appliquer(api: Cluster, p: dict) -> int: {"group": n, "comment": str(g.get("role", ""))[:100]}), f"groupe {n}") for r in g["regles"]: corps = {"type": str(r.get("sens", "in")).lower(), "action": r["action"], - "enable": 1, "comment": str(r.get("via") or g.get("role", ""))[:100]} + "enable": 1, "comment": _commentaire_regle(g, r)} for cle, champ in (("source", "source"), ("proto", "proto"), ("dport", "dport"), ("icmp_type", "icmp-type")): if r.get(cle): corps[champ] = r[cle] _fait(api(f"/cluster/firewall/groups/{n}", "POST", corps), f"regle de {n}") + for n, regles in sorted(p["regles_commenter"].items()): + for r in regles: + chemin = f"/cluster/firewall/groups/{n}/{r['pos']}" + if _fait(api(chemin, "PUT", {"comment": r["comment"]}), f"commentaire de {n}/{r['pos']}"): + if (_lire(api, chemin) or {}).get("comment") != r["comment"]: + echecs += 1 + print(f" ! ECHEC commentaire {n}/{r['pos']} : relecture differente de l'ecriture") + # 3. Affectations aux VM, et activation de leur pare-feu — sans quoi les groupes # seraient poses sur une machine qui ne filtre pas. for v, a in sorted(p["affect_majer"].items()): @@ -286,7 +344,9 @@ def appliquer(api: Cluster, p: dict) -> int: f"purge VM {v}") for g in a["groupes"]: _fait(api(f"/nodes/{noeud}/qemu/{v}/firewall/rules", "POST", - {"type": "group", "action": g, "enable": 1}), f"VM {v} += {g}") + {"type": "group", "action": g, "enable": 1, + "comment": _commentaire_affectation(a["hote"], p["_voulus"]["groupes"][g])}), + f"VM {v} += {g}") # `policy_in` sur LA VM : c'est ce qui rend les groupes ci-dessus exclusifs. # Au datacenter, la meme valeur s'appliquerait a tout ce qui n'est pas un tenant. # @@ -308,6 +368,17 @@ def appliquer(api: Cluster, p: dict) -> int: _fait(api(f"/nodes/{noeud}/qemu/{v}/firewall/options", "PUT", {"enable": 1, "policy_in": POLITIQUE_VM}), f"pare-feu de la VM {v}") + for v, regles in sorted(p["affect_commenter"].items()): + if v in p["affect_majer"]: + continue # les nouvelles affectations portent deja leur commentaire + noeud = p["affect_posees"][v]["noeud"] + for r in regles: + chemin = f"/nodes/{noeud}/qemu/{v}/firewall/rules/{r['pos']}" + if _fait(api(chemin, "PUT", {"comment": r["comment"]}), f"commentaire VM {v}/{r['pos']}"): + if (_lire(api, chemin) or {}).get("comment") != r["comment"]: + echecs += 1 + print(f" ! ECHEC commentaire VM {v}/{r['pos']} : relecture differente de l'ecriture") + # 4. Retraits : groupes avant IPSets, un IPSet reference ne se supprime pas. for n in p["groupes_retirer"]: for r in reversed(api(f"/cluster/firewall/groups/{n}") or []): @@ -367,6 +438,7 @@ def main() -> int: raise SystemExit(f"REFUS : VM hors du devis ou du cluster : {inconnues}") ecartees = len(p["affect_majer"]) p["affect_majer"] = {v: a for v, a in p["affect_majer"].items() if v in args.vm} + p["affect_commenter"] = {v: a for v, a in p["affect_commenter"].items() if v in args.vm} ecartees -= len(p["affect_majer"]) print(f" (portee restreinte : {ecartees} affectation(s) de VM laissee(s) pour plus tard)\n") quelque_chose = afficher(p) diff --git a/scripts/resoudre_flux.py b/scripts/resoudre_flux.py index 277d3b6..81a0fb8 100644 --- a/scripts/resoudre_flux.py +++ b/scripts/resoudre_flux.py @@ -87,6 +87,28 @@ class ErreurFlux(Exception): pass +def libelle_flux(role: str, raison: str) -> str: + """Le role et sa justification, sur une ligne lisible par les trois pare-feux.""" + texte = f"{role}: {raison}" + return " ".join("".join(c if c.isprintable() else " " for c in texte).split()) + + +def borner_libelle(texte: str, octets: int) -> str: + """Borne un commentaire UTF-8 sans couper un caractere ; la raison reste au registre.""" + contenu = texte.encode("utf-8") + if len(contenu) <= octets: + return texte + if octets < 4: + raise ErreurFlux("Identifiant trop long : aucune place pour la description du flux.") + return contenu[:octets - 3].decode("utf-8", errors="ignore").rstrip() + "..." + + +def commentaire_nft(role: str, raison: str) -> str: + """Un commentaire NATIF (conserve par nft), avec guillemets et UTF-8 proteges.""" + texte = borner_libelle(libelle_flux(role, raison), 127) + return "comment " + json.dumps(texte, ensure_ascii=False) + + def charger_flux() -> dict[str, list[dict]]: """{role: [flux, ...]} pour chaque role portant un meta/flux.yml.""" resultat: dict[str, list[dict]] = {} @@ -182,6 +204,8 @@ def valider(flux_par_role: dict[str, list[dict]]) -> tuple[int, int]: for cle in ("sens", "port", "protocole", "pair", "chiffrement", "raison"): if cle not in fl: erreurs.append(f"{ref} : cle manquante {cle}") + if not isinstance(fl.get("raison"), str) or not fl["raison"].strip(): + erreurs.append(f"{ref} : raison attendue (texte non vide pour le libelle du pare-feu)") if fl.get("sens") not in SENS: erreurs.append(f"{ref} : sens invalide {fl.get('sens')!r}") if fl.get("protocole") not in PROTO: @@ -764,7 +788,8 @@ def generer_nftables(flux_par_role: dict[str, list[dict]], site: bool = False) - ip_par_hote = _ip_par_hote(data) ports_plan = _ports_du_plan_site() if site else _ports_du_plan() regles_admin = [ - f" ip saddr {{ {cidr} }} tcp dport 22 accept # administration (garde anti-lockout, intrant nftables_admin_ssh)" + f" ip saddr {{ {cidr} }} tcp dport 22 accept " + + commentaire_nft("administration", "Garde anti-lockout, intrant nftables_admin_ssh") for cidr in admin_ssh ] if site: @@ -878,7 +903,7 @@ def generer_nftables(flux_par_role: dict[str, list[dict]], site: bool = False) - if local and not srcs: continue # lo deja accepte regles.append(f" {saddr}{_selecteur_nft(fl['protocole'], port)} accept" - f" # {g}: {fl.get('raison','')}") + f" {commentaire_nft(g, fl['raison'])}") entree = {"proto": proto, "port": str(port), "role": g, "sources": list(srcs) if saddr else ["0.0.0.0/0"]} # « TOUT LE MONDE » NE DOIT PAS EFFACER QUI ETAIT NOMME (2026-10-04). L'hote @@ -963,11 +988,11 @@ def _rendre_nft(hote: str, regles: list[str]) -> str: f"table inet setops_flux {{\n" f" chain input {{\n" f" type filter hook input priority 0; policy drop;\n\n" - f" iif \"lo\" accept\n" - f" ct state established,related accept\n" - f" ct state invalid drop\n" - f" ip protocol icmp accept\n" - f" ip6 nexthdr icmpv6 accept\n\n" + f" iif \"lo\" accept comment \"socle: communications locales\"\n" + f" ct state established,related accept comment \"socle: connexions etablies et associees\"\n" + f" ct state invalid drop comment \"socle: paquets invalides\"\n" + f" ip protocol icmp accept comment \"socle: diagnostic et erreurs reseau IPv4\"\n" + f" ip6 nexthdr icmpv6 accept comment \"socle: diagnostic et erreurs reseau IPv6\"\n\n" f"{corps}\n\n" f" # L'INTERNE PARLE, LA BORDURE SE TAIT (2026-08-28).\n" f" #\n" @@ -992,7 +1017,7 @@ def _rendre_nft(hote: str, regles: list[str]) -> str: f" # moitie du choix : 982 000 entrees par jour, dont 82 %% un balayage\n" f" # Internet contre le port VNC. Repondre a chacune serait un vecteur\n" f" # d'amplification, source usurpee comprise.\n" - f" reject with icmpx type admin-prohibited\n" + f" reject with icmpx type admin-prohibited comment \"socle: refus des entrees non autorisees\"\n" f" }}\n\n" f" # LA CHAINE `forward` RESTE MUETTE, et ce n'est pas un oubli : elle porte le\n" f" # trafic qui TRAVERSE cet hote, pas celui qui lui est destine. Y repondre\n" @@ -1001,9 +1026,9 @@ def _rendre_nft(hote: str, regles: list[str]) -> str: f" # defaut qu'on corrige dans `input`.\n" f" chain forward {{\n" f" type filter hook forward priority 0; policy drop;\n" - f" ct state established,related accept\n" - f" iifname \"docker0\" accept\n" - f" oifname \"docker0\" accept\n" + f" ct state established,related accept comment \"transit: connexions etablies et associees\"\n" + f" iifname \"docker0\" accept comment \"transit: trafic depuis le pont docker0\"\n" + f" oifname \"docker0\" accept comment \"transit: trafic vers le pont docker0\"\n" f" }}\n" f" chain output {{ type filter hook output priority 0; policy accept; }}\n" f"}}\n" diff --git a/scripts/tests/test_libelles_parefeu.py b/scripts/tests/test_libelles_parefeu.py new file mode 100644 index 0000000..4f0ac2f --- /dev/null +++ b/scripts/tests/test_libelles_parefeu.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""Les raisons du registre survivent dans les pare-feux et se corrigent sans recreer les regles.""" +from __future__ import annotations + +import copy +import io +import json +import sys +import tempfile +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from unittest.mock import patch + +import jinja2 +import yaml + +RACINE = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(RACINE / "scripts")) + +import appliquer_opnsense as opn +import appliquer_proxmox_fw as pve +import resoudre_flux as flux + + +class ClusterFictif: + """Une regle deja posee et une affectation ; toute ecriture de politique est refusee.""" + hote = "cluster-fictif" + + def __init__(self, ignorer=False): + self.ignorer = ignorer + self.ecritures = [] + self.regle = {"pos": 3, "type": "in", "action": "ACCEPT", "source": "+t90-app", + "proto": "tcp", "dport": "5432", "enable": 0, "comment": "ancien"} + self.affectation = {"pos": 7, "type": "group", "action": "t90-pg", "enable": 1} + + @staticmethod + def rate(rep): + return None + + def __call__(self, chemin, methode="GET", corps=None): + objets = {"/cluster/firewall/groups/t90-pg/3": self.regle, + "/nodes/noeud/qemu/9001/firewall/rules/7": self.affectation} + if methode != "GET": + assert methode == "PUT" and chemin in objets and set(corps) == {"comment"} + self.ecritures.append((chemin, copy.deepcopy(corps))) + if not self.ignorer: + objets[chemin].update(corps) + return None + if chemin in objets: + return copy.deepcopy(objets[chemin]) + lectures = { + "/cluster/firewall/ipset": [{"name": "t90-app"}], + "/cluster/firewall/ipset/t90-app": [{"cidr": "192.0.2.10"}], + "/cluster/firewall/groups": [{"group": "t90-pg"}], + "/cluster/firewall/groups/t90-pg": [self.regle], + "/cluster/resources?type=vm": [{"vmid": 9001, "node": "noeud"}], + "/nodes/noeud/qemu/9001/firewall/rules": [self.affectation], + "/nodes/noeud/qemu/9001/firewall/options": {"enable": 1, "policy_in": "REJECT"}, + "/cluster/firewall/options": {"enable": 1, "policy_in": "ACCEPT"}, + } + return copy.deepcopy(lectures[chemin]) + + +class FrontiereFictive: + """Les trois sortes de regles : des descriptions anciennes, des identites stables.""" + def __init__(self, devis, ignorer=False): + self.ignorer = ignorer + self.ecritures = [] + self.objets = {} + for objet, cle, champ, source in ( + ("filter", opn.cle_regle, "description", "regles"), + ("source_nat", opn.cle_nat, "description", "nat"), + ("d_nat", opn.cle_redirection, "descr", "redirections")): + self.objets[objet] = {"uuid": objet, champ: cle(devis[source][0]) + " — ancien", + "enabled": "0", "sequence": "42"} + + def __call__(self, chemin, corps=None): + for objet, courant in self.objets.items(): + prefixe = f"/api/firewall/{objet}/" + if not chemin.startswith(prefixe): + continue + commande = chemin[len(prefixe):].split("/")[0] + if commande == "search_rule": + # La regle humaine n'entre jamais dans le perimetre gere. + return {"rows": [copy.deepcopy(courant), {"uuid": "humaine", "description": "Personnel"}]} + if commande == "get_rule": + return {"rule": copy.deepcopy(courant)} + if commande == "set_rule": + champ = "descr" if objet == "d_nat" else "description" + assert set(corps) == {"rule"} and set(corps["rule"]) == {champ} + self.ecritures.append((chemin, copy.deepcopy(corps))) + if not self.ignorer: + courant.update(corps["rule"]) + return {"result": "saved"} + if commande == "apply": + return {"status": "ok"} + raise AssertionError(f"Ecriture de politique inattendue : {chemin}") + if chemin == "/api/diagnostics/interface/getRoutes": + return [{"destination": "default"}] + if chemin in ("/api/firewall/alias/searchItem/", "/api/routes/routes/searchroute/"): + return {"rows": []} + if chemin == "/api/firewall/alias/reconfigure/": + return {"status": "ok"} + raise AssertionError(chemin) + + +class LibellesParefeu(unittest.TestCase): + def setUp(self): + self.sortie = redirect_stdout(io.StringIO()) + self.sortie.__enter__() + self.addCleanup(self.sortie.__exit__, None, None, None) + + def test_nft_genere_des_commentaires_natifs_et_protege_le_texte(self): + raison = 'Accès "base" via C:\\réseau\n' + 'é' * 150 + declar = {"serveur_postgresql": [{"sens": "ingress", "port": 5432, + "protocole": "tcp", "pair": "serveur_keycloak", + "chiffrement": "tls-requis", "raison": raison}]} + data = {"all": {"children": { + "hotes_actifs": {"hosts": {"base": {"ansible_host": "192.0.2.20"}, + "app": {"ansible_host": "192.0.2.10"}}}, + "serveur_postgresql": {"hosts": {"base": {}}}, + "serveur_keycloak": {"hosts": {"app": {}}}}}} + with tempfile.TemporaryDirectory() as tmp, \ + patch.object(flux, "_inventaire_site", return_value=data), \ + patch.object(flux, "_sources_admin_ssh_site", return_value=["192.0.2.0/24"]), \ + patch.object(flux, "_ports_du_plan_site", return_value={}), \ + patch("underlay.chemin", return_value=Path(tmp) / "underlay.yml"): + flux.generer_nftables(declar, site=True) + texte = (Path(tmp) / "flux-genere/base.nft").read_text() + regle = next(l for l in texte.splitlines() if "tcp dport 5432" in l) + commentaire = json.loads(regle.split(" comment ", 1)[1]) + self.assertTrue(commentaire.startswith('serveur_postgresql: Accès "base" via C:\\réseau ')) + self.assertLessEqual(len(commentaire.encode("utf-8")), 127) + self.assertTrue(commentaire.endswith("...")) + for ligne in texte.splitlines(): + if ligne.strip().startswith(("ip ", "ip6 ", "iif", "oif", "ct ", "reject ")): + self.assertIn(" comment ", ligne) + + def test_nft_repli_annote_toutes_ses_regles(self): + dossier = RACINE / "roles/nftables_baseline" + valeurs = yaml.safe_load((dossier / "defaults/main.yml").read_text()) + valeurs.update(nftables_baseline_extra_tcp_ports=[8080], nftables_baseline_extra_udp_ports=[53]) + env = jinja2.Environment() + env.filters["bool"] = bool + texte = env.from_string((dossier / "templates/nftables.conf.j2").read_text()).render(valeurs) + regles = [l for l in texte.splitlines() if l.strip().startswith(("iif ", "ct ", "ip ", "ip6 ", "tcp ", "udp "))] + self.assertGreaterEqual(len(regles), 8) + self.assertTrue(all(" comment " in l for l in regles)) + + def test_raison_vide_refusee(self): + fl = copy.deepcopy(flux.charger_flux()) + fl["serveur_postgresql"][0]["raison"] = " \n " + with self.assertRaisesRegex(flux.ErreurFlux, "raison attendue"): + flux.valider(fl) + + def devis_proxmox(self): + return {"blocs": [{"ipsets": {"t90-app": {"role": "application", "membres": ["192.0.2.10"]}}, + "groupes": [{"nom": "t90-pg", "role": "serveur_postgresql", "regles": [ + {"sens": "IN", "action": "ACCEPT", "source": "+t90-app", "proto": "tcp", + "dport": "5432", "via": "serveur_keycloak", "raison": "Persistance du SSO."}]}], + "affectations": [{"vmid": 9001, "hote": "base", "groupes": ["t90-pg"]}]}]} + + def test_proxmox_corrige_en_place_puis_converge(self): + api = ClusterFictif() + devis = self.devis_proxmox() + with patch.object(pve, "_prefixes_retires", return_value=()): + p = pve.plan(api, devis) + self.assertFalse(p["groupes_majer"] or p["affect_majer"]) + self.assertTrue(pve.afficher(p)) + self.assertEqual(pve.appliquer(api, p), 0) + self.assertEqual(len(api.ecritures), 2) + self.assertEqual(api.regle["enable"], 0) + self.assertIn("Persistance du SSO.", api.regle["comment"]) + self.assertFalse(pve.afficher(pve.plan(api, devis))) + + def test_proxmox_detecte_une_ecriture_sans_effet(self): + api = ClusterFictif(ignorer=True) + with patch.object(pve, "_prefixes_retires", return_value=()): + self.assertEqual(pve.appliquer(api, pve.plan(api, self.devis_proxmox())), 1) + + def devis_frontiere(self): + return {"alias": {}, "regles": [{"tenant": "exemple", "interface": "wan", "sens": "in", + "protocole": "tcp", "source": "any", "destination": "WEB", "ports": [443], + "role": "serveur_web_frontal", "raison": "Publication HTTPS des sites."}], + "nat": [{"tenant": "exemple", "interface": "wan", "source": "TENANT", + "destination": "any", "cible": "wanip"}], + "redirections": [{"interface": "wan", "protocole": "tcp", "port_public": 443, + "cible": "192.0.2.20", "port_local": 443, "role": "serveur_web_frontal"}]} + + def test_opnsense_corrige_par_uuid_puis_converge(self): + devis = self.devis_frontiere() + api = FrontiereFictive(devis) + p = opn.plan(api, devis) + for categorie in ("regles", "nat", "rdr"): + self.assertFalse(p[categorie + "_creer"] or p[categorie + "_retirer"]) + self.assertTrue(opn.afficher(p)) + self.assertEqual(opn.appliquer(api, p), 0) + self.assertEqual(len(api.ecritures), 3) + self.assertTrue(all(x["enabled"] == "0" and x["sequence"] == "42" for x in api.objets.values())) + self.assertIn("Publication HTTPS des sites.", api.objets["filter"]["description"]) + self.assertFalse(opn.afficher(opn.plan(api, devis))) + devis["regles"][0]["raison"] = "Nouvelle justification." + self.assertEqual(len(opn.plan(api, devis)["descriptions_majer"]), 1) + + def test_opnsense_detecte_une_ecriture_sans_effet(self): + devis = self.devis_frontiere() + api = FrontiereFictive(devis, ignorer=True) + self.assertEqual(opn.appliquer(api, opn.plan(api, devis)), 1) + + def test_opnsense_garde_la_cle_et_borne_les_octets(self): + r = self.devis_frontiere()["regles"][0] + r["raison"] = 'Accès "HTTPS"\n' + 'é' * 300 + cle = opn.cle_regle(r) + texte = opn._corps_regle(r, cle)["description"] + self.assertEqual(texte.split(" — ")[0], cle) + self.assertIn('Accès "HTTPS"', texte) + self.assertNotIn("\n", texte) + self.assertLessEqual(len(texte.encode()), 255) + with self.assertRaises(flux.ErreurFlux): + opn._description("x" * 255, "role", "raison") + + +if __name__ == "__main__": + unittest.main()