pare-feu : conserver et réconcilier les raisons des flux
This commit is contained in:
parent
162604ad24
commit
dd4ca09245
10 changed files with 623 additions and 27 deletions
81
CHANGELOG.md
81
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
|
||||
|
|
|
|||
1
Makefile
1
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
|
||||
|
|
|
|||
130
docs/audit/preuve-2026-10-09.md
Normal file
130
docs/audit/preuve-2026-10-09.md
Normal 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._
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
225
scripts/tests/test_libelles_parefeu.py
Normal file
225
scripts/tests/test_libelles_parefeu.py
Normal 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()
|
||||
Loading…
Reference in a new issue