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 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-10-12 01:57:43 -04:00
parent 548d35b1b5
commit 92a396b3fe
8 changed files with 140 additions and 6 deletions

View file

@ -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.

View file

@ -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

View file

@ -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]

View file

@ -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)

View file

@ -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

View file

@ -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) ---

View file

@ -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"

View file

@ -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.<domaine>` 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())