supervision du site, et la panne qui dormait dans un mot
Some checks are pending
verifier / verifier (push) Waiting to run

Le site a son temoin : site-mon-01 (VLAN 36) porte PostgreSQL, Icinga et un
relais. Le depot lui rapporte, et son premier verdict fut un vrai defaut —
site-mon-01 n avait jamais depose son propre etat. Les trois sont au vert.

serveur_backup_verification_locale repasse a true sur le depot : le drapeau ne
dit plus  on renonce  mais  verifie ce que tu peux ouvrir . La liste des noeuds
attendus derive de l inventaire ou tourne le role, donc du site seul.

serveur_postfix gagne un mode relais : un site n heberge aucune boite, il
expedie. Directement par sa frontiere — emprunter le MTA d un locataire ferait
dependre l hebergeur d un ecosysteme qu il peut outvivre.

LA PANNE DE FOND : le modele de routes OPNsense lit enabled ; on lui envoyait
disabled=0, un champ ignore. enabled restait a son defaut, ETEINT. Chaque route
creee par Set-OPS depuis l origine l etait desactivee — invisible, parce qu une
route eteinte EXISTE dans le modele et compte  posee . Le rechargement lance
pour activer la nouvelle patte a fait reprendre au noyau sa table depuis le
modele : 14 routes sur 15 disparues, 11 machines de Chezlepro injoignables le
lendemain de sa reconstruction, et le devis toujours vert.

Trois corrections empilees : la cause (enabled), la cecite (le plan lit la table
du NOYAU et signale absent ou eteint), l inaction (la reconfiguration ne se
declenchait que sur un changement du modele).

Aussi : un role du site peut enfin en appeler un autre a travers la frontiere —
le devis traitait tout pair nomme comme  l exterieur . Et nftables_admin_ssh se
DERIVE de la carte : recopie a la main, il retardait d une zone, et m a
verrouille dehors de la machine que je venais de creer.

Reste ouvert : le destinataire des alertes est encore root@localhost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
This commit is contained in:
Daniel Allaire 2026-09-02 17:34:01 -04:00
parent cf9abe74b6
commit 4e12c3a802
10 changed files with 312 additions and 15 deletions

View file

