supervision du site, et la panne qui dormait dans un mot
Some checks are pending
verifier / verifier (push) Waiting to run
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:
parent
cf9abe74b6
commit
4e12c3a802
10 changed files with 312 additions and 15 deletions
84
CHANGELOG.md
84
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) ---
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue