diff --git a/CHANGELOG.md b/CHANGELOG.md index d115f98..8f335ca 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/docs/audit/preuve-2026-09-02.md b/docs/audit/preuve-2026-09-02.md index 8784db0..45c5696 100644 --- a/docs/audit/preuve-2026-09-02.md +++ b/docs/audit/preuve-2026-09-02.md @@ -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 | diff --git a/roles/serveur_backup_site/meta/main.yml b/roles/serveur_backup_site/meta/main.yml index 0f188b8..1eb477f 100644 --- a/roles/serveur_backup_site/meta/main.yml +++ b/roles/serveur_backup_site/meta/main.yml @@ -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 diff --git a/roles/serveur_postfix/defaults/main.yml b/roles/serveur_postfix/defaults/main.yml index 1210fa8..b06b0ca 100644 --- a/roles/serveur_postfix/defaults/main.yml +++ b/roles/serveur_postfix/defaults/main.yml @@ -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 diff --git a/roles/serveur_postfix/tasks/main.yml b/roles/serveur_postfix/tasks/main.yml index 779764e..b7a6805 100644 --- a/roles/serveur_postfix/tasks/main.yml +++ b/roles/serveur_postfix/tasks/main.yml @@ -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 diff --git a/roles/serveur_postfix/templates/main.cf.j2 b/roles/serveur_postfix/templates/main.cf.j2 index 078d9d1..f5f7300 100644 --- a/roles/serveur_postfix/templates/main.cf.j2 +++ b/roles/serveur_postfix/templates/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) --- diff --git a/scripts/appliquer_opnsense.py b/scripts/appliquer_opnsense.py index 295aabf..e702bf0 100644 --- a/scripts/appliquer_opnsense.py +++ b/scripts/appliquer_opnsense.py @@ -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 diff --git a/scripts/devis_opnsense.py b/scripts/devis_opnsense.py index cc76e67..cf6d648 100644 --- a/scripts/devis_opnsense.py +++ b/scripts/devis_opnsense.py @@ -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: diff --git a/scripts/resoudre_flux.py b/scripts/resoudre_flux.py index 33fab21..8e62b64 100644 --- a/scripts/resoudre_flux.py +++ b/scripts/resoudre_flux.py @@ -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]: diff --git a/scripts/site_inventaire.py b/scripts/site_inventaire.py index 6202dcb..7768234 100755 --- a/scripts/site_inventaire.py +++ b/scripts/site_inventaire.py @@ -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