From 34bfd43e06b8d158d3789bef07841fe002577f73 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Thu, 1 Oct 2026 10:45:48 -0400 Subject: [PATCH] =?UTF-8?q?flux=20:=20seulement=5Fsi=20=E2=80=94=20un=20fl?= =?UTF-8?q?ux=20peut=20dependre=20d'un=20reglage=20de=20l'ecosysteme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La vigie (8080) et la console (8090) n'ecoutent sur le reseau qu'en mode locale ; en SSO, nginx est lie a 127.0.0.1 et la regle ne menait a rien. Evaluee par ecosysteme dans les trois generateurs (nftables, Proxmox, frontiere). Locataires : 8 regles Proxmox et 4 nftables en moins. Site : inchange. Co-Authored-By: Claude Opus 5.5 --- docs/audit/preuve-2026-09-30.md | 9 ++-- roles/serveur_icingaweb2/meta/flux.yml | 5 ++ roles/serveur_ops/meta/flux.yml | 5 ++ scripts/devis_opnsense.py | 4 ++ scripts/devis_proxmox_fw.py | 3 ++ scripts/resoudre_flux.py | 63 ++++++++++++++++++++++++++ 6 files changed, 85 insertions(+), 4 deletions(-) diff --git a/docs/audit/preuve-2026-09-30.md b/docs/audit/preuve-2026-09-30.md index c4f14b8..38487f0 100644 --- a/docs/audit/preuve-2026-09-30.md +++ b/docs/audit/preuve-2026-09-30.md @@ -7,7 +7,7 @@ > [`docs/audit/affirmations.md`](affirmations.md). - **Instance** : `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance` — inventaire `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance/inventories/principal/hosts.yml` -- **Verdict** : ✅ CONFORME (82 OK · 0 echec · 1 saute) +- **Verdict** : ❌ NON CONFORME (81 OK · 1 echec · 1 saute) ## Preuves @@ -37,7 +37,7 @@ | 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), 44 groupe(s), 116 regle(s). | +| 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. | @@ -62,7 +62,7 @@ | 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 101 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 (139 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 317 regles `pass`), tous non consignes et tous motives. | +| 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 309 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 muette (actions : b | @@ -91,7 +91,8 @@ | P76 | Tout gabarit de role se rend vraiment | — | ✅ OK | 185 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 | 5 ecosysteme(s) (instance-ci-1646753, OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, SITE-Chezlepro) : pattes, edges, certificats, rechargements, jumeaux d' | +| P79 | Replis silencieux : une derivation vide ne passe pas pour un succes | — | ❌ ECHEC | 1 repli(s) silencieux — une derivation vide rend un succes : + - flux : OPS-Chezlepro-lab — perimees par rapport au plan : ops-01.nft | | 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 | diff --git a/roles/serveur_icingaweb2/meta/flux.yml b/roles/serveur_icingaweb2/meta/flux.yml index ee38916..52a2349 100644 --- a/roles/serveur_icingaweb2/meta/flux.yml +++ b/roles/serveur_icingaweb2/meta/flux.yml @@ -13,10 +13,15 @@ flux: # Les deux consoles de l'observabilite avaient la meme contrainte et une seule des deux # l'avait ecrite. En mode `locale`, nginx authentifie lui-meme : ce qui entre par le # plan d'administration frappe un vestibule, pas l'application. + # + # SEULEMENT SI NGINX ECOUTE SUR LE RESEAU (2026-09-30). En SSO, il est lie a 127.0.0.1 + # derriere la passerelle (`serveur_icingaweb2_nginx_bind: "127.0.0.1:"`) : ouvrir ce port + # depuis l'edge ne menait a rien — la sonde `connectivite` le disait chaque minute. - sens: ingress port: 8080 protocole: tcp pair: [edge, admin] + seulement_si: {variable: serveur_icingaweb2_nginx_bind, egal: ""} chiffrement: clair raison: >- Interface web servie via l'edge (TLS terminé à l'edge ; SSO possible via diff --git a/roles/serveur_ops/meta/flux.yml b/roles/serveur_ops/meta/flux.yml index b17b60a..8c28105 100644 --- a/roles/serveur_ops/meta/flux.yml +++ b/roles/serveur_ops/meta/flux.yml @@ -15,10 +15,15 @@ flux: # # LA REGLE N'EST EMISE QUE SI LE PLAN L'EXPOSE : un runner qui ne publie pas sa console # n'a pas ce port ouvert, et `serveur_ops_gui_actif` reste faux. + # + # SEULEMENT EN MODE `locale` (2026-09-30) : c'est la que nginx EST le vestibule et ecoute + # sur le reseau. En `oidc`, il est lie a 127.0.0.1 (voir `serveur_ops_gui_nginx_bind`) et + # la regle etait « sans objet » — desormais elle n'est plus emise du tout. - sens: ingress port: 8090 protocole: tcp pair: [edge, admin] + seulement_si: {variable: serveur_ops_gui_auth, egal: locale} chiffrement: clair raison: >- Console d'exploitation servie par l'edge (TLS terminé à l'edge), et joignable diff --git a/scripts/devis_opnsense.py b/scripts/devis_opnsense.py index be587d0..eafe269 100644 --- a/scripts/devis_opnsense.py +++ b/scripts/devis_opnsense.py @@ -47,6 +47,7 @@ from inventory_rules import sous_reseau_de, supernet_de # noqa: E402 # lecture, sinon le devis et les nftables divergeraient en silence. from resoudre_flux import ( # noqa: E402 charger_flux, + flux_applicable, _enfants, _hotes_du_groupe, _inventaire, @@ -563,6 +564,9 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: tenants_sans_inventaire.append(nom) continue for role, fl in retenus: + # Un flux conditionnel (`seulement_si`) ne vaut que la ou son reglage le dit. + if not flux_applicable(fl, role, inv.parent.parent.parent): + continue # AVANT L'ALIAS (2026-09-17). Le saut plus bas arrivait APRES la creation de # l'alias du role : un flux vers la frontiere declare par `serveur_prometheus` # posait `SETOPS__SRV_PROMETHEUS` chez chaque locataire, qu'aucune regle diff --git a/scripts/devis_proxmox_fw.py b/scripts/devis_proxmox_fw.py index 80d137d..71e81e9 100644 --- a/scripts/devis_proxmox_fw.py +++ b/scripts/devis_proxmox_fw.py @@ -41,6 +41,7 @@ from resoudre_flux import ( # noqa: E402 GROUPE_EDGE, charger_flux, est_groupe_operationnel, + flux_applicable, _enfants, _hotes_du_groupe, _ip_par_hote, @@ -204,6 +205,8 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: for fl in flux_par_role.get(groupe, []): if fl.get("sens") != "ingress": continue + if not flux_applicable(fl, groupe, inv.parent.parent.parent): + continue pairs = _pairs(fl) # `externe` est saute PAIR PAR PAIR, jamais le flux entier : le SSH du # socle est declare `[flotte, externe]`, et sauter tout le flux perdait diff --git a/scripts/resoudre_flux.py b/scripts/resoudre_flux.py index a4506db..a1d5ad1 100644 --- a/scripts/resoudre_flux.py +++ b/scripts/resoudre_flux.py @@ -100,6 +100,60 @@ def charger_flux() -> dict[str, list[dict]]: return resultat +# UN FLUX PEUT DEPENDRE D'UN REGLAGE (2026-09-30). La console d'exploitation et Icinga Web +# n'ecoutent sur le reseau qu'en mode `locale` (le SITE, sans annuaire) ; en SSO, nginx est +# lie a 127.0.0.1 derriere la passerelle, et le port ouvert depuis l'edge ne mene a rien — +# la sonde `connectivite` le montrait chaque minute. Le code le savait (« la regle devient +# simplement sans objet ») ; le registre ne savait pas le dire. +# +# seulement_si: {variable: , egal: } +# +# La valeur est lue la ou ce role la lit : variables de l'hote (inventaire du site), puis +# `group_vars` de l'ecosysteme, puis defauts du role. INDETERMINABLE (une expression Jinja) +# -> la regle est emise, comme avant, et on le dit : ouvrir sans savoir est le statu quo, +# fermer sans savoir pourrait couper une console. +def _valeur_variable(variable: str, groupe: str, dossier: Path | None, + hostvars: dict | None) -> tuple[bool, object]: + if hostvars and variable in hostvars: + return True, hostvars[variable] + if dossier is not None: + gv = dossier / "inventories" / "principal" / "group_vars" + candidats = [gv / f"{groupe}.yml", *sorted((gv / groupe).glob("*.yml"))] + candidats += [f for f in sorted((gv / "all").glob("*.yml")) if "vault" not in f.name] + for f in candidats: + if not f.is_file(): + continue + try: + data = yaml.safe_load(f.read_text(encoding="utf-8")) or {} + except yaml.YAMLError: + continue + if isinstance(data, dict) and variable in data: + return True, data[variable] + defauts = ROLES / groupe / "defaults" / "main.yml" + if defauts.is_file(): + data = yaml.safe_load(defauts.read_text(encoding="utf-8")) or {} + if variable in data: + v = data[variable] + if isinstance(v, str) and "{{" in v: + return False, None + return True, v + return False, None + + +def flux_applicable(fl: dict, groupe: str, dossier: Path | None, + hostvars: dict | None = None) -> bool: + """Le flux s'applique-t-il a CET ecosysteme (et a cet hote) ? Voir `seulement_si`.""" + cond = fl.get("seulement_si") + if not cond: + return True + connu, valeur = _valeur_variable(str(cond.get("variable")), groupe, dossier, hostvars) + if not connu: + print(f"note : {groupe} — `{cond.get('variable')}` indeterminable ici : la regle " + f"du port {fl.get('port')} est emise (statu quo).", file=sys.stderr) + return True + return str(valeur if valeur is not None else "") == str(cond.get("egal", "")) + + def _pairs(flux: dict) -> list: pair = flux.get("pair") return pair if isinstance(pair, list) else [pair] @@ -125,6 +179,10 @@ def valider(flux_par_role: dict[str, list[dict]]) -> tuple[int, int]: erreurs.append(f"{ref} : protocole invalide {fl.get('protocole')!r}") if fl.get("chiffrement") not in CHIFFREMENT: erreurs.append(f"{ref} : chiffrement invalide {fl.get('chiffrement')!r}") + cond = fl.get("seulement_si") + if cond is not None and (not isinstance(cond, dict) or not cond.get("variable") + or "egal" not in cond): + erreurs.append(f"{ref} : seulement_si attend {{variable: , egal: }}") for p in _pairs(fl): if p not in MOTS_PAIR and p not in roles_existants and not str(p).startswith(("serveur_", "client_")): erreurs.append(f"{ref} : pair inconnu {p!r}") @@ -704,8 +762,10 @@ def generer_nftables(flux_par_role: dict[str, list[dict]], site: bool = False) - # chez le site, pas dans l'instance montee au moment ou on l'a genere. import underlay as underlay_mod sortie_dir = (underlay_mod.chemin().resolve().parent / "flux-genere") + dossier_ecosysteme = None # le site porte ses reglages par hote (plan) else: sortie_dir = _inventaire().parent.parent.parent / "flux-genere" + dossier_ecosysteme = _inventaire().parent.parent.parent sortie_dir.mkdir(parents=True, exist_ok=True) ecrits: list[Path] = [] # CE QUE CHAQUE VM DOIT POUVOIR JOINDRE, ET DE QUI ELLE ACCEPTE (2026-09-30). La sonde @@ -723,6 +783,9 @@ def generer_nftables(flux_par_role: dict[str, list[dict]], site: bool = False) - for fl in flux_par_role.get(g, []): if fl.get("sens") != "ingress": continue + if not flux_applicable(fl, g, dossier_ecosysteme, + (_enfants(data).get(g, {}).get("hosts") or {}).get(hote)): + continue ports = fl["port"] if isinstance(fl["port"], list) else [fl["port"]] # `derive` se resout par le plan ; sans lui on n'emet RIEN plutot qu'une # regle que `nft` refusera — et l'on DIT ce qu'on ne pose pas. Un flux