@ -1,5 +1,89 @@
# CHANGELOG — Set-OPS
## 2026-09-02 (4) — Le site a un temoin ; et une panne dormait depuis des semaines dans un mot
**56 preuves.** L'hebergeur a desormais sa propre supervision : `site-mon-01` (VLAN 36,
zone `site-supervision`) porte PostgreSQL, Icinga et un relais de courriel. Le depot de
sauvegarde lui rapporte, et **son premier verdict a ete un vrai defaut** — `site-mon-01`
n'avait jamais depose son propre etat. Corrige, les trois sont au vert :
sauvegarde: site-forge-01 -> OK : instantane il y a 5 h, 835 fichier(s)
sauvegarde: site-pki-01 -> OK : instantane il y a 5 h, 13 fichier(s)
sauvegarde: site-mon-01 -> OK : instantane il y a 0 h, 1 fichier(s)
`serveur_backup_verification_locale` repasse a `true` sur le depot du site. Ce drapeau ne
dit plus « on renonce » mais « verifie ce que tu peux ouvrir » :
`serveur_backup_noeuds_attendus` derive de l'inventaire OU TOURNE LE ROLE, donc des seules
machines du site, dont il detient le mot de passe restic. Il reste aveugle aux locataires,
et c'est le but.
### `serveur_postfix` gagne un mode `relais`
Le role est le serveur de courriel d'un ecosysteme : il exige un bind LDAP et un magasin
Dovecot. Un site n'heberge aucune boite — il lui faut EXPEDIER, et rien d'autre. Plutot
qu'un role jumeau (qui aurait duplique le certificat, sa resynchronisation, la resolution
dans le chroot et la validation), un mode : `complet` par defaut, `relais` pour le site.
Le site expedie DIRECTEMENT par sa frontiere. Emprunter le MTA d'un locataire ferait
dependre l'hebergeur d'un ecosysteme qu'il peut outvivre — une emancipation emporterait
son alerte avec elle.
### LA PANNE QUI DORMAIT DANS UN MOT : `disabled` au lieu de `enabled`
Le modele de routes d'OPNsense lit `enabled`. `appliquer_opnsense` lui envoyait
`disabled: "0"` — un champ qu'il IGNORE. `enabled` restait donc a son defaut, c'est-a-dire
ETEINT. **Chaque route creee par Set-OPS depuis l'origine l'etait desactivee.**
Pourquoi personne ne l'avait vu : une route eteinte EXISTE dans le modele. `frontiere-plan`
la comptait « posee » et annoncait « 15 routes inchange ». Elle n'etait simplement pas
installee dans la table de routage — et tant que le boitier n'avait pas ete recharge depuis
sa creation, le noyau gardait les routes ajoutees a la main lors de la mise en place. Tout
fonctionnait.
Le rechargement complet lance pour activer la patte du VLAN 36 a fait reprendre au noyau sa
table depuis le modele : **quatorze routes sur quinze ont disparu, et onze machines de
Chezlepro sont devenues injoignables** — le lendemain de sa reconstruction. Le devis, lui,
restait vert.
Trois corrections, parce qu'il y avait trois defauts empiles :
1. **La cause** : `enabled: "1"`.
2. **La cecite** : le plan lit desormais la TABLE DU NOYAU (`/api/diagnostics/interface/getRoutes`,
en GET, reponse en liste nue — sondee en POST puis en `{"rows": ...}`, elle rendait
« impossible de lire » sur une frontiere qui repondait tres bien) et signale toute route
declaree mais absente, ainsi que toute route presente mais eteinte.
3. **L'inaction** : la reconfiguration n'etait declenchee que `if routes_creer or
routes_retirer`. Aucun changement, donc aucune reparation : `appliquer` rendait « OK »
sur un routage casse. Elle se declenche maintenant aussi quand le noyau a perdu quelque
chose.
Et « rien a faire » ne s'affiche plus quand le noyau, lui, a du travail : le message disait
vrai du modele et faux du service.
### Un role du site peut enfin en appeler un autre
`serveur_icinga` declare `ingress 5665` depuis `serveur_backup`, et `serveur_backup`
l'`egress` en face. Les deux etaient justes, et la regle de frontiere n'existait pas : le
devis traitait tout `pair` nomme comme « l'exterieur » et sautait le flux. C'etait vrai
tant qu'un pair designait quelque chose de lointain — mais un pair peut nommer un role DU
SITE, pose dans une AUTRE ZONE, et depuis le decoupage en zones deux machines du site ne se
parlent qu'a travers la frontiere. Le depot pouvait joindre un Icinga du monde entier sauf
celui de son propre site.
### Le plan d'administration se DERIVE, il ne se recopie pas
L'intrant `nftables_admin_ssh` du site listait a la main les pattes de la frontiere, sous ce
commentaire : « oublier une seule de ces adresses rend une zone entiere inadministrable ».
Une zone a ete ajoutee le meme jour, la liste ne l'a pas suivie, et `site-mon-01` est
devenue injoignable des l'application de son pare-feu — rouverte par l'agent invite de
l'hyperviseur. La liste LIT desormais la carte ; l'intrant ne sert plus qu'aux voies
qu'elle ne peut pas connaitre.
### Ce qui reste ouvert
Le destinataire des alertes est encore `root@localhost` — le defaut d'Icinga. La
supervision voit et sait ; elle ne sait pas encore a QUI parler.
## 2026-09-02 (3) — Le site sauvegarde son propre etat, et la preuve le lui demande
**56 preuves.** L'hebergeur protegeait l'etat de tous ses locataires et pas le sien. Deux

View file

