pare-feu : conserver et réconcilier les raisons des flux

This commit is contained in:
Daniel Allaire 2026-10-09 19:56:28 -04:00
parent 162604ad24
commit dd4ca09245
10 changed files with 623 additions and 27 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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