From 3786cc055b099f65755776d4acdba786b5d3cd72 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Thu, 6 Aug 2026 21:47:54 -0400 Subject: [PATCH] =?UTF-8?q?flux=20:=20l'ICMP=20n'a=20pas=20de=20port=20?= =?UTF-8?q?=E2=80=94=20nftables=20n'avait=20jamais=20demarre?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le generateur emettait `{protocole} dport {port}` pour tout flux. L'ICMP a un type et un code, pas un port : `icmp dport frag-needed` produit un jeu que `nft` rejette, et un jeu rejete ne se charge PAS — l'hote perd sa barriere au lieu d'en gagner une. Le defaut touchait les 14 hotes. La seconde barriere de D-31 n'avait jamais pu demarrer nulle part ; personne ne l'avait vu parce qu'aucune VM tenant n'avait encore ete deployee. `_selecteur_nft()` traduit : `icmp frag-needed` devient `icmp type destination-unreachable icmp code frag-needed`. Un code inconnu est refuse a la generation, avec le nom du role fautif. La garde tourne aussi a la verification, pour attraper un flux declare mais pas encore porte. Mesure : nftables actif sur infra-pki-01 et infra-dns-01, 16 et 17 regles, dont la garde anti-lockout. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 31 ++++++++++++++++++++++++++++ docs/registre-flux.md | 4 +++- scripts/resoudre_flux.py | 44 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f50fc24..7653f23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,37 @@ ## 2026-08-06 — le chemin nord-sud devient dérivable +### L'ICMP n'a pas de port — et la seconde barrière n'avait jamais démarré + +`nftables.service` refusait de démarrer sur la première VM déployée : + +``` +/etc/nftables.conf:23 icmp dport frag-needed accept + ^^^^^ syntax error, unexpected string +``` + +Le générateur émettait `{protocole} dport {port}` pour **tout** flux. L'ICMP n'a pas de +port : il a un type et un code. Les deux flux PMTUD de D-30 produisaient donc un jeu que +`nft` rejette — et un jeu rejeté ne se charge pas du tout, si bien que l'hôte **perd** sa +barrière au lieu d'en gagner une. + +Le défaut touchait les quatorze hôtes. La seconde barrière de D-31 — les nftables d'hôte, +qui doublent le filtrage de l'hyperviseur — n'avait jamais pu démarrer nulle part. Personne +ne s'en était aperçu parce qu'aucune VM tenant n'avait encore été déployée. + +`_selecteur_nft()` traduit désormais : `tcp dport 22` reste tel quel, +`icmp frag-needed` devient `icmp type destination-unreachable icmp code frag-needed`. Un +code ICMP inconnu est **refusé à la génération**, avec le nom du rôle fautif. + +**La garde tourne aussi à la vérification**, pas seulement à la génération. Un rôle peut +déclarer un flux que personne ne porte encore : le devis passerait, et la panne arriverait +le jour où un hôte prend ce rôle. `nft -c` en local demanderait des privilèges netlink ; +construire le sélecteur ne coûte rien et attrape exactement la même faute. + +Mesuré après correction : `nftables=active` sur les deux VM, 16 et 17 règles chargées, dont +la garde anti-lockout `ip saddr 10.0.0.0/24 tcp dport 22 accept`. + + ### `client_unbound` devient universel — et l'amorçage DNS trouve sa place Une VM ne peut pas s'installer sans résoudre des noms : `apt` en dépend. Or le DNS diff --git a/docs/registre-flux.md b/docs/registre-flux.md index 121cb16..ca30fe9 100644 --- a/docs/registre-flux.md +++ b/docs/registre-flux.md @@ -66,6 +66,8 @@ | `serveur_postgresql` | ingress | 5432 | tcp | serveur_keycloak, serveur_forgejo, serveur_icinga, serveur_nextcloud | tls-requis | Connexions applicatives à PostgreSQL (verify-full ; pg_hba hostssl). | | `serveur_powerdns` | ingress | 53 | udp | flotte | clair | Résolution DNS interne (zone souveraine). DoT/DoH = feuille de route (chiffrement DNS). | | `serveur_powerdns` | ingress | 53 | tcp | flotte | clair | Résolution DNS interne en TCP (réponses volumineuses, AXFR restreint par allow_axfr_ips). | +| `serveur_powerdns` | egress | 53 | udp | externe | clair | Résolution sortante du serveur autoritatif POUR SES PROPRES besoins (apt, NTP) — il ne récurse pour aucun autre hôte. | +| `serveur_powerdns` | egress | 53 | tcp | externe | clair | Repli TCP de la résolution sortante du serveur autoritatif (réponses dépassant la taille UDP). | | `serveur_prometheus` | ingress | 9090 | tcp | localhost | clair | Console Prometheus consommée en local par Grafana co-localisé (pas d'exposition inter-nœud). | | `serveur_prometheus` | egress | 9100 | tcp | client_metrique | tls | Scrape des node_exporter (HTTPS via cert step-ca) sur chaque nœud instrumenté. | | `serveur_redis` | ingress | 6379 | tcp | localhost | clair | Cache/verrous consommés uniquement par l'application co-localisée (ex. Nextcloud). Aucune exposition inter-nœud. | @@ -79,7 +81,7 @@ ## Synthèse chiffrement -- **clair** : 26 flux +- **clair** : 28 flux - **n-a** : 3 flux - **ssh** : 3 flux - **starttls** : 6 flux diff --git a/scripts/resoudre_flux.py b/scripts/resoudre_flux.py index e811e4b..13104c4 100644 --- a/scripts/resoudre_flux.py +++ b/scripts/resoudre_flux.py @@ -210,6 +210,34 @@ def _resoudre_sources(data: dict, pair, actifs_noms: set[str], ip_par_hote: dict return sorted({ip_par_hote[h] for h in noms if h in ip_par_hote}) +# Codes ICMP de destination inatteignable qu'un flux peut nommer dans `port`. L'ICMP n'a +# PAS de port : il a un type et un code. Emettre `icmp dport frag-needed` produit un jeu +# que `nft` refuse — et le service ne demarre plus du tout, donc l'hote perd sa barriere +# au lieu d'en gagner une. Constate le 2026-08-06 sur les 14 hotes a la fois. +CODES_ICMP = { + "frag-needed": "icmp type destination-unreachable icmp code frag-needed", +} + + +def _selecteur_nft(protocole: str, port) -> 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 + inconnu est refuse ici plutot que de produire un fichier que `nft` rejettera — + l'erreur se lit au moment de la generation, pas trois deploiements plus tard. + """ + p = str(protocole).lower() + if p in ("icmp", "icmpv6"): + cle = str(port).strip() + if cle in CODES_ICMP: + return CODES_ICMP[cle] + raise ErreurFlux( + 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." + ) + return f"{p} dport {port}" + + 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")) @@ -237,7 +265,8 @@ def generer_nftables(flux_par_role: dict[str, list[dict]]) -> list[Path]: for port in ports: if local and not srcs: continue # lo deja accepte - regles.append(f" {saddr}{fl['protocole']} dport {port} accept # {g}: {fl.get('raison','')}") + regles.append(f" {saddr}{_selecteur_nft(fl['protocole'], port)} accept" + f" # {g}: {fl.get('raison','')}") contenu = _rendre_nft(hote, regles) chemin = sortie_dir / f"{hote}.nft" chemin.write_text(contenu, encoding="utf-8") @@ -282,6 +311,19 @@ def main(argv: list[str]) -> int: flux = charger_flux() if commande == "verifier": r, t = valider(flux) + # Chaque flux doit produire un selecteur nftables VALIDE. Le verifier ici, et + # pas seulement a la generation, ferme le cas ou un role declare un flux que + # personne ne porte encore : le devis passerait, et la panne arriverait le + # jour ou un hote prend ce role. `nft -c` local demanderait des privileges + # (netlink) ; construire le selecteur ne coute rien et attrape la meme faute. + for role, liste in sorted(flux.items()): + for fl in liste: + ports = fl["port"] if isinstance(fl["port"], list) else [fl["port"]] + for port in ports: + try: + _selecteur_nft(fl.get("protocole", "tcp"), port) + except ErreurFlux as e: + raise ErreurFlux(f"{role} : {e}") from e print(f"Flux coherents : {r} rôles, {t} flux, schéma + matrice OK.") elif commande == "registre": valider(flux)