From 92a396b3fe3c0a594cddd5d4d1e887a179107451 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Mon, 12 Oct 2026 01:57:43 -0400 Subject: [PATCH] forge du site : ses courriels partent, par le relais du site La forge nommait infra-mail-01 en dur comme relais : une machine de locataire, absente du site. Elle derive maintenant son relais du groupe serveur_postfix. Le relais du site admet les machines dont un role declare un flux vers lui, lues dans le registre des flux (aujourd'hui la forge). Deploye au site ; un courriel de mot de passe oublie est arrive et a servi. Aussi : forge-comptes ne compte plus de changement en mode plan. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 32 +++++++++++ Makefile | 1 + playbooks/maintenance/forge_comptes.yml | 5 +- roles/serveur_forgejo/defaults/main.yml | 12 +++- roles/serveur_forgejo/templates/app.ini.j2 | 2 +- roles/serveur_postfix/templates/main.cf.j2 | 4 +- scripts/site_inventaire.py | 25 +++++++++ scripts/tests/test_relais_du_site.py | 65 ++++++++++++++++++++++ 8 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 scripts/tests/test_relais_du_site.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d0184c..027f9e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ # CHANGELOG — Set-OPS +## 2026-10-12 (138) — La forge du site remet ses courriels + +**95 preuves.** `make verifier` passe ; `test_relais_du_site.py` est nouveau, et échoue sur +l'ancien défaut (témoin). Déployé au site après simulation ; éprouvé par Daniel : le lien +« mot de passe oublié » est arrivé et a servi. + +### Ce qui manquait + +« Mot de passe oublié » répondait 200 et la forge du site n'a jamais remis un seul courriel : +`lookup infra-mail-01.genese.internal ... server misbehaving`. Deux défauts, chacun suffisant : + +- **`serveur_forgejo_smtp` nommait `infra-mail-01` en dur** : la machine des boîtes d'un + locataire, où rien n'écoute le port 25 ; au site, ce nom n'existe pas. Il dérive désormais + de l'inventaire (l'hôte qui porte `serveur_postfix`) ; sans relais, le courriel de la forge + s'éteint (`ENABLED = false`) au lieu d'échouer en silence. Les forges des locataires visaient + la même machine : elles viseront `edge-mta-01` à leur prochain déploiement. +- **Le relais du site n'admettait que sa boucle locale** (`mynetworks`). `site_inventaire` + y ajoute les machines dont un rôle déclare un flux `egress` vers `serveur_postfix`, lues dans + le registre des flux (`reseaux_confiance_relais`) : aujourd'hui la forge seule. + +### Ce qui était déjà juste + +Mesuré, une fois l'instrument corrigé : la forge joint le relais (STARTTLS, certificat vérifié), +le relais sort sur le port 25 (`mx.chezlepro.ca` et Google répondent depuis 69.70.26.62), et +`mx.chezlepro.ca` accepte `forge@genese.internal`. Une première sonde (`head -c 120` sur une +bannière de 39 octets) avait fait croire ces chemins fermés. + +### Aussi + +`make forge-comptes` ne compte plus un changement en mode plan : le mot « jeton » de +« jeton a creer » trompait `changed_when`, qui lit désormais les seules lignes d'action. + ## 2026-10-11 (137) — Un mainteneur demande au site par un ticket, et la console du site le voit **95 preuves.** `make verifier` passe ; `test_forge_cles.py` couvre aussi les collaborations. diff --git a/Makefile b/Makefile index b9c20a6..ab79897 100644 --- a/Makefile +++ b/Makefile @@ -337,6 +337,7 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire) python3 scripts/tests/test_proxmox_fw_externe.py python3 scripts/tests/test_libelles_parefeu.py python3 scripts/tests/test_conditions_site.py + python3 scripts/tests/test_relais_du_site.py python3 scripts/tests/test_sonde_tcp.py python3 scripts/tests/test_restauration.py python3 scripts/tests/test_repertoires_partages.py diff --git a/playbooks/maintenance/forge_comptes.yml b/playbooks/maintenance/forge_comptes.yml index 534c003..85fa3d2 100644 --- a/playbooks/maintenance/forge_comptes.yml +++ b/playbooks/maintenance/forge_comptes.yml @@ -35,8 +35,9 @@ # En simulation, le script ne fait que montrer (sans --confirmer) : il peut tourner. check_mode: false register: forge_comptes - changed_when: >- - ['cree ', 'collab ', 'retire ', 'jeton '] | select('in', forge_comptes.stdout) | list | length > 0 + # LES LIGNES D'ACTION, PAS DES MOTS : « jeton a creer », dans le plan, contient « jeton » + # et faisait compter un changement alors que rien n'etait ecrit (2026-10-11). + changed_when: forge_comptes.stdout is search('(?m)^ (cree|collab|retire|equipe|jeton) ') # 1 = un ecart DIT (un courriel a remplir, par exemple) : il s'affiche, il n'empeche pas # le reste. 2 = le script a refuse de travailler. failed_when: forge_comptes.rc not in [0, 1] diff --git a/roles/serveur_forgejo/defaults/main.yml b/roles/serveur_forgejo/defaults/main.yml index b7476ea..5c5bce4 100644 --- a/roles/serveur_forgejo/defaults/main.yml +++ b/roles/serveur_forgejo/defaults/main.yml @@ -47,8 +47,16 @@ serveur_forgejo_tls_cle: "/etc/step/certs/{{ ansible_fqdn | default(ansible_host serveur_forgejo_groupe: "serveur_forgejo" serveur_forgejo_db_host: "" # dérivé (FQDN) par resoudre_base au déploiement -# Relais courriel. -serveur_forgejo_smtp: "infra-mail-01.{{ domaine_interne }}" +# Relais courriel : DERIVE de l'inventaire, l'hote qui porte `serveur_postfix` (2026-10-12). +# +# Le defaut nommait `infra-mail-01`, la machine des BOITES d'un locataire, ou rien n'ecoute +# le port 25 ; au site, ce nom n'existe meme pas. La forge du site n'a donc jamais remis un +# seul courriel : « mot de passe oublie » repondait 200 et le journal disait +# `lookup infra-mail-01.genese.internal ... server misbehaving`. Sans relais dans +# l'ecosysteme, la valeur est vide et le courriel de la forge s'eteint (`app.ini`). +serveur_forgejo_smtp: >- + {{ ((groups['serveur_postfix'] | default([]) | first) ~ '.' ~ domaine_interne) + if (groups['serveur_postfix'] | default([])) else '' }} # Secrets OBLIGATOIRES (Ansible Vault). serveur_forgejo_secret_key: "{{ vault_forgejo_secret_key | default('') }}" # rempli depuis la voute (vault_forgejo_secret_key) diff --git a/roles/serveur_forgejo/templates/app.ini.j2 b/roles/serveur_forgejo/templates/app.ini.j2 index 2cb4fe4..ac0d49c 100644 --- a/roles/serveur_forgejo/templates/app.ini.j2 +++ b/roles/serveur_forgejo/templates/app.ini.j2 @@ -68,7 +68,7 @@ ACCOUNT_LINKING = auto {% endif %} [mailer] -ENABLED = true +ENABLED = {{ 'true' if serveur_forgejo_smtp else 'false' }} PROTOCOL = smtp SMTP_ADDR = {{ serveur_forgejo_smtp }} SMTP_PORT = 25 diff --git a/roles/serveur_postfix/templates/main.cf.j2 b/roles/serveur_postfix/templates/main.cf.j2 index f5f7300..78531ef 100644 --- a/roles/serveur_postfix/templates/main.cf.j2 +++ b/roles/serveur_postfix/templates/main.cf.j2 @@ -22,7 +22,9 @@ local_recipient_maps = 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. #} + cette machine, et les machines dont un role declare un flux vers ce relais (la forge ; + au site, `site_inventaire.reseaux_confiance_relais`). 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) --- diff --git a/scripts/site_inventaire.py b/scripts/site_inventaire.py index 5a2d881..9b3178b 100755 --- a/scripts/site_inventaire.py +++ b/scripts/site_inventaire.py @@ -375,6 +375,8 @@ def inventaire() -> dict: for s in serveurs.values() if str(s.get("etat", "actif")) == "actif" and (par_reseau.get(s.get("reseau")) or {}).get("sous_reseau")}) + import resoudre_flux as _rf # noqa: PLC0415 — il importe ce module a son tour, plus tard + _relais_confiance = reseaux_confiance_relais(serveurs, _groupes_de, _rf.charger_flux()) # LE RESOLVEUR D'AMORCAGE SE DERIVE, IL NE S'ECRIT PAS (2026-08-25). # @@ -777,6 +779,11 @@ def inventaire() -> dict: # 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", + # QUI PEUT FAIRE RELAYER (2026-10-12). Le defaut du role n'admet que la boucle + # locale : juste tant qu'Icinga, sur la meme machine, etait le seul a ecrire. La + # forge du site envoie ses courriels (mot de passe oublie) par ce relais ; refusee, + # elle n'aurait rien pu remettre hors du site. Le registre des flux dit qui l'emploie. + "serveur_postfix_reseaux_confiance": _relais_confiance, # LE GRAFANA DU SITE N'A PAS DE SSO — pas par oubli, par D-87. # # Le SSO d'un tenant supposerait que le site emprunte l'identite d'un @@ -1056,6 +1063,24 @@ def inventaire() -> dict: return out +def reseaux_confiance_relais(serveurs: dict, groupes_de, flux_par_role: dict) -> str: + """`mynetworks` du relais du site : la boucle locale, et les machines qui le DECLARENT. + + Une machine peut faire relayer si l'un des roles qu'elle porte declare un flux `egress` + vers `serveur_postfix` (la forge, un `client_smtp`). Le registre des flux le dit deja ; + une liste ecrite ici le suivrait avec retard. Pur : `test_relais_du_site.py`. + """ + def _pairs(fl: dict) -> list: + p = fl.get("pair") + return p if isinstance(p, list) else [p] + emetteurs = {r for r, liste in flux_par_role.items() for fl in liste + if fl.get("sens") == "egress" and "serveur_postfix" in _pairs(fl)} + ips = sorted(f"{s['ip']}/32" for n, s in serveurs.items() + if str(s.get("etat", "actif")) == "actif" and s.get("ip") + and emetteurs & set(groupes_de(n))) + return " ".join(["127.0.0.0/8", "[::1]/128", *ips]) + + def _racine_backup_du_site() -> str: """La racine que `serveur_backup_site` passe a sa dependance `serveur_backup`.""" meta = Path(__file__).resolve().parent.parent / "roles" / "serveur_backup_site" / "meta" / "main.yml" diff --git a/scripts/tests/test_relais_du_site.py b/scripts/tests/test_relais_du_site.py new file mode 100644 index 0000000..99ed935 --- /dev/null +++ b/scripts/tests/test_relais_du_site.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""La forge du site trouve son relais de courriel, et le relais la laisse passer. + +POURQUOI (2026-10-12). « Mot de passe oublie » sur la forge du site n'a jamais remis un +courriel. Deux defauts, chacun suffisant : + 1. `serveur_forgejo_smtp` valait `infra-mail-01.` en dur : la machine des boites + d'un locataire, absente du site ; + 2. le relais du site (`mynetworks`) n'admettait que sa boucle locale. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +RACINE = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(RACINE / "scripts")) + +import site_inventaire as si # noqa: E402 +from resoudre_flux import charger_flux # noqa: E402 + + +def main() -> int: + echecs: list[str] = [] + + # 1. La forge ne nomme plus de machine : elle lit le groupe du relais. + defauts = (RACINE / "roles/serveur_forgejo/defaults/main.yml").read_text(encoding="utf-8") + ligne = next((l for l in defauts.splitlines() if l.startswith("serveur_forgejo_smtp:")), "") + bloc = defauts[defauts.find(ligne):].split("\n\n")[0] + if "infra-mail-01" in bloc or "groups['serveur_postfix']" not in bloc: + echecs.append("serveur_forgejo_smtp ne derive pas du groupe `serveur_postfix`") + + # 2. Le relais admet les emetteurs declares, et eux seuls (pur). + serveurs = {"forge": {"ip": "10.0.33.11"}, "edge": {"ip": "10.0.37.11"}, + "mon": {"ip": "10.0.36.11"}, "vieille": {"ip": "10.0.33.99", "etat": "retire"}} + groupes = {"forge": {"serveur_forgejo"}, "edge": {"serveur_nginx"}, + "mon": {"serveur_postfix", "serveur_icinga"}, "vieille": {"serveur_forgejo"}} + obtenu = si.reseaux_confiance_relais(serveurs, lambda n: groupes[n], charger_flux()).split() + if obtenu != ["127.0.0.0/8", "[::1]/128", "10.0.33.11/32"]: + echecs.append(f"reseaux_confiance_relais = {obtenu}, attendu boucle + la forge seule") + + # 3. Sur le site monte, s'il l'est : la machine du relais admet sa forge. + try: + inv = si.inventaire() + except Exception: # noqa: BLE001 — aucun site monte : la partie pure suffit + inv = {} + hv = (inv.get("_meta") or {}).get("hostvars") or {} + relais = (inv.get("serveur_postfix") or {}).get("hosts") or [] + forges = (inv.get("serveur_forgejo") or {}).get("hosts") or [] + for r in relais: + confiance = str((hv.get(r) or {}).get("serveur_postfix_reseaux_confiance") or "").split() + for f in forges: + ip = f"{(hv.get(f) or {}).get('ansible_host')}/32" + if ip not in confiance: + echecs.append(f"{r} n'admet pas sa forge {f} ({ip}) : {confiance}") + + for e in echecs: + print(f"ECHEC : {e}") + if not echecs: + print(f"OK : la forge lit son relais ; le relais admet ses emetteurs declares" + f"{' (site monte verifie)' if relais else ''}.") + return 1 if echecs else 0 + + +if __name__ == "__main__": + raise SystemExit(main())