une source vide n est pas « tout le monde »

Les journaux de la fabric, et le defaut de classe que leur mise en place a
revele. 10 hotes dans Loki (7 VM du site + 3 hyperviseurs), site a 67
services tous OK contre 51 avant.

METRIQUES TIREES, JOURNAUX POUSSES. Un scrape part du collecteur et doit
REVENIR : il exige un chemin symetrique, que la route par defaut gelee
interdit. Un push part de la source et n attend qu un accuse.

Trois trous dans les generateurs. Le devis de la frontiere ne connaissait
fabric qu en DESTINATION - un flux entrant tombait dans « rien d autre n
entre » sans rien dire. La regle etait posee sur la patte de la destination
alors que D-61 raisonne en ARRIVEE, et opt7 manquait a la table des zones.

Et le plus grave : fabric etait un mot RECONNU mais NON RESOLU. Source
vide, donc regle sans saddr - tcp dport 3100 accept, ouvert a tous. Un mot
reconnu mais non resolu est pire qu un mot inconnu : celui-ci serait refuse
a la validation, celui-la produit une porte grande ouverte qui a l air d un
flux precis.

LA CLASSE ENTIERE EST REFERMEE. Le defaut n etait pas propre a fabric :
toute paire nommant un ensemble et ne resolvant rien ouvrait le port.
Trouve en lisant les fichiers generes, l API de supervision d un tenant
etait ouverte a tous parce que pair: serveur_backup ne resout rien - cet
ecosysteme depose chez le site. expositions et externe, eux, veulent bien
dire tout le monde : c est une intention. Ailleurs, pas de source, pas de
regle, et le generateur le DIT.

Trois regles se referment, chacune verifiee AVANT d appliquer. Deux etaient
sans effet ; la troisieme portait Grafana, qui ecoute bien sur 3000. Sa
declaration disait pair: edge - juste chez un tenant, faux au site qui n a
pas d edge. Ajout de admin : ce qui etait accidentel devient explicite.

Et la mesure a montre autre chose : Grafana etait DEJA injoignable depuis
le poste, la frontiere ne laissant pas passer le 3000. Le site a une
console deployee et sans chemin d acces. Ce n est pas corrige ici, mais
c est dit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
This commit is contained in:
Daniel Allaire 2026-09-10 19:14:23 -04:00
parent 5abf20102d
commit 0778979b89
8 changed files with 231 additions and 10 deletions

View file

