diff --git a/CHANGELOG.md b/CHANGELOG.md index 38fc240..e8e82a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,59 @@ # CHANGELOG — Set-OPS +## 2026-08-25 — La frontière voit le site : `fabric`, alias et règles + +### Un mot manquait au vocabulaire des flux + +Le runner de SITE déclarait son API Proxmox en `pair: externe`. Or « externe » se rend +par `!SETOPS_INTERNES` — *tout sauf les espaces privés* — et les hyperviseurs **sont** en +RFC 1918. La règle sortante les aurait exclus **tout en ayant l'air d'ouvrir le flux**. +Un flux qui a l'air ouvert et qui ne l'est pas est pire qu'un flux fermé : il ne se +cherche pas. + +D'où `fabric` : le matériel de l'hébergeur — hyperviseurs, frontière, commutateurs. Ce +n'est ni `flotte` (les machines d'un écosystème) ni `voisins_site` (les tenants d'à côté), +c'est le **socle** sur lequel les uns et les autres reposent. Le seul à s'y adresser est +le runner du site, et c'est tout son objet. + +### Deux flux qui n'existaient pas + +**Le cache racine ne pouvait rien aller chercher.** `serveur_cache_site` ne déclarait +qu'un `ingress 3142` : la chaîne entière se terminait sur un cache vide. Ça ne se serait +vu qu'au premier `apt update` d'un écosystème neuf — c'est-à-dire au pire moment. + +**Le runner de site n'avait que l'API.** Déplacer un disque, poser un pont, lire une +configuration réseau passe par le shell du nœud ; ouvrir les flux du tenant qu'on +matérialise passe par l'API de la frontière. Trois pouvoirs distincts, déclarés à part. + +### Ce que le devis ignorait + +Les machines du site ne sont dans **aucun** plan de tenant : la boucle du devis ne +pouvait pas les voir, et il rendait zéro règle pour elles **sans rien signaler**. Un +devis muet sur une machine qui existe n'est pas un devis. + +Le devis émet désormais `SETOPS_SITE` (le réseau), `SETOPS_FABRIC` (ce que le runner +pilote), `SETOPS_ADMIN_SITE` (seule source du SSH) et un alias par rôle du site. Leur +trafic arrive par `opt2`, **pas** par le lien de transit — une règle posée sur la mauvaise +interface ne correspond jamais, et c'est la panne la plus silencieuse de cette couche. Le +SSH de gestion, lui, arrive par `lan`. Plus le NAT sortant du site : son réseau est +directement attaché, mais le mode automatique d'OPNsense a été quitté en déclarant les +tenants à la main — compter sur un mode qu'on a soi-même quitté se paierait au premier +téléchargement. + +**Le socle s'applique aussi au site.** `site_inventaire.py` range ses machines dans +`serveur_debian` : même durcissement SSH, mêmes horloges, mêmes règles que n'importe +quelle machine de la flotte. L'oublier aurait donné à la machine la plus puissante du +site la protection la plus faible. + +### Patient 0 rendu à sa condition de tenant + +Son plan déclarait encore `serveur_ops_site` et `serveur_cache_site` — il tenait ces +rôles parce que le SITE n'existait pas comme objet à lui. Il existe. Un tenant qui +garderait ces rôles s'arrogerait un pouvoir sur ses voisins, et patient 0 est un tenant +comme les autres : c'est même tout ce qu'il prouve. + +Bilan au devis : **26 règles à créer, 5 à retirer** (dont les trois périmées de patient 0). + ## 2026-08-24 — Un SITE n'est pas un plan : ses machines vivent dans l'underlay J'avais d'abord fait entrer le site dans le générateur des tenants, avec une branche diff --git a/docs/audit/preuve-2026-08-25.md b/docs/audit/preuve-2026-08-25.md index 176ee05..2c2d93c 100644 --- a/docs/audit/preuve-2026-08-25.md +++ b/docs/audit/preuve-2026-08-25.md @@ -21,7 +21,7 @@ | 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). | | P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 36 groupes classes, aucun cycle, aucune arete en arriere. | -| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 34 rôles, 87 flux, schéma + matrice OK. | +| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 34 rôles, 91 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 | playbook: playbooks/proxmox/cloner_vm_debian.yml | | P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. | @@ -36,8 +36,8 @@ | P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 3 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 : 8 reseau(x), aucune collision avec la plage tenant. | -| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | CONFORME : frontiere nord/sud, 68 regles, 15 routes, admin=10.0.0.0/24,10.17.0.0/24,10.29.19.41/32,192.168.254.2/32,192.168.255.2/32. | -| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 3 tenant(s), 51 groupe(s), 80 regle(s). | +| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | CONFORME : frontiere nord/sud, 78 regles, 15 routes, admin=10.0.0.0/24,10.17.0.0/24,10.29.19.41/32,192.168.254.2/32,192.168.255.2/32. | +| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 3 tenant(s), 50 groupe(s), 79 regle(s). | | P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 15 hote(s) x 5 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. | @@ -46,7 +46,7 @@ | P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 49 scripts expliques et atteignables, 102 cibles make documentees, 60 roles avec README. | | P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 37 exigence(s) de role, toutes satisfaites (125 cle(s) declaree(s) par l'instance). | | P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 33 revendication(s) de port, aucune collision entre roles co-localises (37 groupes). | -| P34 | Chaque document declare son lecteur | — | ✅ OK | 42 document(s) declarent leur lecteur (22 genere(s) exempte(s)). | +| P34 | Chaque document declare son lecteur | — | ✅ OK | 42 document(s) declarent leur lecteur (23 genere(s) exempte(s)). | | P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 5 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) detiennent de l'etat, tous porteurs de `client_backup` (9 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. | diff --git a/roles/serveur_cache_site/meta/flux.yml b/roles/serveur_cache_site/meta/flux.yml index 222d6e8..f79cfe7 100644 --- a/roles/serveur_cache_site/meta/flux.yml +++ b/roles/serveur_cache_site/meta/flux.yml @@ -20,3 +20,27 @@ flux: Servir les caches des écosystèmes voisins. Debian n'est ainsi téléchargé qu'une fois pour tout le site, et le cache ne voit que des requêtes AGRÉGÉES — jamais quelle machine installe quoi. + + # LA RACINE DOIT POUVOIR REMPLIR. Ce cache n'a pas d'amont interne — c'est LUI l'amont + # de tous les autres. Sans flux sortant, la chaine entiere se termine sur un cache vide : + # + # VM du tenant -> cache du tenant -> cache du SITE -> (rien) + # + # Il manquait, et ca ne se serait vu qu'au premier `apt update` d'un ecosysteme neuf — + # c'est-a-dire au pire moment. + - sens: egress + port: 80 + protocole: tcp + pair: externe + chiffrement: clair + raison: >- + Remplir le cache depuis les dépôts Debian amont. En clair parce que les dépôts apt + sont signés : l'intégrité vient de la signature, pas du transport. + - sens: egress + port: 443 + protocole: tcp + pair: externe + chiffrement: tls-requis + raison: >- + Les dépôts tiers qui n'existent qu'en HTTPS (smallstep, Grafana, Icinga). Le cache + les relaie pour que la flotte n'ait pas à sortir elle-même. diff --git a/roles/serveur_ops_site/meta/flux.yml b/roles/serveur_ops_site/meta/flux.yml index cf9627d..206938b 100644 --- a/roles/serveur_ops_site/meta/flux.yml +++ b/roles/serveur_ops_site/meta/flux.yml @@ -1,15 +1,40 @@ --- # Flux réseau du runner de SITE. Voir docs/flux-conception.md. # -# Un seul flux, et c'est tout son sens : l'API de l'hyperviseur. Ce rôle n'entre JAMAIS +# Les flux du POUVOIR DE MATERIALISER : API de l'hyperviseur, shell des noeuds, API de +# la frontiere. Chacun est un pouvoir distinct, donc declare a part. Ce rôle n'entre JAMAIS # chez un tenant — c'est `serveur_ops` qui configure les machines, une fois qu'elles # existent. flux: - sens: egress port: 8006 protocole: tcp - pair: externe + pair: fabric chiffrement: tls-requis raison: >- API de l'hyperviseur : créer, cloner et détruire les VM de la fabric. Le seul flux par lequel un écosystème peut en matérialiser un autre. + + # SSH VERS LES HYPERVISEURS. L'API Proxmox ne fait pas tout : deplacer un disque, + # lire une configuration reseau, poser un pont — cela passe par le shell du nœud. + # Le declarer separement de l'API rend visible que ce sont DEUX pouvoirs distincts. + - sens: egress + port: 22 + protocole: tcp + pair: fabric + chiffrement: ssh + raison: >- + Shell des hyperviseurs : ce que l'API ne couvre pas — configuration reseau, ponts, + deplacement de disques. Un pouvoir distinct de l'API, donc declare a part. + + # L'API DE LA FRONTIERE. Le runner du site pose les regles et les alias qui laissent + # passer les flux d'un tenant : sans ce flux, il prepare un terrain injoignable. + - sens: egress + port: 443 + protocole: tcp + pair: fabric + chiffrement: tls-requis + raison: >- + API de la frontiere OPNsense : poser les alias et les regles qui ouvrent les flux + du tenant qu'on materialise. Preparer le terrain sans cela laisserait un terrain + injoignable. diff --git a/scripts/devis_opnsense.py b/scripts/devis_opnsense.py index de8af83..adaefb4 100644 --- a/scripts/devis_opnsense.py +++ b/scripts/devis_opnsense.py @@ -64,6 +64,10 @@ NOEUD_SORTIE = "" IF_TRANSIT = "" IF_WAN = "" IF_GESTION = "" +# L'interface ou arrive le trafic des MACHINES DU SITE (vlan 30 / vmbr3). Une regle +# posee sur la mauvaise interface ne correspond jamais : le trafic du site ne penetre pas +# le pare-feu par le lien de transit des tenants, mais par sa propre patte. +IF_SITE = "" def depot_hebergeur() -> Path | None: @@ -314,6 +318,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: if_wan = _ou_marqueur(_intr, "opnsense_if_wan", IF_WAN) if_transit = _ou_marqueur(_intr, "opnsense_if_transit", IF_TRANSIT) if_gestion = _ou_marqueur(_intr, "opnsense_if_gestion", IF_GESTION) + if_site = _ou_marqueur(_intr, "opnsense_if_site", IF_SITE) nom_actif, nomenclature_active = _instance_active() # L'alias d'un tenant vaut ses SOUS-RESEAUX ATTRIBUES, pas son supernet. @@ -543,6 +548,96 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: }) intrants = intrants_frontiere() + # --- LES MACHINES DU SITE ------------------------------------------------- + # + # Elles ne sont dans AUCUN plan de tenant : la boucle ci-dessus ne pouvait donc pas + # les voir, et le devis rendait zero regle pour elles sans rien signaler. Un devis + # muet sur une machine qui existe n'est pas un devis. + # + # Leur trafic arrive par la patte du site (`if_site`), pas par le lien de transit. + # Une regle posee sur la mauvaise interface ne correspond jamais — c'est la panne la + # plus silencieuse de cette couche. + _u = underlay_mod.charger() + _machines_site = underlay_mod.machines(_u) + if _machines_site: + _res_site = {r.get("nom"): r for r in underlay_mod.reseaux(_u)} + _sous_site = sorted({str(_res_site[m["reseau"]]["sous_reseau"]) + for m in _machines_site if m.get("reseau") in _res_site}) + alias["SETOPS_SITE"] = { + "type": "network", + "contenu": _sous_site, + "description": "Reseau des machines de l'hebergeur (runner de SITE, cache racine)", + } + # LA FABRIC — ce que le runner du site doit joindre pour MATERIALISER : l'API des + # hyperviseurs, leur shell, l'API de la frontiere. C'est du RFC 1918 : le rendre + # 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. + _fabric = sorted({str(h["ip"]) for h in underlay_mod.hotes(_u) + if h.get("role") in ("hyperviseur", "frontiere") and h.get("ip")}) + alias["SETOPS_FABRIC"] = { + "type": "host", + "contenu": _fabric, + "description": "Materiel de l'hebergeur : hyperviseurs et frontiere — " + "ce que le runner du SITE pilote pour materialiser", + } + # LE PLAN D'ADMINISTRATION, source du SSH vers le site. Sans lui, la regle + # n'aurait pas de source : l'omettre ferme l'acces, l'ouvrir a `any` exposerait + # le SSH du runner — la machine la plus puissante du site — a l'Internet. + _admin_site = [str(r["sous_reseau"]) for r in underlay_mod.reseaux(_u) + if r.get("nom") == "management" and r.get("sous_reseau")] + if _admin_site: + alias["SETOPS_ADMIN_SITE"] = { + "type": "network", + "contenu": _admin_site, + "description": "Plan d'administration — seule source du SSH vers le site", + } + # Le socle vaut aussi pour le site : ses machines sont des Debian de la flotte, + # et c'est lui qui porte leur SSH. `site_inventaire.py` les y range. + _roles_site = sorted({s for m in _machines_site for s in (m.get("services") or [])} + | {"serveur_debian"}) + for _role in _roles_site: + _ips = sorted({str(m["ip"]) for m in _machines_site + if _role == "serveur_debian" + or _role in (m.get("services") or [])}) + _a = nom_alias(_role, "SITE") + alias[_a] = { + "type": "host", + "contenu": _ips, + "description": f"Machines du site portant {_role}", + } + for _fl in flux_par_role.get(_role, []): + _entrant = _fl.get("sens") == "ingress" + _pairs_fl = _pairs(_fl) + if _entrant and "voisins_site" in _pairs_fl: + # Un tenant qui s'adresse au site : le paquet penetre le pare-feu par + # le lien de TRANSIT, pas par la patte du site. Une regle par voisin, + # parce qu'une regle porte une source. + _portees = [(f"SETOPS_TENANT_{p2}{n2['index']}", if_transit) + for _n2, p2, n2 in tenants] + elif _entrant and "22" in _ports(_fl) and _admin_site: + # Le SSH de gestion arrive par la patte d'ADMINISTRATION de la + # frontiere, pas par celle du site : c'est la que le paquet penetre + # le pare-feu. Une regle posee ailleurs ne correspondrait jamais. + _portees = [("SETOPS_ADMIN_SITE", if_gestion)] + elif _entrant: + continue # rien d'autre n'entre chez le site depuis l'exterieur + else: + _portees = [(_a, if_site)] + for _src, _itf in _portees: + regles.append({ + "sens": "in" if _entrant else "out", + "interface": _itf, + "protocole": _fl.get("protocole", "tcp"), + "source": _src if _entrant else _a, + "destination": _a if _entrant else ( + "SETOPS_FABRIC" if "fabric" in _pairs_fl else "!SETOPS_INTERNES"), + "ports": _ports(_fl), + "chiffrement": _fl.get("chiffrement"), + "role": _role, + "tenant": "SITE", + "raison": _fl.get("raison", ""), + }) + transit = transit_underlay() saut = prochain_saut(transit) # UNE ROUTE PAR SOUS-RESEAU REELLEMENT ATTRIBUE, et non une par supernet. @@ -589,6 +684,19 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: } for nom, pfx, n in tenants ] + # LE SITE AUSSI SORT. Son reseau est directement attache a la frontiere (OPT2), donc + # le mode automatique d'OPNsense le couvrirait — mais nous ne sommes plus en mode + # automatique : declarer les tenants a la main l'a bascule. Compter sur un mode qu'on + # a soi-meme quitte, c'est le genre de supposition qui se paie au premier `apt update` + # du cache racine, quand plus rien ne se telecharge et que tout a l'air correct. + if _machines_site and "SETOPS_SITE" in alias: + nat.append({ + "interface": if_wan, + "source": "SETOPS_SITE", + "destination": "any", + "cible": "wanip", + "tenant": "SITE", + }) # Adresse du noeud de sortie primaire sur le lien de frontiere, calculee une fois : # elle sert au prochain saut des routes ET au commentaire qui l'explique. diff --git a/scripts/resoudre_flux.py b/scripts/resoudre_flux.py index 29bc059..2305d1d 100644 --- a/scripts/resoudre_flux.py +++ b/scripts/resoudre_flux.py @@ -53,8 +53,19 @@ CHIFFREMENT = {"tls-requis", "tls", "starttls", "ssh", "tls-cible", "clair", "n- # # Il rend des CIDR (les supernets des voisins), pas des adresses d'hotes : il est donc # traite a part, comme `admin`. +# `fabric` — LE MATERIEL DE L'HEBERGEUR : hyperviseurs, frontiere, commutateurs. +# +# Il a fallu ce mot le 2026-08-25. Le runner de SITE declarait son API Proxmox en +# `externe`, et « externe » se rend par « tout sauf les espaces prives ». Or les +# hyperviseurs SONT en RFC 1918 : la regle sortante les aurait exclus, tout en ayant +# l'air d'ouvrir le flux. Un flux qui a l'air ouvert et qui ne l'est pas est pire qu'un +# flux ferme — il ne se cherche pas. +# +# Ce n'est pas non plus `flotte` (les machines d'un ecosysteme) ni `voisins_site` (les +# tenants d'a cote) : c'est le SOCLE sur lequel les uns et les autres reposent. Le seul +# a s'y adresser est le runner du site, et c'est tout son objet — materialiser. MOTS_PAIR = {"edge", "flotte", "externe", "localhost", "expositions", "derive", "admin", - "voisins_site"} + "voisins_site", "fabric"} GROUPE_EDGE = "serveur_nginx" diff --git a/scripts/site_inventaire.py b/scripts/site_inventaire.py index 6188beb..8e56bb5 100755 --- a/scripts/site_inventaire.py +++ b/scripts/site_inventaire.py @@ -34,6 +34,12 @@ import underlay as U # noqa: E402 # L'inventaire des tenants passe par ce compte ; le site n'a aucune raison d'en differer. UTILISATEUR_DEFAUT = "ansible" +# LE SOCLE S'APPLIQUE AUSSI AU SITE. Une machine de l'hebergeur n'est pas d'une autre +# espece : elle veut le meme durcissement SSH, les memes horloges, le meme pare-feu que +# n'importe quelle machine de la flotte. L'oublier lui aurait donne un SSH par defaut et +# aucune regle de frontiere — la machine la plus puissante du site, la moins protegee. +GROUPE_SOCLE = "serveur_debian" + def inventaire() -> dict: u = U.charger() @@ -68,6 +74,7 @@ def inventaire() -> dict: # par exemple pour ne PAS chercher un plan la ou il n'y en a pas. "setops_site": True, } + groupes.setdefault(GROUPE_SOCLE, []).append(nom) for service in m.get("services") or []: groupes.setdefault(service, []).append(nom)