flux : l'ICMP n'a pas de port — nftables n'avait jamais demarre

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 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-06 21:47:54 -04:00
parent e276e76f32
commit 3786cc055b
3 changed files with 77 additions and 2 deletions

View file

@ -2,6 +2,37 @@
## 2026-08-06 — le chemin nord-sud devient dérivable ## 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 ### `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 Une VM ne peut pas s'installer sans résoudre des noms : `apt` en dépend. Or le DNS

View file

@ -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_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 | 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` | 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` | 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_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. | | `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 ## Synthèse chiffrement
- **clair** : 26 flux - **clair** : 28 flux
- **n-a** : 3 flux - **n-a** : 3 flux
- **ssh** : 3 flux - **ssh** : 3 flux
- **starttls** : 6 flux - **starttls** : 6 flux

View file

@ -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}) 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]: def generer_nftables(flux_par_role: dict[str, list[dict]]) -> list[Path]:
data = yaml.safe_load(_inventaire().read_text(encoding="utf-8")) or {} data = yaml.safe_load(_inventaire().read_text(encoding="utf-8")) or {}
actifs_noms = set(_hotes_du_groupe(data, "hotes_actifs")) 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: for port in ports:
if local and not srcs: if local and not srcs:
continue # lo deja accepte 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) contenu = _rendre_nft(hote, regles)
chemin = sortie_dir / f"{hote}.nft" chemin = sortie_dir / f"{hote}.nft"
chemin.write_text(contenu, encoding="utf-8") chemin.write_text(contenu, encoding="utf-8")
@ -282,6 +311,19 @@ def main(argv: list[str]) -> int:
flux = charger_flux() flux = charger_flux()
if commande == "verifier": if commande == "verifier":
r, t = valider(flux) 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.") print(f"Flux coherents : {r} rôles, {t} flux, schéma + matrice OK.")
elif commande == "registre": elif commande == "registre":
valider(flux) valider(flux)