@ -1,5 +1,75 @@
# CHANGELOG — Set-OPS # CHANGELOG — Set-OPS
## 2026-09-10 (15) — Une source vide n'est pas « tout le monde »
Les journaux de la fabric, et le defaut de classe que leur mise en place a revele.
journaux 10 hotes dans Loki (7 VM du site + 3 hyperviseurs)
site 67 services, tous OK (51 avant)
### Metriques TIREES, journaux POUSSES — et ce n'est pas un caprice
Un scrape part du collecteur et doit REVENIR : il exige un chemin symetrique, que la route
par defaut gelee (D-57) interdit. Un push part de la source et n'attend qu'un accuse : la
route SPECIFIQUE vers les zones du site suffit — celle qu'on venait justement de completer.
### Trois trous dans les generateurs, du meme jour
**Le devis de la frontiere ne connaissait `fabric` qu'en DESTINATION.** Un flux entrant
depuis la fabric tombait dans « rien d'autre n'entre » et n'emettait AUCUNE regle, sans
rien dire. Meme forme que le trou des integrations universelles, trouve le matin meme.
**La regle etait sur la mauvaise patte.** D-61 fait raisonner le devis en ARRIVEE ; la
regle etait posee sur l'interface de la DESTINATION. `opt7` — la patte face a la fabric,
libellee PROXMOX — manquait a la table des zones, qui ne recensait que le site. Elle y est,
et l'interface se derive desormais du reseau ou vivent les hyperviseurs.
**Et le plus grave : `fabric` etait un mot RECONNU mais NON RESOLU.** Le generateur de
pare-feu d'hote l'acceptait comme valide, ne trouvait aucune adresse, et une source vide
produit une regle sans `saddr` :
tcp dport 3100 accept # ouvert a tout le monde
*Un mot reconnu mais non resolu est pire qu'un mot inconnu* : celui-ci serait refuse a la
validation, celui-la produit une porte grande ouverte qui a l'air d'un flux precis.
### La classe entiere, refermee
Le defaut n'etait pas propre a `fabric`. **Toute** paire nommant un ensemble et ne
resolvant rien ouvrait le port. Trouve en lisant les fichiers generes :
tcp dport 5665 accept # serveur_icinga, sur le mon-01 d'un TENANT
L'API de supervision ouverte a tous, parce que `pair: serveur_backup` ne resout rien — cet
ecosysteme depose son etat chez le site et n'a pas de depot a lui.
`expositions` et `externe`, EUX, veulent bien dire « tout le monde » : ce sont des services
publies, et leur ouverture est une INTENTION. Ailleurs : **pas de source, pas de regle** —
et le generateur le DIT, parce qu'un flux tu en silence est une porte qu'on croit fermee.
Trois regles se sont refermees. Chacune verifiee AVANT d'appliquer :
| regle | verdict |
|---|---|
| `site-forge-01:3000` | rien n'ecoute — la forge sert 443 |
| `mon-01:5665` (tenant) | deux autres regles couvrent les pousseurs |
| `site-mon-01:3000` | **Grafana ecoute** — a corrige avant de fermer |
### Grafana : `edge` ET `admin`
Chez un tenant, le nginx d'edge termine le TLS : `edge` resout, c'est le seul chemin. Le
SITE n'a PAS d'edge — chaque service s'y sert lui-meme. `edge` n'y resolvait donc rien, et
la console etait ouverte a l'Internet PAR ACCIDENT, sous un commentaire qui parlait d'un
edge inexistant.
Ajouter `admin` rend explicite ce qui etait accidentel. Et la mesure a montre autre chose :
**Grafana etait deja injoignable depuis le poste** — la frontiere ne laisse pas passer le
3000. La regle ouverte ne servait a rien, sauf a rester ouverte si la frontiere s'ouvrait
un jour.
*Le site a donc une console deployee et sans chemin d'acces.* Ce n'est pas corrige ici,
mais c'est dit.
## 2026-09-10 (14) — Le site surveille enfin sa fabric ## 2026-09-10 (14) — Le site surveille enfin sa fabric
La supervision du site voyait ses sept VM et rien d'autre. Mesure de depart, depuis La supervision du site voyait ses sept VM et rien d'autre. Mesure de depart, depuis

View file