@ -35,7 +35,7 @@
| P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) : adressage 100% derive du seed index. |
| 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 : 12 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. |
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 3 tenant(s), 51 groupe(s), 81 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). |
@ -48,21 +48,21 @@
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 33 revendication(s) de port, aucune collision entre roles co-localises (38 groupes). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 42 document(s) declarent leur lecteur (30 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 | — | ❌ ECHEC | 1 hote(s) detiennent de l'etat sans sauvegarde : site : site-pki-01 detient ['serveur_step_ca'] — ajouter `client_backup` a leurs `integrations` dans `plan/serv |
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ❌ ECHEC | 1 hote(s) detiennent de l'etat sans sauvegarde : site : site-mon-01 detient ['serveur_postgresql'] — ajouter `client_backup` a leurs `integrations` dans `plan/s |
| P37 | Le placement du tenant existe chez son hebergeur | — | ✅ OK | placement confronte a l'hebergeur monte (SITE-Chezlepro) : noeud, stockage, pont — tous offerts. |
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 39 role(s) serveur/client tous nommes, 40 groupe(s) cite(s) en table existent tous. |
| P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 27 page(s) de wiki toutes atteignables. |
| 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 : 50 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 |
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 6 machine(s) du plan retrouvees, 80 regle(s) du site. |
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 7 machine(s) du plan retrouvees, 101 regle(s) du site. |
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 4 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). |
| 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). |
| P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 84 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 (116 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 145 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 166 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 — 15 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere muette (actions : b |

View file

@ -29,7 +29,19 @@ dependencies:
# La vérification n'est pas supprimée, elle est DÉPLACÉE chez celui qui peut
# l'exercer : le locataire, qui détient la clé et sait ce qu'il a envoyé.
- role: serveur_backup
serveur_backup_verification_locale: false
# `true` DEPUIS QUE LE SITE A UN TEMOIN (2026-09-02).
#
# Ce drapeau a longtemps valu `false`, et pour une raison juste : le depot ne peut
# pas ouvrir les depots des LOCATAIRES — restic chiffre chez eux, avec un mot de
# passe qui ne quitte pas leur voute. Mais il ne verifiait alors RIEN, y compris ce
# qu'il pouvait parfaitement ouvrir.
#
# `serveur_backup_noeuds_attendus` derive de l'INVENTAIRE OU TOURNE LE ROLE. Ici
# c'est celui du site : la liste ne contient donc que des machines du site, dont ce
# depot detient le mot de passe restic — la meme voute. Il verifie exactement ce
# qu'il peut ouvrir, et reste aveugle au reste. C'etait deja vrai avant ; ce qui
# manquait, c'etait quelqu'un a qui le dire.
serveur_backup_verification_locale: true
# LE SITE N'HABITE PAS LA RACINE DE SON PROPRE DÉPÔT.
#
# `serveur_backup` donne au compte `restic` la racine des dépôts pour home. C'est

View file

@ -3,6 +3,25 @@
# boîtes, alias), remise LMTP réseau vers le nœud mail-store (Dovecot). TLS via step_ca.
# NE STOCKE AUCUNE BOÎTE. Cf. docs/courriel-conception.md + docs/identite-sso.md.
# DEUX MODES, PARCE QU'UN SITE N'A PAS DE BOITES (2026-09-02).
#
# `complet` — le serveur de courriel d'un ecosysteme : il RECOIT, resout les
# destinataires dans l'annuaire LDAP et remet a Dovecot par LMTP. C'est le mode
# historique, et il reste le defaut.
#
# `relais` — le site. Il n'heberge aucune boite et n'a ni annuaire ni magasin : il
# EXPEDIE, et rien d'autre. C'est ce dont la supervision a besoin pour alerter.
#
# POURQUOI CE MODE PLUTOT QU'UN SECOND ROLE : les deux partagent tout ce qui compte —
# le certificat et sa resynchronisation, la resolution dans le chroot, la validation de
# la configuration. Un role jumeau aurait duplique ces quatre pieces, et c'est
# exactement le genre de duplication qui finit par diverger sans qu'on le voie.
#
# POURQUOI PAS UN RELAIS SORTANT EXTERNE : le site expedie DIRECTEMENT, par sa
# frontiere. Emprunter le MTA d'un locataire ferait dependre l'hebergeur d'un
# ecosysteme qu'il peut outvivre — une emancipation emporterait son alerte avec elle.
serveur_postfix_mode: "complet"
serveur_postfix_paquets:
- postfix
- postfix-ldap

View file

@ -2,6 +2,7 @@
- name: Resoudre l'annuaire (role partage)
ansible.builtin.include_role:
name: resoudre_annuaire
when: serveur_postfix_mode != "relais"
- name: Adopter la connexion annuaire pour Postfix
ansible.builtin.set_fact:
@ -10,18 +11,21 @@
serveur_postfix_ldap_bind_dn: "{{ resoudre_annuaire_bind_dn }}"
serveur_postfix_ldap_bind_password: "{{ resoudre_annuaire_bind_password }}"
no_log: true
when: serveur_postfix_mode != "relais"
- name: Exiger le mot de passe de bind LDAP (Vault)
ansible.builtin.assert:
that:
- serveur_postfix_ldap_bind_password | length > 0
fail_msg: "serveur_postfix_ldap_bind_password requis (via Ansible Vault, ex. vault_openldap_admin)."
when: serveur_postfix_mode != "relais"
- name: Exiger le nœud mail-store (remise LMTP)
ansible.builtin.assert:
that:
- serveur_postfix_mailstore_hote | length > 0
fail_msg: "serveur_postfix_mailstore_hote requis (FQDN du nœud Dovecot qui héberge les boîtes)."
when: serveur_postfix_mode != "relais"
- name: Installer debconf-utils (requis par le module debconf)
ansible.builtin.apt:
@ -170,6 +174,8 @@
# On redemarre donc sur tout changement de `main.cf`. C'est plus large que le seul
# `inet_protocols`, et c'est voulu : distinguer ligne par ligne ce qui se recharge de ce
# qui se redemarre demanderait de tenir a jour une liste que Postfix ne publie pas.
when: serveur_postfix_mode != "relais"
- name: Déployer main.cf
ansible.builtin.template:
src: main.cf.j2

View file

@ -15,12 +15,23 @@ mynetworks = {{ serveur_postfix_reseaux_confiance }}
mydestination =
local_recipient_maps =
{% if serveur_postfix_mode == 'relais' %}
{# UN RELAIS N'HEBERGE RIEN, IL EXPEDIE.
Pas de domaine virtuel, pas de carte LDAP, pas de remise LMTP : il n'y a aucune
boite ici. Ce qu'il faut, c'est que la supervision puisse SORTIR une alerte, et
qu'elle sorte au nom du SITE — pas d'un locataire. #}
relayhost =
{# `mynetworks` seul decide qui peut faire relayer : la loopback, donc les services de
cette machine, et rien d'autre. Un relais ouvert est repere et exploite en heures. #}
smtpd_relay_restrictions = permit_mynetworks, reject_unauth_destination
{% else %}
# --- Boîtes virtuelles : validation LDAP + remise LMTP vers le nœud mail-store (Dovecot) ---
virtual_mailbox_domains = {{ serveur_postfix_domaine }}
virtual_mailbox_maps = ldap:/etc/postfix/ldap-mailboxes.cf
virtual_transport = lmtp:inet:[{{ serveur_postfix_mailstore_hote }}]:{{ serveur_postfix_lmtp_port }}
# Résolution via le plancher /etc/hosts (indépendant du DNS) pour la remise interne.
lmtp_host_lookup = native
{% endif %}
{% if serveur_postfix_tls_actif %}
# --- TLS (certificat step_ca) ---

View file

@ -140,10 +140,28 @@ def cle_route(r: dict) -> str:
def _corps_route(r: dict, k: str, passerelle: str) -> dict:
"""Le corps d'une route — et le champ qui decide qu'elle EXISTE VRAIMENT.
`enabled`, ET NON `disabled` (mesure du 2026-09-02). Le modele de routes d'OPNsense
lit `enabled` ; on lui envoyait `disabled: "0"`, un champ qu'il ignore. `enabled`
restait donc a son defaut — c'est-a-dire ETEINT. Chaque route creee par Set-OPS
depuis l'origine l'etait desactivee.
POURQUOI PERSONNE NE L'AVAIT VU. Une route desactivee est presente dans le modele :
`frontiere-plan` la comptait « posee » et annoncait « 15 routes inchange ». Elle
n'etait simplement pas installee dans la table de routage. Tant que le boitier
n'avait pas ete recharge depuis leur creation, le noyau gardait les routes ajoutees
a la main lors de la mise en place — et tout fonctionnait.
Le jour ou la frontiere a ete rechargee completement (pour activer une nouvelle
patte), le noyau a repris sa table depuis le modele : quatorze routes sur quinze ont
disparu, et onze machines d'un locataire sont devenues injoignables. Le devis, lui,
restait vert. La panne dormait depuis des semaines dans un mot.
"""
return {
"network": r["reseau"],
"gateway": passerelle,
"disabled": "0",
"enabled": "1",
"descr": k,
}
@ -220,6 +238,46 @@ def _contenu(x) -> set[str]:
return {v.strip() for v in str(x or "").replace(",", "\n").split("\n") if v.strip()}
def routes_du_noyau(api: Frontiere) -> set[str] | None:
"""Les reseaux que la frontiere ROUTE VRAIMENT, lus dans sa table.
POURQUOI CE N'EST PAS REDONDANT AVEC LE MODELE (mesure du 2026-09-02).
Le reste de ce script compare le devis au MODELE de configuration : une route
presente dans `/api/routes/routes` est comptee « posee ». C'est ce qu'il faut pour
savoir quoi creer ou retirer — et ca ne dit RIEN de ce que le noyau porte.
Les deux ont diverge : un rechargement complet de la frontiere (`rc.reload_all`,
lance pour activer une nouvelle patte) a vide la table de routage de quatorze routes
sur quinze. Le modele les avait toujours ; `frontiere-plan` annoncait donc
« 15 routes inchange » pendant que ONZE MACHINES d'un locataire etaient injoignables.
Et la reconfiguration n'est declenchee que `if routes_creer or routes_retirer` —
aucun changement, donc aucune reparation : le devis etait vert et le service coupe.
Rend None si la frontiere ne sait pas repondre : on DIT qu'on n'a pas pu verifier,
on ne conclut pas que tout va bien. Une verification muette vaut moins que pas de
verification, parce qu'elle rassure.
"""
# EN GET, ET LA REPONSE EST UNE LISTE NUE (mesure du 2026-09-02). Les endpoints de
# diagnostic ne se lisent pas comme les modeles de configuration : pas de corps de
# requete — un POST rend un objet VIDE, sans erreur — et pas d'enveloppe `rows`, mais
# directement la liste des routes. Sondees en POST puis en `{"rows": ...}`, les deux
# tentatives precedentes rendaient « impossible de lire » sur une frontiere qui
# repondait parfaitement.
for chemin in ("/api/diagnostics/interface/getRoutes",
"/api/diagnostics/interface/get_routes"):
try:
rep = api(chemin)
except Exception:
continue
lignes = rep if isinstance(rep, list) else (rep.get("rows") or rep.get("items"))
if not lignes:
continue
return {str(r.get("destination") or "").strip() for r in lignes
if isinstance(r, dict)}
return None
def _lire(api: Frontiere, chemin: str, corps: dict | None = None) -> dict:
"""Une lecture du boitier — ou un REFUS. Jamais un silence pris pour du vide.
@ -290,7 +348,19 @@ def plan(api: Frontiere, devis: dict) -> dict:
if d.startswith("setopsroute:"):
rt_posees[d] = x
# Ce que le NOYAU porte vraiment — calcule ici parce que c'est ici qu'on a l'API,
# et transporte dans le plan pour que l'affichage puisse le dire.
_reelles = routes_du_noyau(api)
return {
"routes_noyau_lisible": _reelles is not None,
# Presente dans le modele mais ETEINTE : elle compte « posee » partout ailleurs,
# et n'est installee nulle part. C'est la forme exacte du defaut du 2026-09-02.
"routes_eteintes": sorted(
str(x.get("network") or "") for x in rt_posees.values()
if str(x.get("enabled", "1")) not in ("1", "True", "true")),
"routes_absentes_du_noyau": sorted(
r["reseau"] for k, r in rt_voulues.items()
if _reelles is not None and k in rt_posees and r["reseau"] not in _reelles),
"routes_creer": {k: r for k, r in rt_voulues.items() if k not in rt_posees},
"routes_garder": {k for k in rt_voulues if k in rt_posees},
"routes_retirer": {k: x for k, x in rt_posees.items() if k not in rt_voulues},
@ -336,6 +406,29 @@ def afficher(p: dict) -> bool:
f"-> {str(x.get('gateway'))}")
for n in sorted(p["alias_retirer"]):
print(f" - alias ORPHELIN {n}")
# ECRIRE, PUIS RELIRE — SUR LE NOYAU, PAS SUR LE MODELE (D-68, applique aux routes
# le 2026-09-02). Tout ce qui precede compare le devis au MODELE de configuration.
# Le noyau peut avoir perdu ce que le modele affirme, et le devis reste vert : c'est
# arrive, quatorze routes sur quinze, et onze machines d'un locataire injoignables
# pendant que `frontiere-plan` annoncait « 15 routes inchange ».
if not p.get("routes_noyau_lisible"):
print("\n ROUTES : impossible de lire la table du noyau — verification NON FAITE.")
else:
eteintes = p.get("routes_eteintes") or []
if eteintes:
print(f"\n ROUTES PRESENTES MAIS ETEINTES ({len(eteintes)}) — le modele les "
"porte, `enabled` est a 0, le noyau ne les installera pas :")
for r in eteintes:
print(f" ! {r}")
absentes = p.get("routes_absentes_du_noyau") or []
if absentes:
print(f"\n ROUTES DECLAREES MAIS ABSENTES DU NOYAU ({len(absentes)}) — "
"le modele les a, la table de routage non :")
for r in absentes:
print(f" ! {r}")
print(" Les reseaux ci-dessus ne sont PAS routes. Appliquer force leur "
"reinstallation.")
creer = len(p["alias_creer"]) + len(p["regles_creer"]) + len(p["nat_creer"]) \
+ len(p["routes_creer"])
retirer = len(p["regles_retirer"]) + len(p["alias_retirer"]) + len(p["nat_retirer"]) \
@ -406,7 +499,10 @@ def appliquer(api: Frontiere, p: dict) -> int:
if echecs:
print(f"\n {echecs} echec(s) — RIEN N'EST APPLIQUE, la config reste en attente.")
return 1
if p["routes_creer"] or p["routes_retirer"]:
# La reconfiguration ne se declenchait QUE sur un changement du modele. Le jour ou
# le noyau perd des routes que le modele a toujours, il n'y a aucun changement —
# donc aucune reparation, et `appliquer` rendait « OK » sur un routage casse.
if p["routes_creer"] or p["routes_retirer"] or p.get("routes_absentes_du_noyau"):
print(" routes :", api("/api/routes/routes/reconfigure/", {}).get("status", "?"))
print(" alias :", api("/api/firewall/alias/reconfigure/", {}).get("status", "?"))
print(" regles :", str(api("/api/firewall/filter/apply/", {}).get("status", "?")).strip())
@ -453,7 +549,12 @@ def main() -> int:
p = plan(api, devis)
p["_uuid_alias"] = {x["name"]: x["uuid"] for x in
(_lire(api, "/api/firewall/alias/searchItem/", {"rowCount": 1000}).get("rows") or [])}
if not afficher(p):
# UNE ROUTE ABSENTE DU NOYAU EST DU TRAVAIL, MEME SI LE MODELE EST A JOUR.
# `afficher` rend « rien a faire » quand le devis et le modele s'accordent. Ils
# s'accordaient le jour ou quatorze routes sur quinze manquaient dans la table de
# routage : le message disait vrai du modele et faux du service.
if not afficher(p) and not p.get("routes_absentes_du_noyau") \
and not p.get("routes_eteintes"):
print("\n La frontiere dit deja ce que le devis dit. Rien a faire.")
return 0

View file

@ -752,6 +752,38 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
# 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 and (set(_pairs_fl) & set(_roles_site)):
# UN ROLE DU SITE QUI EN APPELLE UN AUTRE — ET CE N'EST PAS
# « L'EXTERIEUR » (mesure du 2026-09-02).
#
# La branche precedente disait « rien d'autre n'entre chez le site
# depuis l'exterieur » et sautait le flux. C'etait vrai tant qu'un
# `pair` nomme designait quelque chose de lointain. Mais un `pair`
# peut nommer un ROLE DU SITE LUI-MEME, pose dans une AUTRE ZONE — et
# depuis le decoupage en zones, deux machines du site ne se parlent
# qu'a travers la frontiere.
#
# CE QUE CA A COUTE : `serveur_icinga` declare `ingress 5665` depuis
# `serveur_backup`, et `serveur_backup` declare l'`egress` en face.
# Les deux etaient justes. La regle d'entree n'existait pas, celle de
# sortie visait `!SETOPS_INTERNES` — le depot pouvait donc parler a
# un Icinga du monde entier SAUF a celui de son propre site. Le
# rapport passif expirait en 20 s, et la supervision qu'on venait de
# monter ne recevait rien.
#
# Une regle par ZONE SOURCE, comme pour `flotte` : le paquet penetre
# par la patte de la zone d'ou il part. La source reste le ROLE
# appelant, jamais sa zone entiere — c'est la difference entre
# « le depot peut joindre le temoin » et « la zone du depot le peut ».
_appelants = sorted(set(_pairs_fl) & set(_roles_site))
_portees = []
for _ap in _appelants:
_zones_ap = sorted({_if_de(m.get("reseau")) for m in _machines_site
if _ap == "serveur_debian"
or _ap in _services_site.get(m["nom"], [])})
_portees += [(nom_alias(_ap, "SITE"), z) for z in _zones_ap]
if not _portees:
continue
elif _entrant:
continue # rien d'autre n'entre chez le site depuis l'exterieur
else:

View file

@ -439,17 +439,42 @@ def _inventaire_site() -> dict:
def _sources_admin_ssh_site() -> list[str]:
"""`nftables_admin_ssh` du PLAN DU SITE — la meme garde, l'autre cote de la frontiere.
"""Par ou l'administration entre sur le SITE — DERIVE de la carte, plus recopie.
Le site n'a pas de `group_vars/all` : ses intrants vivent dans `plan/10-intrants.yml`.
Rendre [] ici priverait les regles de leur garde anti-verrouillage, donc on le REFUSE
plus haut plutot que de generer un jeu de regles qui coupe SSH.
L'exploitant administre PAR REBOND : la connexion vers une machine du site est
ouverte par la frontiere, et arrive avec l'adresse de SA PATTE DANS LA ZONE DE LA
MACHINE VISEE — pas avec celle du poste. Il faut donc la passerelle de chaque zone.
POURQUOI DERIVE (paye le 2026-09-02, une heure apres avoir ecrit le contraire).
Cette liste etait un intrant, ecrit a la main, avec ce commentaire dans le plan :
« oublier une seule de ces adresses rend une zone entiere inadministrable ». Une zone
a ete ajoutee le meme jour ; la liste ne l'a pas suivie ; `site-mon-01` s'est
retrouvee injoignable des l'application de son pare-feu, et il a fallu la rouvrir par
l'agent invite de l'hyperviseur.
Une liste recopiee retarde toujours d'une zone sur la carte. Celle-ci LIT la carte :
ajouter un reseau au site suffit, il n'y a plus rien a penser a mettre a jour.
L'intrant reste lu et AJOUTE a la derivation — un site peut avoir une voie
d'administration que la carte ne decrit pas (un VPN, un poste sur un lien hors
fabric). On elargit, on ne remplace pas.
"""
import underlay as underlay_mod
srcs: list[str] = []
u = underlay_mod.charger() or {}
for r in (u.get("reseaux") or []):
# Les zones du SITE seulement : les reseaux de fabric (management, ceph, iscsi)
# ne portent pas de machines du site et n'ont pas a ouvrir SSH chez elles.
if str(r.get("nom", "")).startswith("site-") and r.get("passerelle"):
srcs.append(f"{r['passerelle']}/32")
# La zone de pilotage en entier : le runner du site y vit, c'est de la qu'il
# deploie. Une passerelle seule ne suffirait pas a le laisser entrer.
if str(r.get("nom", "")) == "site-pilotage" and r.get("sous_reseau"):
srcs.append(str(r["sous_reseau"]))
src = (underlay_mod.lire_plan_site("10-intrants.yml") or {}).get("nftables_admin_ssh")
if not src:
return []
return [str(s) for s in src] if isinstance(src, list) else [str(src)]
if src:
srcs += [str(s) for s in src] if isinstance(src, list) else [str(src)]
return sorted(set(srcs))
def generer_nftables(flux_par_role: dict[str, list[dict]], site: bool = False) -> list[Path]:

View file

@ -403,6 +403,13 @@ def inventaire() -> dict:
# depend de l'ordre des plays est un reglage qui reviendra en arriere.
#
# Ici, tout play qui applique `ssh_hardening` a cet hote la voit.
# LE POSTFIX DU SITE EXPEDIE, IL NE RECOIT PAS.
#
# `serveur_postfix` est d'abord le serveur de courriel d'un ecosysteme : il
# resout ses destinataires dans un annuaire LDAP et remet a Dovecot. Le site
# n'a ni l'un ni l'autre, et n'heberge aucune boite — il lui faut seulement
# de quoi faire SORTIR une alerte, en son propre nom.
"serveur_postfix_mode": "relais",
# Le depot du site, pour les machines du site qui y deposent. Vide s'il n'y
# en a pas — `client_backup` refuse alors, plutot que de viser un nom qui
# ne repond pas : mieux vaut un deploiement qui s'arrete qu'une sauvegarde