flux : seulement_si — un flux peut dependre d'un reglage de l'ecosysteme

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 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-10-01 10:45:48 -04:00
parent 209730b62a
commit 34bfd43e06
6 changed files with 85 additions and 4 deletions

View file

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

View file

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

View file

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

View file

@ -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_<tenant>_SRV_PROMETHEUS` chez chaque locataire, qu'aucune regle

View file

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

View file

@ -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: <nom>, egal: <valeur>}
#
# 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: <nom>, egal: <valeur>}}")
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