flux + postfix : un port symbolique dans le pare-feu, un rechargement qui n applique rien

Le deploiement depuis ops-01 passe de 3 plays a 21 : douze machines sur quinze
deployees completement. Deux defauts restaient.

UN PORT SYMBOLIQUE ECRIT TEL QUEL.

    /etc/nftables.conf:36: Could not resolve service: Servname not supported
        ip saddr { ... } udp dport derive accept   # serveur_powerdns

`port: derive` dit que le port depend du deploiement. Le devis de la frontiere le
resout depuis le 2026-08-25 ; le generateur nftables ecrivait le mot, et nft
refusait TOUT le fichier. 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 par
serveur_resolveur, et l omission de PowerDNS est juste puisqu il ecoute en loopback
derriere lui.

LA VALIDATION NE POSE PAS LA MEME QUESTION QUE L EMISSION. Ma premiere garde
refusait tout port non numerique et a fait echouer P09, qui valide les roles HORS
instance — la ou derive est 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.

RECHARGER N APPLIQUE PAS UN CHANGEMENT D ECOUTE, deuxieme fois.

    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. main.cf
notifie desormais le redemarrage.

Diagnostic corrige en chemin : j ai d abord accuse postfix@-.service, dont
l assertion echouait — c est moi qui l avais declenche par un demarrage manuel,
postfix.service le declare en Conflicts.

make verifier : vert. make prouver : CONFORME, 55 OK, 0 echec, 0 saute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
This commit is contained in:
Daniel Allaire 2026-08-30 13:00:54 -04:00
parent a5c9a88f00
commit fde0903ed8
3 changed files with 160 additions and 3 deletions

View file

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

View file

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

View file

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