diff --git a/CHANGELOG.md b/CHANGELOG.md index 07bcd10..ce163eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,61 @@ # CHANGELOG — Set-OPS +## 2026-08-30 — Vingt-et-un plays : le resolveur ouvre la chaine, deux defauts tombent + +**55 preuves.** Le deploiement depuis `ops-01` passe de **3 plays a 21**. Douze machines +sur quinze sont deployees completement (`ok=91` a `137`, `failed=0`). Le resolveur du site +a debloque tout ce qui suivait. + +Trois echecs restants, deux causes — et les deux etaient de vrais defauts du moteur. + +### Un port symbolique ecrit tel quel dans le pare-feu + +``` +/etc/nftables.conf:36: Error: Could not resolve service: Servname not supported + ip saddr { ... } udp dport derive accept # serveur_powerdns +``` + +`port: derive` dit *« ce port depend du deploiement »* — Forgejo ecoute 3000 derriere un +edge et 443 quand il sert son propre TLS ; PowerDNS 53 seul sur son hote et 5300 en +loopback derriere le resolveur. **Seul le plan sait lequel.** Le devis de la frontiere le +resout depuis le 2026-08-25 ; le generateur nftables, lui, ecrivait le mot. + +Il resout desormais par le plan, et **n'emet rien** quand il ne peut pas — en le DISANT : +*un flux tu en silence est une porte qu'on croit ouverte.* Verifie que ca ne ferme rien : +`infra-dns-01` garde son 53, ouvert par `serveur_resolveur` ; l'omission de PowerDNS est +juste, puisqu'il ecoute en loopback derriere lui. + +**Et la validation ne pose pas la meme question que l'emission.** Ma premiere garde +refusait tout port non numerique — elle a fait echouer P09, qui valide les roles **hors +instance**, la ou `derive` est parfaitement legitime. `PORTS_SYMBOLIQUES` nomme le +vocabulaire : le mot passe a la validation, jamais dans un fichier, et un mot inconnu reste +refuse des deux cotes. *Confondre les deux questions coute des deux cotes : un fichier +casse d'un cote, un role correct declare fautif de l'autre.* + +### Recharger n'applique pas un changement d'ecoute — deuxieme fois + +``` +warning: ignoring inet_protocols parameter value change +warning: to change inet_protocols, stop and start Postfix +fatal: :::submission: Address family for hostname not supported +``` + +`main.cf` porte `inet_protocols`, que Postfix refuse de changer a chaud. Le `master` garde +`all`, tente d'ouvrir `:::submission` en IPv6, et meurt — **apres avoir accepte une +configuration valide**. `postfix check` ne dit rien, parce que la configuration EST valide : +c'est la TRANSITION qui ne l'est pas. + +Meme famille que `nginx : restart si changement d'ecoute`. Ce qui vit dans le processus +maitre — protocoles, adresses, ports — exige qu'il reparte. `main.cf` notifie desormais le +redemarrage. + +*Diagnostic corrige en chemin : j'ai d'abord accuse `postfix@-.service`, dont l'assertion +echouait sur `/etc/postfix-/main.cf`. C'etait MOI qui l'avais declenche en tentant un +demarrage manuel — `postfix.service` le declare en `Conflicts`. Le vrai journal etait +ailleurs.* + +make verifier : vert. make prouver : CONFORME, 55 OK, 0 echec, 0 saute. + ## 2026-08-30 — La cle de signature est versionnee : une lignee qui se reproduit sans Internet **55 preuves.** Le deploiement de Chezlepro depuis son propre runner a franchi le socle et diff --git a/roles/serveur_postfix/tasks/main.yml b/roles/serveur_postfix/tasks/main.yml index 8a57283..779764e 100644 --- a/roles/serveur_postfix/tasks/main.yml +++ b/roles/serveur_postfix/tasks/main.yml @@ -149,6 +149,27 @@ no_log: true # contient le mot de passe de bind LDAP notify: Recharger postfix +# REDEMARRER, PAS RECHARGER — ET POSTFIX LE DIT LUI-MEME (2026-08-30). +# +# `main.cf` porte `inet_protocols`, et ce parametre-la ne se change PAS a chaud. Sur une +# machine nee en `all` et passee en `ipv4`, le rechargement rend : +# +# warning: ignoring inet_protocols parameter value change +# warning: to change inet_protocols, stop and start Postfix +# fatal: :::submission: Address family for hostname not supported +# +# Le `master` garde donc son ancienne valeur, tente d'ouvrir `:::submission` en IPv6, et +# meurt. Le service tombe en `failed` APRES avoir accepte une configuration valide — +# `postfix check` ne dit rien, parce que la configuration EST valide ; c'est la +# transition qui ne l'est pas. +# +# MEME FAMILLE QUE `nginx-restart-si-changement-d-ecoute` : un rechargement ne reapplique +# pas un changement d'ECOUTE. Ce qui vit dans le processus maitre — protocoles, adresses, +# ports — exige qu'il reparte. +# +# 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. - name: Déployer main.cf ansible.builtin.template: src: main.cf.j2 @@ -156,7 +177,7 @@ owner: root group: root mode: "0644" - notify: Recharger postfix + notify: Redémarrer postfix - name: Activer la soumission :587 (clients authentifiés, SASL Dovecot distant) ansible.builtin.blockinfile: diff --git a/scripts/resoudre_flux.py b/scripts/resoudre_flux.py index 16ad2f8..310dd9a 100644 --- a/scripts/resoudre_flux.py +++ b/scripts/resoudre_flux.py @@ -307,7 +307,20 @@ CODES_ICMP = { } -def _selecteur_nft(protocole: str, port) -> str: +# LES PORTS QUI NE SONT PAS DES NOMBRES, ET QUI SONT LEGITIMES. +# +# `derive` dit « ce port depend du deploiement » : Forgejo ecoute 3000 derriere un edge et +# 443 quand il sert son propre TLS, PowerDNS 53 seul sur son hote et 5300 en loopback +# derriere le resolveur. Seul le PLAN sait lequel — le role, lui, ne peut que le nommer. +# +# Ce mot est donc VALIDE quand on verifie un role, et INTERDIT quand on emet un fichier : +# ce sont deux questions differentes, et les confondre coute des deux cotes. Ecrit tel +# quel dans un ruleset, `nft` refuse TOUT le fichier ; refuse a la validation, un role +# parfaitement correct devient une faute. +PORTS_SYMBOLIQUES = {"derive"} + + +def _selecteur_nft(protocole: str, port, resolu: bool = True) -> str: """Selecteur nftables pour un flux : `tcp dport 22`, ou l'equivalent ICMP. Un `port` non numerique sur de l'ICMP designe un code, jamais un port. Un code @@ -323,14 +336,59 @@ def _selecteur_nft(protocole: str, port) -> str: f"Flux ICMP avec `port: {port}` : l'ICMP n'a pas de port. Nommer un code " f"connu ({', '.join(sorted(CODES_ICMP))}) ou etendre CODES_ICMP." ) + # UN PORT SYMBOLIQUE NE DOIT JAMAIS ATTEINDRE LE FICHIER (2026-08-30). + # + # `port: derive` dit « ce port depend du deploiement ». Il etait ecrit TEL QUEL dans + # le ruleset, et `nft` le refusait a l'application — donc trois deploiements plus + # tard, sur la machine, avec un message qui parle de `ai_socktype` : + # + # /etc/nftables.conf:36: Error: Could not resolve service: Servname not supported + # ip saddr { ... } udp dport derive accept # serveur_powerdns + # + # L'appelant le resout depuis le plan AVANT d'arriver ici. S'il n'y arrive pas, il + # n'emet pas de regle — comme le devis OPNsense le fait depuis le 2026-08-25. Cette + # garde est le dernier filet : elle transforme un fichier casse en erreur de + # GENERATION, lisible la ou on peut encore la corriger. + mot = str(port).strip() + if not mot.isdigit(): + if mot in PORTS_SYMBOLIQUES and not resolu: + # Validation d'un role, hors instance : le mot est declare, le plan le + # resoudra. On rend un selecteur de forme, jamais ecrit dans un fichier. + return f"{p} dport <{mot}>" + raise ErreurFlux( + f"Flux {p} avec `port: {port}` : port non numerique" + + (" et non resolu par le plan" if mot in PORTS_SYMBOLIQUES + else f" et inconnu (attendus : un nombre, ou {', '.join(sorted(PORTS_SYMBOLIQUES))})") + + ". Le declarer au plan (`applications.yml`, cle `port`) ou ne pas emettre " + "la regle : ecrit tel quel, `nft` refuse tout le fichier." + ) return f"{p} dport {port}" +def _ports_du_plan() -> dict[str, str]: + """Port declare au plan pour chaque groupe — ce que `port: derive` vaut ici. + + MEME SOURCE QUE LE DEVIS DE LA FRONTIERE (`devis_opnsense._ports_du_plan_site`), pour + le plan du TENANT au lieu de celui du site. Un service dit `derive` quand son port + depend du deploiement : Forgejo ecoute 3000 derriere un edge et 443 quand il sert son + propre TLS. Seul le plan sait lequel. + """ + try: + from inventory_rules import plan_de + f = Path(plan_de()) / "applications.yml" + apps = (yaml.safe_load(f.read_text(encoding="utf-8")) or {}).get("applications") or {} + return {str(a["groupe"]): str(a["port"]) for a in apps.values() + if a.get("groupe") and a.get("port")} + except Exception: + return {} + + def generer_nftables(flux_par_role: dict[str, list[dict]]) -> list[Path]: data = yaml.safe_load(_inventaire().read_text(encoding="utf-8")) or {} actifs_noms = set(_hotes_du_groupe(data, "hotes_actifs")) ip_par_hote = _ip_par_hote(data) admin_ssh = _sources_admin_ssh() + ports_plan = _ports_du_plan() regles_admin = [ f" ip saddr {{ {cidr} }} tcp dport 22 accept # administration (garde anti-lockout, intrant nftables_admin_ssh)" for cidr in admin_ssh @@ -347,6 +405,25 @@ def generer_nftables(flux_par_role: dict[str, list[dict]]) -> list[Path]: if fl.get("sens") != "ingress": continue ports = fl["port"] if isinstance(fl["port"], list) else [fl["port"]] + # `derive` se resout par le plan ; sans lui on n'emet RIEN plutot qu'une + # regle que `nft` refusera — et l'on DIT ce qu'on ne pose pas. Un flux + # tu en silence est une porte qu'on croit ouverte. + if str(fl.get("protocole", "")).lower() in ("tcp", "udp"): + reels = [] + for pt in ports: + if str(pt).isdigit(): + reels.append(pt) + continue + connu = ports_plan.get(g) + if connu: + reels.append(connu) + else: + print(f"note : {g} declare un port `{pt}` que le plan ne " + f"resout pas — aucune regle emise pour {hote}.", + file=sys.stderr) + if not reels: + continue + ports = reels srcs = _resoudre_sources(data, fl.get("pair"), actifs_noms, ip_par_hote) saddr = "" if not srcs else "ip saddr { " + ", ".join(srcs) + " } " local = "localhost" in (fl.get("pair") if isinstance(fl.get("pair"), list) else [fl.get("pair")]) @@ -450,7 +527,10 @@ def main(argv: list[str]) -> int: ports = fl["port"] if isinstance(fl["port"], list) else [fl["port"]] for port in ports: try: - _selecteur_nft(fl.get("protocole", "tcp"), port) + # `resolu=False` : on verifie un ROLE, pas un fichier. Un + # port symbolique y est legitime — c'est le plan qui le + # resoudra, et `generer_nftables` refuse s'il n'y arrive pas. + _selecteur_nft(fl.get("protocole", "tcp"), port, resolu=False) except ErreurFlux as e: raise ErreurFlux(f"{role} : {e}") from e print(f"Flux coherents : {r} rôles, {t} flux, schéma + matrice OK.")