contexte : etape 3, la frontiere lit la face reseau
devis_opnsense prend dans la face publiee les adresses des groupes, l'intrant d'administration, les verdicts, le tunnel et les zones publiees. Devis identique a l'octet pres, a HEAD comme par le repli. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8c1e8c725b
commit
013c1b5279
4 changed files with 150 additions and 21 deletions
31
CHANGELOG.md
31
CHANGELOG.md
|
|
@ -1,5 +1,36 @@
|
|||
# CHANGELOG — Set-OPS
|
||||
|
||||
## 2026-10-05 (93) — Étape 3 : la frontière lit la face réseau
|
||||
|
||||
**Le chemin** (`docs/conception-contextes.md` §6), étape 3. Quatrième consommateur du site, le
|
||||
plus lourd : le devis de la frontière (167 Ko). Il lisait chez chaque locataire ses réseaux
|
||||
d'administration (`group_vars`), son inventaire (les adresses de chaque groupe, dont les
|
||||
runners et les primaires DNS de tous les locataires), ses variables (conditions
|
||||
`seulement_si`), son `plan/domaines.yml` et son `plan/acces.yml`.
|
||||
|
||||
**Fait** (`devis_opnsense.py`) : quatre lecteurs, la face d'abord, l'ancienne lecture en
|
||||
repli explicite — `cibles_du_tenant()` (adresses d'un groupe), `admin_du_tenant()`
|
||||
(l'intrant), `applicable_chez()` (le verdict publié), et la face pour le tunnel (présent si
|
||||
un pair est actif) et pour savoir qui publie des zones. Les copies en ligne du réseau et du
|
||||
port du tunnel ne servent plus que le repli ; elles partiront avec lui.
|
||||
|
||||
**Un faux succès, attrapé avant le commit** : la première modification s'est arrêtée sur une
|
||||
assertion (un commentaire séparait deux lignes du motif) **avant d'écrire**. La comparaison
|
||||
qui suivait a dit « identique » — l'ancien code comparé à lui-même. Relevé, refait ; la
|
||||
comparaison avec l'ancien code se fait désormais contre la version de HEAD, lancée à part.
|
||||
|
||||
**Le critère** : le devis de la frontière est **identique octet pour octet** depuis la face,
|
||||
par le repli, et par l'ancien code (HEAD). `make frontiere-plan` contre la frontière réelle :
|
||||
rien à créer, rien à retirer.
|
||||
|
||||
**Éprouvé** :
|
||||
- `test_contexte.py`, 102 contrôles : un verdict basculé dans la face publiée retire sa règle
|
||||
WAN (soumission Postfix 587 : 1 règle, puis 0) — la frontière lit bien la face.
|
||||
- `make verifier` conforme, 92/92.
|
||||
|
||||
**Restent à basculer** : la découverte des locataires (index et catégories, lus par la
|
||||
frontière, le SDN, les pools et le commutateur), le placement et le clonage.
|
||||
|
||||
## 2026-10-05 (92) — Étape 3 : le pare-feu Proxmox lit la face réseau
|
||||
|
||||
**Le chemin** (`docs/conception-contextes.md` §6), étape 3. Troisième consommateur du site :
|
||||
|
|
|
|||
|
|
@ -182,7 +182,8 @@ les publie désormais (`sources_declarees`). La frontière, en trois temps : les
|
|||
(P87), les entrées publiques (P88), l'administration (P89) et les sorties (P90) sont faites.
|
||||
**L'étape 2 est terminée** (2026-10-05). Étape 3 : l'instancier lit la fiche déposée par le site, et
|
||||
l'inventaire d'un locataire se génère sans son site, à l'octet près (P91). Le locataire publie sa face
|
||||
réseau (P92) ; les comptes de sauvegarde, le DNS public et le pare-feu Proxmox du site la lisent.
|
||||
réseau (P92) ; les comptes de sauvegarde, le DNS public, le pare-feu Proxmox et la frontière du site
|
||||
la lisent.
|
||||
|
||||
Méthodes du contrat : `site.fiche_pour(locataire)`, `locataire.face_reseau()`. Une classe
|
||||
n'ouvre jamais les fichiers de l'autre ; une preuve vérifiera la règle.
|
||||
|
|
|
|||
|
|
@ -297,6 +297,57 @@ def cibles_par_role(role: str, inventaire: Path) -> list[str]:
|
|||
#
|
||||
# L'outil a bien refuse d'appliquer quoi que ce soit, mais le devis, lui, annoncait un
|
||||
# objet impossible. Un devis qui promet ce que la cible refusera n'est pas un devis.
|
||||
# LA FACE RESEAU D'ABORD (2026-10-05, docs/conception-contextes.md §6, etape 3). Le locataire
|
||||
# publie `face-reseau.yml` : ses groupes et leurs adresses, son administration, son tunnel, ses
|
||||
# zones publiques, le verdict de ses flux conditionnels. La frontiere ne lit plus son inventaire,
|
||||
# ses variables ni son plan. Sans face publiee (transition), l'ancienne lecture, et on le dit.
|
||||
_FACES: dict[str, dict | None] = {}
|
||||
|
||||
|
||||
def face_publiee(nom: str) -> dict | None:
|
||||
if nom not in _FACES:
|
||||
try:
|
||||
import contexte
|
||||
from devis_reseau import DOSSIER_INSTANCES
|
||||
_FACES[nom] = contexte.Locataire(DOSSIER_INSTANCES / nom).face_publiee()
|
||||
except Exception:
|
||||
_FACES[nom] = None
|
||||
if _FACES[nom] is None:
|
||||
print(f"note : {nom} n'a pas publie sa face reseau — lecture de ses fichiers "
|
||||
f"internes (transition).", file=sys.stderr)
|
||||
return _FACES[nom]
|
||||
|
||||
|
||||
def cibles_du_tenant(role: str, nom: str) -> list[str]:
|
||||
"""IP des hotes qui portent ce role chez ce locataire : sa face, sinon son inventaire."""
|
||||
face = face_publiee(nom)
|
||||
if face is not None:
|
||||
ips = {h: m.get("ip") for h, m in (face.get("machines") or {}).items()}
|
||||
return sorted({ips[h] for h in (face.get("groupes") or {}).get(role, []) if ips.get(h)})
|
||||
inv = inventaire_de(nom)
|
||||
return cibles_par_role(role, inv) if inv else []
|
||||
|
||||
|
||||
def admin_du_tenant(nom: str) -> list[str]:
|
||||
"""Ses reseaux d'administration declares (l'intrant) : sa face, sinon ses `group_vars`."""
|
||||
face = face_publiee(nom)
|
||||
if face is not None:
|
||||
return list((face.get("administration") or {}).get("intrant") or [])
|
||||
return admin_de(nom)
|
||||
|
||||
|
||||
def applicable_chez(fl: dict, role: str, nom: str) -> bool:
|
||||
"""Le flux s'applique-t-il chez ce locataire ? Son verdict publie, sinon ses variables."""
|
||||
if not fl.get("seulement_si"):
|
||||
return True
|
||||
face = face_publiee(nom)
|
||||
if face is not None:
|
||||
import contexte
|
||||
return bool(((face.get("conditions") or {}).get(role) or {}).get(contexte.cle_condition(fl), True))
|
||||
inv = inventaire_de(nom)
|
||||
return flux_applicable(fl, role, inv.parent.parent.parent) if inv else True
|
||||
|
||||
|
||||
LONGUEUR_MAX_ALIAS = 31
|
||||
|
||||
# Meme abreviation que le pare-feu est-ouest (`devis_proxmox_fw` : serveur_ -> srv-), pour
|
||||
|
|
@ -495,7 +546,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
|||
admin_par_if: dict[str, dict[str, list[str]]] = {}
|
||||
for nom, pfx, n in tenants:
|
||||
etiquette = f"{pfx}{n['index']}"
|
||||
cidrs = admin_de(nom)
|
||||
cidrs = admin_du_tenant(nom)
|
||||
admin_par_tenant[etiquette] = cidrs
|
||||
par_vpn = [c for c in cidrs if _par_le_vpn(c)]
|
||||
locaux = [c for c in cidrs if c not in par_vpn and _porte_par(c, reseaux_gestion)]
|
||||
|
|
@ -559,13 +610,12 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
|||
for nom, pfx, n in tenants:
|
||||
etiquette = f"{pfx}{n['index']}"
|
||||
alias_tenant = f"SETOPS_TENANT_{etiquette}"
|
||||
inv = inventaire_de(nom)
|
||||
if inv is None:
|
||||
if face_publiee(nom) is None and inventaire_de(nom) is None:
|
||||
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):
|
||||
if not applicable_chez(fl, role, nom):
|
||||
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`
|
||||
|
|
@ -576,7 +626,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
|||
if role == ROLE_SOCLE:
|
||||
destination = alias_tenant
|
||||
else:
|
||||
cibles = cibles_par_role(role, inv)
|
||||
cibles = cibles_du_tenant(role, nom)
|
||||
if not cibles:
|
||||
continue # role absent de ce tenant : aucune regle a poser
|
||||
a = nom_alias(role, etiquette)
|
||||
|
|
@ -887,9 +937,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
|||
# au SSH du site rendrait l'insemination indiscernable d'un droit d'entree
|
||||
# general — exactement ce que l'isolation refuse.
|
||||
_runners_tenants = sorted({ip for _n2, _p2, _x2 in tenants
|
||||
for ip in (cibles_par_role("serveur_ops_tenant",
|
||||
inventaire_de(_n2))
|
||||
if inventaire_de(_n2) else [])})
|
||||
for ip in cibles_du_tenant("serveur_ops_tenant", _n2)})
|
||||
if _runners_tenants:
|
||||
alias["SETOPS_RUNNERS_TENANTS"] = {
|
||||
"type": "host",
|
||||
|
|
@ -909,6 +957,9 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
|||
# n'y ecoutait — mais un chemin ouvert vers une machine qui n'en a pas besoin est
|
||||
# exactement ce que le moindre privilege refuse.
|
||||
def _publie(_nom_tenant: str) -> bool:
|
||||
_face = face_publiee(_nom_tenant)
|
||||
if _face is not None:
|
||||
return bool((_face.get("dns_public") or {}).get("zones"))
|
||||
try:
|
||||
import devis_reseau as _dr
|
||||
_f = _dr.DOSSIER_INSTANCES / _nom_tenant / "plan" / "domaines.yml"
|
||||
|
|
@ -918,9 +969,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
|||
except (OSError, ImportError):
|
||||
return False
|
||||
_primaires_dns = sorted({ip for _n2, _p2, _x2 in tenants if _publie(_n2)
|
||||
for ip in (cibles_par_role("serveur_powerdns",
|
||||
inventaire_de(_n2))
|
||||
if inventaire_de(_n2) else [])})
|
||||
for ip in cibles_du_tenant("serveur_powerdns", _n2)})
|
||||
if _primaires_dns:
|
||||
alias["SETOPS_DNS_PRIMAIRES_TENANTS"] = {
|
||||
"type": "host",
|
||||
|
|
@ -1574,15 +1623,21 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
|||
_idx_t = _n_t.get("index")
|
||||
if _idx_t is None:
|
||||
continue
|
||||
try:
|
||||
_acces_t = (yaml.safe_load(
|
||||
(_dr.DOSSIER_INSTANCES / _nom_t / "plan" / "acces.yml").read_text(encoding="utf-8"))
|
||||
or {}).get("acces_admin_vpn") or {}
|
||||
except (OSError, yaml.YAMLError):
|
||||
continue
|
||||
if not [n for n, c in _acces_t.items()
|
||||
if isinstance(c, dict) and str(c.get("etat", "present")) == "present"]:
|
||||
continue # aucun pair actif : aucune route, aucune regle
|
||||
_face_t = face_publiee(_nom_t)
|
||||
if _face_t is not None:
|
||||
# Le tunnel n'existe que s'il a un pair actif : la face le dit (`tunnel` vide sinon).
|
||||
if not (_face_t.get("administration") or {}).get("tunnel"):
|
||||
continue
|
||||
else:
|
||||
try:
|
||||
_acces_t = (yaml.safe_load(
|
||||
(_dr.DOSSIER_INSTANCES / _nom_t / "plan" / "acces.yml").read_text(encoding="utf-8"))
|
||||
or {}).get("acces_admin_vpn") or {}
|
||||
except (OSError, yaml.YAMLError):
|
||||
continue
|
||||
if not [n for n, c in _acces_t.items()
|
||||
if isinstance(c, dict) and str(c.get("etat", "present")) == "present"]:
|
||||
continue # aucun pair actif : aucune route, aucune regle
|
||||
_etiq_t = f"{_pfx_t}{_idx_t}"
|
||||
_alias_tunnel = f"SETOPS_ADMIN_{_etiq_t}_TUNNEL"
|
||||
alias[_alias_tunnel] = {
|
||||
|
|
|
|||
|
|
@ -510,6 +510,47 @@ def proxmox_lit_la_face() -> None:
|
|||
f"le devis Proxmox lit la face publiee : {g} {port}/tcp, {avant} regle(s), 0 une fois le verdict bascule")
|
||||
|
||||
|
||||
def frontiere_lit_la_face() -> None:
|
||||
"""La frontiere lit la face publiee : un verdict bascule dans la face retire sa regle WAN."""
|
||||
import yaml as _yaml
|
||||
couples = C.couples_hebergeur()
|
||||
if not couples:
|
||||
print("SAUTE aucun site frere : aucune frontiere a confronter")
|
||||
return
|
||||
site, loc = couples[0]
|
||||
face = loc.face_publiee() or {}
|
||||
cible = next(((g, k) for g, v in (face.get("conditions") or {}).items() for k, ok in v.items()
|
||||
if ok and k.startswith("ingress|tcp|") and g == "serveur_postfix"), None)
|
||||
if cible is None:
|
||||
print("SAUTE aucune soumission Postfix active a basculer")
|
||||
return
|
||||
g, cle = cible
|
||||
port = cle.split("|")[2]
|
||||
|
||||
def regles_wan() -> int:
|
||||
import devis_opnsense
|
||||
devis_opnsense._FACES.clear()
|
||||
_e, devis = C._devis_frontiere(site, loc)
|
||||
return sum(1 for r in devis.get("regles") or []
|
||||
if r.get("tenant") == loc.nom and r.get("role") == g and port in (r.get("ports") or [])
|
||||
and r.get("interface") == devis.get("if_wan"))
|
||||
|
||||
chemin = loc.depot / C.NOM_FICHIER_FACE_RESEAU
|
||||
original = chemin.read_text(encoding="utf-8")
|
||||
avant = regles_wan()
|
||||
try:
|
||||
f = _yaml.safe_load(original)
|
||||
f["conditions"][g][cle] = False
|
||||
chemin.write_text(_yaml.safe_dump(f, allow_unicode=True, sort_keys=False), encoding="utf-8")
|
||||
apres = regles_wan()
|
||||
finally:
|
||||
chemin.write_text(original, encoding="utf-8")
|
||||
import devis_opnsense
|
||||
devis_opnsense._FACES.clear()
|
||||
verifier(avant > 0 and apres == 0,
|
||||
f"la frontiere lit la face publiee : {g} {port}/tcp sur le WAN, {avant} regle(s), 0 une fois le verdict bascule")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
d = Path(tmp)
|
||||
|
|
@ -528,6 +569,7 @@ def main() -> int:
|
|||
fiche_deposee()
|
||||
face_publiee()
|
||||
proxmox_lit_la_face()
|
||||
frontiere_lit_la_face()
|
||||
if ECHECS:
|
||||
print(f"\n{len(ECHECS)} echec(s).")
|
||||
return 1
|
||||
|
|
|
|||
Loading…
Reference in a new issue