inter-sites : le lien entre les sites, et les pattes de tunnel hors des pattes de zone

- underlay.py : role d'hote frontiere_pair ; reseaux_tunnel() ecarte les tunnels des pattes
  de la frontiere (reseaux de gestion, SETOPS_FABRIC, SETOPS_FRONTIERE, resoudre_flux,
  supervision des pattes).
- devis_opnsense : seuls les roles des machines du plan font un alias (le relais en faisait un vide).
- La machine du cache recoit artefacts_amorcage_direct au lieu d'une valeur vide : le vide
  ouvrait la sortie 80/443 du socle a toutes les machines du site (jamais applique). P79
  revient a sa forme stricte.

95 preuves (make verifier), make test passe. CHANGELOG 148.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-10-14 01:38:40 -04:00
parent ca731eedc4
commit 65a4268871
7 changed files with 90 additions and 31 deletions

View file

@ -1,3 +1,35 @@
## 2026-10-14 (148) — Le lien entre les sites, et ce qu'il a montré en ne devant rien ouvrir
**95 preuves.** `make verifier` passe (site de Chezlepro), `make test` passe. Témoin : avec le lien
déclaré dans sa carte, le devis de la frontière de Chezlepro est **identique au caractère près**
à celui d'avant-hier, et son inventaire ne diffère que par le drapeau du cache ; les fiches
remises aux locataires ne changent que par l'empreinte de la carte.
**Le tunnel entre les sites (phase 1).** L'ancien tunnel `bifrost-1` ↔ site de TechnoLibre était
mort depuis le remplacement du boîtier, et ses adresses autorisées (`10.23.0.0/16`, l'ancien
index du site) recouvraient l'écosystème OPS-Technolibre hébergé à Chezlepro. Refait :
`requin` l'initie (DHCP derrière une traduction d'adresse) vers `69.70.26.62:51820` ; chaque
bout n'admet que l'autre site et les deux primaires DNS des locataires ; routes WireGuard
désactivées des deux côtés (la route `10.23.0.0/16` de `bifrost-1` a disparu) ; interface
`INTERSITES` sans règle sur `requin` — rien ne passe encore. Prouvé : poignée de main des deux
côtés, compteurs qui montent. Déclaré dans les deux cartes (réseau `inter-sites`, rôle d'hôte
`frontiere_pair`). Sur `bifrost-1`, enregistrer l'instance avec un seul champ a vidé son lien
vers le pair (l'API remet à vide les relations absentes) : vu au relevé suivant, rétabli.
**Une régression de l'entrée 146, attrapée avant toute application.** La valeur VIDE
d'`artefacts_amorcage` (machine du cache) activait la sortie conditionnelle 80/443 de
`serveur_debian` — que le devis ouvre pour tout le groupe, pas pour la machine qui remplit la
condition : toutes les machines du site seraient sorties sur l'Internet. Ni `requin` ni
`bifrost-1` n'ont reçu ce devis. Remplacé par un drapeau à part, `artefacts_amorcage_direct` ;
P79 revient à sa forme stricte.
**Les pattes de tunnel ne sont pas des pattes de zone.** Le relais puis le lien ont donné à la
frontière des adresses qui devenaient sources admises des journaux et des métriques — en
désaccord avec les règles des locataires. `underlay.reseaux_tunnel()` les écarte des six
dérivations : réseaux de gestion, `SETOPS_FABRIC`, `SETOPS_FRONTIERE`, fabric et frontière dans
`resoudre_flux`, supervision des pattes. Et le devis ne retient plus que les rôles des machines
du PLAN : `serveur_relais`, porté par le seul relais, y faisait un alias vide.
## 2026-10-14 (147) — L'ordre d'un site neuf : le dépôt avant l'autorité, le cache avant tous ## 2026-10-14 (147) — L'ordre d'un site neuf : le dépôt avant l'autorité, le cache avant tous
**95 preuves.** `make verifier` passe (site de Chezlepro), `make test` passe. Simulation complète **95 preuves.** `make verifier` passe (site de Chezlepro), `make test` passe. Simulation complète

View file

@ -78,18 +78,16 @@
when: when:
- artefacts_amorcage is defined - artefacts_amorcage is defined
- artefacts_amorcage | string | length > 0 - artefacts_amorcage | string | length > 0
- not (artefacts_amorcage_direct | default(false) | bool)
# LA MACHINE QUI PORTE LE CACHE VA DROIT CHEZ DEBIAN (2026-10-13). L'inventaire lui # LA MACHINE QUI PORTE LE CACHE VA DROIT CHEZ DEBIAN (2026-10-13). Le fichier herite du
# donne `artefacts_amorcage` VIDE (et non absent) : le fichier herite du gabarit vise # gabarit vise un cache qui n'existe pas encore — le sien — ou une adresse perimee :
# un cache qui n'existe pas encore — le sien — ou une adresse perimee. Absent, il # l'inventaire le dit par `artefacts_amorcage_direct`, et on le retire.
# survivrait ; vide, on le retire.
- name: Retirer l'amorcage sur la machine qui porte le cache - name: Retirer l'amorcage sur la machine qui porte le cache
ansible.builtin.file: ansible.builtin.file:
path: /etc/apt/apt.conf.d/00-setops-amorcage-artefacts path: /etc/apt/apt.conf.d/00-setops-amorcage-artefacts
state: absent state: absent
when: when: artefacts_amorcage_direct | default(false) | bool
- artefacts_amorcage is defined
- artefacts_amorcage | string | length == 0
# RETIRER CE QUI DESIGNE UN CACHE QUI N'EXISTE PLUS (2026-09-10). # RETIRER CE QUI DESIGNE UN CACHE QUI N'EXISTE PLUS (2026-09-10).
# #

View file

@ -178,6 +178,8 @@ def reseaux_locaux_frontiere(underlay: dict | None, transit: dict | None) -> lis
sur = {str(h.get("reseau")) for h in underlay_mod.hotes(underlay) sur = {str(h.get("reseau")) for h in underlay_mod.hotes(underlay)
if str(h.get("nom") or "") == nom} if str(h.get("nom") or "") == nom}
sur.discard(str((transit or {}).get("nom"))) sur.discard(str((transit or {}).get("nom")))
# Un tunnel arrive par SON interface, jamais par la gestion (relais, lien entre sites).
sur -= underlay_mod.reseaux_tunnel(underlay)
cidrs = [] cidrs = []
for r in underlay_mod.reseaux(underlay): for r in underlay_mod.reseaux(underlay):
if str(r.get("nom")) in sur and r.get("sous_reseau"): if str(r.get("nom")) in sur and r.get("sous_reseau"):
@ -907,7 +909,8 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
# par `!SETOPS_INTERNES` (la forme de « vers Internet ») l'aurait exclu tout en # par `!SETOPS_INTERNES` (la forme de « vers Internet ») l'aurait exclu tout en
# ayant l'air d'ouvrir le flux. D'ou le mot `fabric`, et cet alias-ci. # ayant l'air d'ouvrir le flux. D'ou le mot `fabric`, et cet alias-ci.
_fabric = sorted({str(h["ip"]) for h in underlay_mod.hotes(_u) _fabric = sorted({str(h["ip"]) for h in underlay_mod.hotes(_u)
if h.get("role") in ("hyperviseur", "frontiere") and h.get("ip")}) if h.get("role") in ("hyperviseur", "frontiere") and h.get("ip")
and str(h.get("reseau")) not in underlay_mod.reseaux_tunnel(_u)})
alias["SETOPS_FABRIC"] = { alias["SETOPS_FABRIC"] = {
"type": "host", "type": "host",
"contenu": _fabric, "contenu": _fabric,
@ -1020,7 +1023,12 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
"description": "Autoritatifs des tenants heberges — source du DNS public", "description": "Autoritatifs des tenants heberges — source du DNS public",
} }
_roles_site = sorted({g for gs in _services_site.values() for g in gs} # LES ROLES DES MACHINES DU PLAN, PAS DE TOUT L'INVENTAIRE (2026-10-14). L'inventaire du
# site compte aussi des EQUIPEMENTS hors du plan — les hyperviseurs, le relais —, mais
# les adresses des alias ne viennent que du plan : un role porte par le seul relais
# (`serveur_relais`) donnait un alias VIDE, et le devis refusait de se rendre.
_noms_plan = {str(m["nom"]) for m in _machines_site}
_roles_site = sorted({g for h, gs in _services_site.items() if h in _noms_plan for g in gs}
| {"serveur_debian"}) | {"serveur_debian"})
for _role in _roles_site: for _role in _roles_site:
_ips = sorted({str(m["ip"]) for m in _machines_site _ips = sorted({str(m["ip"]) for m in _machines_site
@ -1583,7 +1591,8 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
if _flux_frontiere: if _flux_frontiere:
_pattes_frontiere = sorted({str(h["ip"]) for h in underlay_mod.hotes(_u) _pattes_frontiere = sorted({str(h["ip"]) for h in underlay_mod.hotes(_u)
if h.get("role") == "frontiere" and h.get("ip") if h.get("role") == "frontiere" and h.get("ip")
and str(h.get("etat", "actif")) != "reserve"}) and str(h.get("etat", "actif")) != "reserve"
and str(h.get("reseau")) not in underlay_mod.reseaux_tunnel(_u)})
if _pattes_frontiere: if _pattes_frontiere:
alias["SETOPS_FRONTIERE"] = { alias["SETOPS_FRONTIERE"] = {
"type": "host", "type": "host",

View file

@ -4079,19 +4079,10 @@ def _repli_hotes_du_site(enfants: dict, hv: dict) -> list[str]:
fautes.append(f"rechargement : {h} renouvelle son certificat sans recharger " fautes.append(f"rechargement : {h} renouvelle son certificat sans recharger "
f"nginx — le nouveau reste sur disque, l'ancien est servi") f"nginx — le nouveau reste sur disque, l'ancien est servi")
if membres("serveur_artefacts"): if membres("serveur_artefacts"):
porteurs = set(membres("serveur_artefacts"))
for h, v in sorted(hv.items()): for h, v in sorted(hv.items()):
v = v or {} if (v or {}).get("dns_amorcage") and not (v or {}).get("artefacts_amorcage"):
if not v.get("dns_amorcage") or v.get("artefacts_amorcage"): fautes.append(f"jumeaux : {h} recoit `dns_amorcage` sans "
continue f"`artefacts_amorcage` — le gabarit garde son ancien proxy apt")
# VIDE SUR LA MACHINE QUI PORTE LE CACHE, ET SEULEMENT LA (2026-10-13). Elle ne
# peut pas s'amorcer sur un cache qu'elle n'a pas encore : le socle RETIRE alors
# le fichier du gabarit (`artefacts_amorcage` defini et vide). Absent, ou vide
# ailleurs, c'est toujours le repli silencieux que cette garde existe pour voir.
if h in porteurs and "artefacts_amorcage" in v:
continue
fautes.append(f"jumeaux : {h} recoit `dns_amorcage` sans "
f"`artefacts_amorcage` — le gabarit garde son ancien proxy apt")
return fautes return fautes

View file

@ -368,8 +368,11 @@ def _adresses_fabric() -> list[str]:
carte = _u.charger() carte = _u.charger()
if not carte: if not carte:
return [] return []
# Meme exclusion que `SETOPS_FABRIC` : une patte de tunnel n'est pas du materiel
# que l'on joint (voir `underlay.reseaux_tunnel`).
return sorted({str(h["ip"]) for h in _u.hotes(carte) return sorted({str(h["ip"]) for h in _u.hotes(carte)
if h.get("role") in ("hyperviseur", "frontiere") and h.get("ip")}) if h.get("role") in ("hyperviseur", "frontiere") and h.get("ip")
and str(h.get("reseau")) not in _u.reseaux_tunnel(carte)})
except Exception: except Exception:
return [] return []
@ -394,7 +397,8 @@ def _adresses_frontiere() -> list[str]:
return [] return []
return sorted({str(h["ip"]) for h in _u.hotes(carte) return sorted({str(h["ip"]) for h in _u.hotes(carte)
if h.get("role") == "frontiere" and h.get("ip") if h.get("role") == "frontiere" and h.get("ip")
and str(h.get("etat", "actif")) != "reserve"}) and str(h.get("etat", "actif")) != "reserve"
and str(h.get("reseau")) not in _u.reseaux_tunnel(carte)})
except Exception: except Exception:
return [] return []

View file

@ -611,6 +611,10 @@ def inventaire() -> dict:
# est une alarme qu'on cesse de lire. # est une alarme qu'on cesse de lire.
if str(_h.get("etat") or "actif") != "actif": if str(_h.get("etat") or "actif") != "actif":
continue continue
# Une patte de TUNNEL n'est pas une patte de zone : le relais et le lien entre les
# sites se surveillent par leur passerelle (RELAIS_GW), pas comme une zone.
if str(_h.get("reseau") or "") in U.reseaux_tunnel(u):
continue
_ip = str(_h.get("ip") or "") _ip = str(_h.get("ip") or "")
_res = str(_h.get("reseau") or "") _res = str(_h.get("reseau") or "")
if not _ip: if not _ip:
@ -981,12 +985,16 @@ def inventaire() -> dict:
# LE CACHE NE S'AMORCE PAS SUR LUI-MEME (2026-10-13). Toute machine du site # LE CACHE NE S'AMORCE PAS SUR LUI-MEME (2026-10-13). Toute machine du site
# recevait `artefacts_amorcage` = le cache — y compris le cache, et des le # recevait `artefacts_amorcage` = le cache — y compris le cache, et des le
# socle, alors qu'il n'est deploye qu'a la couche `services`. Le premier `apt` # socle, alors qu'il n'est deploye qu'a la couche `services`. Le premier `apt`
# d'un site construit depuis zero aurait echoue partout ; celui de Chezlepro # d'un site construit depuis zero aurait echoue partout.
# n'a jamais ete reconstruit, son cache existait deja. VIDE ici, et non absent : #
# le socle RETIRE alors le fichier du gabarit (qui vise `10.0.33.21`, une adresse # UN DRAPEAU A PART, ET NON UNE VALEUR VIDE (corrige le 2026-10-14). Le vide
# d'avant le renumerotage) et la machine va droit chez Debian, ce que la # activait la sortie conditionnelle 80/443 de `serveur_debian` (`seulement_si:
# frontiere permet au cache du site. # artefacts_amorcage non_vide: false`) — et le devis l'ouvre pour TOUT le groupe,
**({"artefacts_amorcage": ""} # pas pour la machine qui remplit la condition : toutes les machines du site
# seraient sorties sur l'Internet. Vu au devis avant toute application. Le cache
# garde sa valeur ; ce drapeau dit au socle de RETIRER le fichier du gabarit (qui
# vise `10.0.33.21`, d'avant le renumerotage). Il sort par sa propre regle.
**({"artefacts_amorcage_direct": True}
if _cache_site and str(srv.get("ip")) == _cache_site else {}), if _cache_site and str(srv.get("ip")) == _cache_site else {}),
# En dernier : ce qu'une machine declare d'elle-meme prime sur ce que le # En dernier : ce qu'une machine declare d'elle-meme prime sur ce que le
# plan declare pour toutes. # plan declare pour toutes.

View file

@ -434,7 +434,12 @@ def _ports_a_plat(hote: dict) -> list[str]:
# `serveur_relais`), et qui porte sa face publique. Le site de TechnoLibre n'a qu'une # `serveur_relais`), et qui porte sa face publique. Le site de TechnoLibre n'a qu'une
# adresse DHCP ; ses adresses fixes sont louees sur un VPS et lui arrivent par un tunnel. # adresse DHCP ; ses adresses fixes sont louees sur un VPS et lui arrivent par un tunnel.
# Ce n'est pas une `passerelle_amont` — celle-ci, nous ne la configurons jamais. # Ce n'est pas une `passerelle_amont` — celle-ci, nous ne la configurons jamais.
ROLES_HOTE = ("switch", "hyperviseur", "frontiere", "passerelle_amont", "relais") #
# `frontiere_pair` (2026-10-13) : la frontiere d'un AUTRE site, au bout du tunnel entre les
# sites. Declaree dans les deux cartes, chacune avec sa propre frontiere en face. Ce n'est
# pas la notre (aucun devis ne la configure d'ici) ; ce n'est pas un routeur qu'on ignore.
ROLES_HOTE = ("switch", "hyperviseur", "frontiere", "passerelle_amont", "relais",
"frontiere_pair")
# UN LIEN QUI N'EST NI UN VLAN NI UN PORT (2026-10-13). Le tunnel vers le relais porte # UN LIEN QUI N'EST NI UN VLAN NI UN PORT (2026-10-13). Le tunnel vers le relais porte
# un sous-reseau de liaison, mais aucune etiquette : il ne traverse aucun commutateur. # un sous-reseau de liaison, mais aucune etiquette : il ne traverse aucun commutateur.
@ -443,6 +448,18 @@ ROLES_HOTE = ("switch", "hyperviseur", "frontiere", "passerelle_amont", "relais"
TUNNELS = ("wireguard",) TUNNELS = ("wireguard",)
def reseaux_tunnel(underlay: dict | None) -> set[str]:
"""Les noms des reseaux qui sont des TUNNELS (`tunnel:`).
ECARTES DES PATTES DE LA FRONTIERE (2026-10-14). Le relais et le lien entre les sites
donnent a la frontiere une adresse de plus — mais rien de ce qu'elle EMET (journaux,
metriques, heure) n'en part, et rien de ce qui y arrive ne vient du plan de gestion. Les
compter a rendu `172.31.0.2` source admise des journaux d'un locataire, en desaccord
avec ses regles : un tunnel ne vit que par les regles que le devis declare pour lui.
"""
return {str(r.get("nom")) for r in reseaux(underlay) if r.get("tunnel") is not None}
def role_hote(hote: dict) -> str: def role_hote(hote: dict) -> str:
"""Nature d'un equipement declare. `switch` par defaut (compatibilite). """Nature d'un equipement declare. `switch` par defaut (compatibilite).