@ -21,7 +21,7 @@
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. | | 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), 1 nom(s) surveille(s) sans reference orpheline. | | P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check), 1 nom(s) surveille(s) sans reference orpheline. |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 41 groupes classes, aucun cycle, aucune arete en arriere. | | P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 41 groupes classes, aucun cycle, aucune arete en arriere. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 39 rôles, 105 flux, schéma + matrice OK. | | P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 39 rôles, 106 flux, schéma + matrice OK. |
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). | | 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 | serveur_resolveur_site | | P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | serveur_resolveur_site |
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. | | P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
@ -37,7 +37,7 @@
| P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (22 sections). | | 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 : 13 reseau(x), aucune collision avec la plage tenant. | | P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 13 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. | | 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, 3 tenant(s), 49 groupe(s), 81 regle(s). | | P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 3 tenant(s), 49 groupe(s), 85 regle(s). |
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 14 hote(s) x 6 integration(s) universelle(s) : aucune lacune, aucune recopie (0 exemption(s) derivee(s) du service rendu). | | P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 14 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. | | 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, 34 VM placee(s), aucun nom ni VMID en collision. | | P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 34 VM placee(s), aucun nom ni VMID en collision. |
@ -55,14 +55,14 @@
| P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. | | P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. |
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 55 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). | | P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 55 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 4 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal, OPS-Patient0/product | | P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 4 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal, OPS-Patient0/product |
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 7 machine(s) du plan retrouvees, 153 regle(s) du site. | | P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 7 machine(s) du plan retrouvees, 154 regle(s) du site. |
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 5 integration(s) appliquent leur serveur avant leurs clients. | | P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 5 integration(s) appliquent leur serveur avant leurs clients. |
| P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). | | P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). |
| P46 | Plancher /etc/hosts : un seul role en decide | — | ✅ OK | Un seul maitre du plancher — roles/hosts_statiques/tasks/main.yml : manage_etc_hosts: false ; et le gabarit maitre est pose (roles/hosts_statiques/templates/hos | | P46 | Plancher /etc/hosts : un seul role en decide | — | ✅ OK | Un seul maitre du plancher — roles/hosts_statiques/tasks/main.yml : manage_etc_hosts: false ; et le gabarit maitre est pose (roles/hosts_statiques/templates/hos |
| 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). | | 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 89 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. | | P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 89 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 (123 lignes). | | 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 (124 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 215 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 216 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 | | 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. | | 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 — 15 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere muette (actions : b | | P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 15 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere muette (actions : b |

View file

@ -43,7 +43,7 @@
| `serveur_forgejo` | egress | 25 | tcp | serveur_postfix | starttls | Notifications courriel (relais via le MTA Postfix). | | `serveur_forgejo` | egress | 25 | tcp | serveur_postfix | starttls | Notifications courriel (relais via le MTA Postfix). |
| `serveur_forgejo` | egress | 443 | tcp | edge | tls-requis | Découverte OIDC et jetons auprès de Keycloak (via son FQDN publié à l'edge). | | `serveur_forgejo` | egress | 443 | tcp | edge | tls-requis | Découverte OIDC et jetons auprès de Keycloak (via son FQDN publié à l'edge). |
| `serveur_forgejo` | egress | 5432 | tcp | serveur_postgresql | tls-requis | Base de données Forgejo (verify-full). | | `serveur_forgejo` | egress | 5432 | tcp | serveur_postgresql | tls-requis | Base de données Forgejo (verify-full). |
| `serveur_grafana` | ingress | 3000 | tcp | edge | clair | Interface web servie via l'edge (TLS terminé à l'edge). | | `serveur_grafana` | ingress | 3000 | tcp | edge, admin | clair | Interface web : servie via l'edge la ou il y en a un, joignable depuis le plan d'administration partout — sans quoi un deploiement sans edge n'a plus de console. |
| `serveur_grafana` | egress | 443 | tcp | edge | tls-requis | Authentification OIDC auprès de Keycloak (via son FQDN publié à l'edge). | | `serveur_grafana` | egress | 443 | tcp | edge | tls-requis | Authentification OIDC auprès de Keycloak (via son FQDN publié à l'edge). |
| `serveur_icinga` | ingress | 5665 | tcp | localhost | clair | API Icinga 2 consommée en local par Icinga Web 2 co-localisé. | | `serveur_icinga` | ingress | 5665 | tcp | localhost | clair | API Icinga 2 consommée en local par Icinga Web 2 co-localisé. |
| `serveur_icinga` | ingress | 5665 | tcp | client_backup | tls-requis | Rapport passif de chaque detenteur d'etat sur SON depot distant : le depot du site heberge du chiffre et ne peut pas le juger. | | `serveur_icinga` | ingress | 5665 | tcp | client_backup | tls-requis | Rapport passif de chaque detenteur d'etat sur SON depot distant : le depot du site heberge du chiffre et ne peut pas le juger. |
@ -60,6 +60,7 @@
| `serveur_keycloak` | egress | 5432 | tcp | serveur_postgresql | tls-requis | Persistance Keycloak dans PostgreSQL (verify-full). | | `serveur_keycloak` | egress | 5432 | tcp | serveur_postgresql | tls-requis | Persistance Keycloak dans PostgreSQL (verify-full). |
| `serveur_loki` | ingress | 3100 | tcp | client_journal | tls | Réception des journaux poussés par Alloy (client_journal) en HTTPS (http_tls_config, cert step-ca). | | `serveur_loki` | ingress | 3100 | tcp | client_journal | tls | Réception des journaux poussés par Alloy (client_journal) en HTTPS (http_tls_config, cert step-ca). |
| `serveur_loki` | ingress | 3100 | tcp | localhost | clair | Requêtes de Grafana co-localisé (datasource Loki en localhost). | | `serveur_loki` | ingress | 3100 | tcp | localhost | clair | Requêtes de Grafana co-localisé (datasource Loki en localhost). |
| `serveur_loki` | ingress | 3100 | tcp | fabric | tls | Journaux des hyperviseurs. La machine qui PORTE les VM ecrivait ses journaux nulle part ailleurs que sur son propre disque — invisibles le jour ou c'est elle qui casse. |
| `serveur_nextcloud` | ingress | 80 | tcp | edge | clair | Interface web servie via l'edge (TLS terminé à l'edge). | | `serveur_nextcloud` | ingress | 80 | tcp | edge | clair | Interface web servie via l'edge (TLS terminé à l'edge). |
| `serveur_nextcloud` | egress | 443 | tcp | edge | tls-requis | Découverte OIDC auprès de Keycloak (via son FQDN publié à l'edge). | | `serveur_nextcloud` | egress | 443 | tcp | edge | tls-requis | Découverte OIDC auprès de Keycloak (via son FQDN publié à l'edge). |
| `serveur_nextcloud` | egress | 5432 | tcp | serveur_postgresql | tls-requis | Base de données Nextcloud (verify-full). | | `serveur_nextcloud` | egress | 5432 | tcp | serveur_postgresql | tls-requis | Base de données Nextcloud (verify-full). |
@ -118,6 +119,6 @@
- **n-a** : 6 flux - **n-a** : 6 flux
- **ssh** : 8 flux - **ssh** : 8 flux
- **starttls** : 6 flux - **starttls** : 6 flux
- **tls** : 10 flux - **tls** : 11 flux
- **tls-cible** : 2 flux - **tls-cible** : 2 flux
- **tls-requis** : 35 flux - **tls-requis** : 35 flux

View file

@ -2,12 +2,27 @@
# Flux réseau de Grafana (tableaux de bord). Voir docs/flux-conception.md. # Flux réseau de Grafana (tableaux de bord). Voir docs/flux-conception.md.
# Prometheus et Loki sont co-localisés (datasources en localhost) : aucun flux inter-nœud. # Prometheus et Loki sont co-localisés (datasources en localhost) : aucun flux inter-nœud.
flux: flux:
# `edge` ET `admin` — PARCE QUE TOUS LES DEPLOIEMENTS N'ONT PAS D'EDGE (2026-09-10).
#
# Chez un tenant, le nginx d'edge termine le TLS et parle en clair a Grafana : `edge`
# resout, et c'est le seul chemin. Le SITE, lui, n'a PAS d'edge — chaque service s'y
# sert lui-meme. `edge` n'y resolvait donc rien, et une source vide produisait une regle
# SANS `saddr` : la console ouverte a l'Internet, par accident, avec un commentaire qui
# parlait d'un edge inexistant.
#
# Le jour ou le generateur a cesse d'ouvrir sur une source vide, la regle a disparu et
# l'acces avec elle — mesure : `ss` ne montre que `*:3000`, rien sur 443. Ajouter `admin`
# rend explicite ce qui etait accidentel : la console se joint depuis le plan
# d'ADMINISTRATION, qui est d'ou l'exploitant la regarde. Chez un tenant, la ligne ne
# retire rien — elle ajoute l'operateur a l'edge.
- sens: ingress - sens: ingress
port: 3000 port: 3000
protocole: tcp protocole: tcp
pair: edge pair: [edge, admin]
chiffrement: clair chiffrement: clair
raison: "Interface web servie via l'edge (TLS terminé à l'edge)." raison: >-
Interface web : servie via l'edge la ou il y en a un, joignable depuis le plan
d'administration partout — sans quoi un deploiement sans edge n'a plus de console.
- sens: egress - sens: egress
port: 443 port: 443
protocole: tcp protocole: tcp

View file

@ -13,3 +13,22 @@ flux:
pair: localhost pair: localhost
chiffrement: clair chiffrement: clair
raison: "Requêtes de Grafana co-localisé (datasource Loki en localhost)." raison: "Requêtes de Grafana co-localisé (datasource Loki en localhost)."
# LA FABRIC JOURNALISE AUSSI, ET ELLE, ELLE PEUT POUSSER (2026-09-10).
#
# Les metriques des hyperviseurs sont TIREES : leur route par defaut est gelee (D-57) et
# le porteur de sante y expirait. Les journaux, eux, passent — parce que les routes
# SPECIFIQUES vers les zones du site ont ete completees le meme jour (`10.0.35.0/24` et
# `10.0.36.0/24` manquaient). Alloy pousse donc vers Loki comme n'importe quelle machine.
#
# LA DIFFERENCE AVEC LES METRIQUES N'EST PAS UN CAPRICE : un scrape part du collecteur et
# doit REVENIR, ce qui exige un chemin symetrique ; un push part de la source et n'attend
# qu'un accuse. La route completee suffit au second, pas au premier.
- sens: ingress
port: 3100
protocole: tcp
pair: fabric
chiffrement: tls
raison: >-
Journaux des hyperviseurs. La machine qui PORTE les VM ecrivait ses journaux nulle
part ailleurs que sur son propre disque — invisibles le jour ou c'est elle qui casse.

View file

@ -787,6 +787,38 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
# jamais la zone entiere. # jamais la zone entiere.
_portees = [("SETOPS_SITE", z) for z in _portees = [("SETOPS_SITE", z) for z in
sorted({_if_de(m.get("reseau")) for m in _machines_site})] sorted({_if_de(m.get("reseau")) for m in _machines_site})]
elif _entrant and "fabric" in _pairs_fl:
# LA FABRIC PARLE AUSSI AU SITE (2026-09-10).
#
# `fabric` n'etait connu qu'en DESTINATION — « le runner du site pilote
# les hyperviseurs ». Un flux ENTRANT depuis la fabric tombait donc dans
# la branche « rien d'autre n'entre » et n'emettait AUCUNE regle, sans
# rien dire. Meme forme que le trou des integrations universelles,
# trouve le matin meme : le generateur ne voyait qu'une moitie du monde.
#
# Le cas concret : les hyperviseurs POUSSENT leurs journaux vers Loki.
# Ils ne peuvent pas etre scrutes (route par defaut gelee, D-57) mais ils
# peuvent emettre — un push n'attend qu'un accuse, la ou un scrape exige
# un chemin symetrique.
#
# Une regle par ZONE du site ou vit le role vise, comme pour `flotte` :
# le paquet penetre le pare-feu par la patte de la zone DESTINATAIRE.
# L'INTERFACE EST CELLE PAR OU LE PAQUET ARRIVE (D-61), donc celle
# qui fait face a la FABRIC — pas celle de la destination. Derivee du
# reseau ou vivent les hyperviseurs, jamais ecrite : le jour ou la
# fabric change de patte, la regle suit.
# SEULEMENT LES RESEAUX DONT ON CONNAIT LA PATTE. Un hyperviseur a
# trois adresses (gestion, transport, transit) ; deux de ces reseaux ne
# touchent pas la frontiere et n'ont pas d'interface declaree. Sans ce
# filtre, `_if_de` retombe sur la patte par DEFAUT et pose une regle sur
# une interface que ce trafic n'emprunte jamais — une regle qui a l'air
# d'ouvrir un flux et ne correspond a rien.
_res_fabric = sorted({str(h.get("reseau")) for h in underlay_mod.hotes(_u)
if h.get("role") == "hyperviseur"
and str(h.get("reseau")) in if_zones})
_portees = [("SETOPS_FABRIC", _if_de(r)) for r in _res_fabric]
if not _portees:
continue
elif _entrant and "22" in _ports(_fl) and _admin_site: elif _entrant and "22" in _ports(_fl) and _admin_site:
# Le SSH de gestion arrive par la patte d'ADMINISTRATION de la # 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 # frontiere, pas par celle du site : c'est la que le paquet penetre

View file

@ -223,6 +223,28 @@ def _ip_par_hote(data: dict) -> dict[str, str]:
return table return table
def _adresses_fabric() -> list[str]:
"""Les adresses du materiel de l'hebergeur : hyperviseurs et frontiere.
MEME SOURCE QUE L'ALIAS `SETOPS_FABRIC` du devis de la frontiere — la carte de
l'hebergeur. Ecrire ces adresses ailleurs les ferait mentir le jour ou un noeud
change de patte, ce qui est deja arrive : la frontiere a porte `.1` puis `.17`, et
les hyperviseurs ont continue de renvoyer vers une adresse que plus personne ne
portait, « sans qu'aucun journal ne le dise ».
Rend [] quand aucun underlay n'est monte : degrader, jamais deviner.
"""
try:
import underlay as _u
carte = _u.charger()
if not carte:
return []
return sorted({str(h["ip"]) for h in _u.hotes(carte)
if h.get("role") in ("hyperviseur", "frontiere") and h.get("ip")})
except Exception:
return []
def _resoudre_sources(data: dict, pair, actifs_noms: set[str], ip_par_hote: dict[str, str], def _resoudre_sources(data: dict, pair, actifs_noms: set[str], ip_par_hote: dict[str, str],
site: bool = False) -> list[str]: site: bool = False) -> list[str]:
"""pair -> IP sources concretes (pour une regle ingress). [] = pas de regle inter-noeud.""" """pair -> IP sources concretes (pour une regle ingress). [] = pas de regle inter-noeud."""
@ -236,6 +258,22 @@ def _resoudre_sources(data: dict, pair, actifs_noms: set[str], ip_par_hote: dict
# `nftables_admin_ssh` ; `voisins_site` des supernets des autres tenants # `nftables_admin_ssh` ; `voisins_site` des supernets des autres tenants
# federes ; `runner_site` du plan du SITE. Traites a part, plus bas. # federes ; `runner_site` du plan du SITE. Traites a part, plus bas.
continue continue
if p == "fabric":
# LA FABRIC N'EST PAS UN GROUPE D'HOTES, ELLE EST DANS LA CARTE (2026-09-10).
#
# `fabric` figurait dans `MOTS_PAIR` — donc accepte comme mot valide — sans
# aucune branche pour le resoudre. Il tombait dans le cas general, ne trouvait
# aucun hote, et une source VIDE produit une regle SANS `saddr` :
#
# tcp dport 3100 accept # ouvert a tout le monde
#
# Un mot reconnu mais non resolu est pire qu'un mot inconnu : celui-ci serait
# refuse a la validation, celui-la produit une porte grande ouverte qui a
# l'air d'un flux precis.
#
# Les adresses viennent de l'underlay, comme celles du devis de la frontiere :
# hyperviseurs et frontiere, le materiel qu'aucun agent n'habite.
continue
if p == "flotte": if p == "flotte":
noms |= actifs_noms noms |= actifs_noms
else: else:
@ -245,6 +283,8 @@ def _resoudre_sources(data: dict, pair, actifs_noms: set[str], ip_par_hote: dict
demandes = pair if isinstance(pair, list) else [pair] demandes = pair if isinstance(pair, list) else [pair]
if "admin" in demandes: if "admin" in demandes:
srcs |= set(_sources_admin_ssh_site() if site else _sources_admin_ssh()) srcs |= set(_sources_admin_ssh_site() if site else _sources_admin_ssh())
if "fabric" in demandes:
srcs |= set(_adresses_fabric())
if "voisins_site" in demandes: if "voisins_site" in demandes:
# DU COTE DU SITE, « VOISIN » N'EXCLUT PERSONNE (mesure du 2026-09-02). # DU COTE DU SITE, « VOISIN » N'EXCLUT PERSONNE (mesure du 2026-09-02).
# #
@ -572,7 +612,36 @@ def generer_nftables(flux_par_role: dict[str, list[dict]], site: bool = False) -
ports = reels ports = reels
srcs = _resoudre_sources(data, fl.get("pair"), actifs_noms, ip_par_hote, site=site) srcs = _resoudre_sources(data, fl.get("pair"), actifs_noms, ip_par_hote, site=site)
saddr = "" if not srcs else "ip saddr { " + ", ".join(srcs) + " } " saddr = "" if not srcs else "ip saddr { " + ", ".join(srcs) + " } "
local = "localhost" in (fl.get("pair") if isinstance(fl.get("pair"), list) else [fl.get("pair")]) _pairs_fl = fl.get("pair") if isinstance(fl.get("pair"), list) else [fl.get("pair")]
local = "localhost" in _pairs_fl
# UNE SOURCE VIDE N'EST PAS « TOUT LE MONDE » (mesure du 2026-09-10).
#
# Une paire qui NOMME un ensemble — un role, `flotte`, `fabric`, `edge` —
# et qui ne resout rien decrivait jusqu'ici une regle SANS `saddr`, donc
# ouverte a l'Internet. Le silence de la resolution devenait une porte.
#
# CE QUE CA A COUTE, trouve en lisant les fichiers generes : sur le
# `mon-01` d'un tenant,
#
# tcp dport 5665 accept # serveur_icinga
#
# L'API de supervision ouverte a tous, parce que `pair: serveur_backup` ne
# resout rien — cet ecosysteme depose son etat chez le site et n'a pas de
# depot a lui. La regle avait l'air d'un flux precis.
#
# `expositions` et `externe`, EUX, veulent bien dire « tout le monde » :
# ce sont des services publies, et leur ouverture est une intention, pas un
# accident. On ne les touche pas.
#
# Ailleurs : pas de source, pas de regle — et on le DIT, parce qu'un flux
# tu en silence est une porte qu'on croit ouverte (ou fermee).
_public = {"expositions", "externe"} & set(_pairs_fl)
if not srcs and not local and not _public:
print(f"note : {g} declare un flux entrant depuis "
f"`{'/'.join(str(x) for x in _pairs_fl)}` qui ne resout aucune "
f"source — aucune regle emise pour {hote} (une source vide "
f"ouvrirait le port a tous).", file=sys.stderr)
continue
for port in ports: for port in ports:
if local and not srcs: if local and not srcs:
continue # lo deja accepte continue # lo deja accepte

View file

@ -539,6 +539,12 @@ def inventaire() -> dict:
_ip_icinga = str(_s.get("ip") or "") _ip_icinga = str(_s.get("ip") or "")
break break
_ip_loki = ""
for _n_app, _a_app in applications.items():
if str(_a_app.get("groupe")) == "serveur_loki":
_ip_loki = str((serveurs.get(_a_app.get("hote")) or {}).get("ip") or "")
break
_hyperviseurs = {} _hyperviseurs = {}
for _h in U.hotes(u): for _h in U.hotes(u):
if str(_h.get("role")) != "hyperviseur": if str(_h.get("role")) != "hyperviseur":
@ -571,6 +577,10 @@ def inventaire() -> dict:
# de noeud du cluster. Mesure : `curl: (6) Could not resolve host` sur les trois. # de noeud du cluster. Mesure : `curl: (6) Could not resolve host` sur les trois.
**({"client_sante_icinga_url": f"https://{_ip_icinga}:5665"} **({"client_sante_icinga_url": f"https://{_ip_icinga}:5665"}
if _ip_icinga else {}), if _ip_icinga else {}),
# LES JOURNAUX, EUX, PARTENT D'ICI — et par une ADRESSE, meme raison que
# ci-dessus : pas de plancher `/etc/hosts` sur un hyperviseur.
**({"client_journal_loki_url":
f"http://{_ip_loki}:3100/loki/api/v1/push"} if _ip_loki else {}),
**communes, **communes,
} }
groupes.setdefault("hyperviseurs", []).append(_nom) groupes.setdefault("hyperviseurs", []).append(_nom)
@ -596,6 +606,11 @@ def inventaire() -> dict:
# Prometheus le remplace par sa propre cible `down`, que la sonde `collecte` # Prometheus le remplace par sa propre cible `down`, que la sonde `collecte`
# rapporte deja. # rapporte deja.
groupes.setdefault("client_metrique", []).append(_nom) groupes.setdefault("client_metrique", []).append(_nom)
# LES JOURNAUX SE POUSSENT, EUX. La route specifique vers les zones du site — celle
# qui manquait pour 35 et 36 — suffit a un push : il part de la source et n'attend
# qu'un accuse. Un scrape, lui, exige que la reponse revienne par le meme chemin,
# ce que la route par defaut gelee empeche. D'ou metriques TIREES, journaux POUSSES.
groupes.setdefault("client_journal", []).append(_nom)
out: dict = {g: {"hosts": sorted(set(h))} for g, h in groupes.items()} out: dict = {g: {"hosts": sorted(set(h))} for g, h in groupes.items()}
out["site"] = {"hosts": sorted(hostvars)} out["site"] = {"hosts": sorted(hostvars)}